@zalom/plastic 1.1.0 → 1.1.2

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 (63) hide show
  1. package/PLASTIC.md +12 -9
  2. package/README.md +3 -3
  3. package/agents/plastic-enforcer.md +8 -4
  4. package/agents/plastic-executor.md +5 -5
  5. package/agents/plastic-future-intent-researcher.md +1 -1
  6. package/agents/plastic-planner.md +15 -11
  7. package/hooks/check-update +1 -1
  8. package/hooks/continue +1 -1
  9. package/package.json +1 -1
  10. package/scripts/dashboard.rb +29 -24
  11. package/scripts/doctor.rb +180 -5
  12. package/scripts/hook-continue +3 -3
  13. package/scripts/install.rb +2 -1
  14. package/scripts/lib/bridge.rb +114 -11
  15. package/scripts/lib/dashboard_banner.rb +8 -9
  16. package/scripts/lib/installer_core.rb +12 -3
  17. package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
  18. package/scripts/lib/release_guard.rb +62 -0
  19. package/scripts/lib/roadmap_queue.rb +285 -0
  20. package/scripts/lib/roadmap_savepoint.rb +213 -0
  21. package/scripts/lib/skill_lint.rb +304 -0
  22. package/scripts/lib/worktree.rb +21 -0
  23. package/scripts/new-intent +1 -0
  24. package/scripts/read-config +3 -3
  25. package/scripts/roadmap-next +44 -0
  26. package/scripts/roadmap-savepoint +64 -0
  27. package/scripts/skill-lint +50 -0
  28. package/skills/auto/SKILL.md +32 -11
  29. package/skills/auto/references/tiers.md +4 -3
  30. package/skills/continuing/SKILL.md +34 -0
  31. package/skills/continuing/evals/evals.json +91 -0
  32. package/skills/dashboard/SKILL.md +17 -14
  33. package/skills/dashboard/references/classification.md +3 -3
  34. package/skills/dashboard/templates/dashboard-global.md +8 -23
  35. package/skills/dashboard/templates/dashboard-project.md +7 -26
  36. package/skills/doctor/SKILL.md +1 -1
  37. package/skills/install/SKILL.md +10 -10
  38. package/skills/intent-continuing/SKILL.md +26 -68
  39. package/skills/intent-continuing/evals/evals.json +26 -26
  40. package/skills/intent-continuing/references/context-management.md +15 -19
  41. package/skills/intent-planning/SKILL.md +11 -11
  42. package/skills/intent-planning/evals/evals.json +20 -5
  43. package/skills/intent-planning/references/plan-format.md +9 -5
  44. package/skills/intent-savepoint/SKILL.md +12 -0
  45. package/skills/intent-starting/evals/evals.json +1 -1
  46. package/skills/project-continuing/SKILL.md +104 -0
  47. package/skills/project-continuing/evals/evals.json +100 -0
  48. package/skills/project-continuing/references/board-fill.md +33 -0
  49. package/skills/releasing/SKILL.md +48 -0
  50. package/skills/releasing/references/release-lines.md +105 -0
  51. package/skills/roadmap/SKILL.md +7 -1
  52. package/skills/roadmap/references/file-format.md +30 -1
  53. package/skills/roadmap/references/operations.md +26 -6
  54. package/skills/roadmap-continuing/SKILL.md +85 -0
  55. package/skills/roadmap-continuing/evals/evals.json +82 -0
  56. package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
  57. package/skills/skill-evaluating/evals/evals.json +1 -1
  58. package/skills/tutorial/references/track-1-guided.md +5 -4
  59. package/skills/tutorial/references/track-2-auto.md +1 -1
  60. package/skills/uninstall/SKILL.md +2 -2
  61. package/skills/update/SKILL.md +2 -2
  62. package/templates/config.yml +2 -1
  63. package/templates/index.md +4 -1
package/PLASTIC.md CHANGED
@@ -19,7 +19,7 @@ store/
19
19
  plan.md # optional - implementation plan (How deliverable)
20
20
  checklist.md # optional - execution registry (How deliverable)
21
21
  outcome.md # optional - detailed result (Exec deliverable)
22
- actions/ # optional - individual work items
22
+ actions/ # optional - real action files (How deliverable, at least one required)
23
23
  resources/ # optional - research, references, screenshots, diagrams
24
24
  savepoint.md # optional - deterministic cycle-step ledger (auto-written)
25
25
  revisions.md # optional - append-only structural-maintenance audit trail
@@ -95,7 +95,7 @@ tags: [plastic, architecture]
95
95
  |-------|---------|-------------|--------|
96
96
  | **What** | `## Intent` | `{ID}--{slug}.md` | `plastic-intent-creating` |
97
97
  | **Why** | `## Context` + Decisions | `spec.md` | `plastic-intent-brainstorming` |
98
- | **How** | Planning | `plan.md` + `actions/` + `checklist.md` | `plastic-intent-planning` |
98
+ | **How** | Planning | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `plastic-intent-planning` |
99
99
  | **Exec** | Execution | `outcome.md` | `plastic-intent-executing` |
100
100
 
101
101
  `## Insights` is the append-only log of durable discoveries captured throughout ALL stages.
@@ -133,8 +133,9 @@ same-structure invariant holds: same file set, stage order, gates, and savepoint
133
133
  every tier and in both modes.
134
134
 
135
135
  S/M collapse the topology (one thinker agent writes spec.md then plan.md plus
136
- checklist.md in one context; actions/ only for L; a sonnet executor implements). L keeps
137
- the full team.
136
+ checklist.md plus at least one real action file in one context, consolidated into a single
137
+ actions/ACTION_1.md at S/M and one file per task at L; a sonnet executor implements). L
138
+ keeps the full team.
138
139
 
139
140
  Never cut at any tier: the independent reviewer, outcome.md as truth of delivery, the
140
141
  delivery lock, worktree isolation, intent creation via skill, INDEX as status truth, the
@@ -167,8 +168,10 @@ and Exec, spins up the team above, and works the dashboard's dispatchable queue.
167
168
  `human_only` (intents that need a person); auto mode consumes the former.
168
169
 
169
170
  **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:
171
+ its own frontmatter: `opus`, `sonnet`, or `haiku`. Never `inherit`, never Fable by default,
172
+ unless an explicit `agents.models.<name>` config override names Fable for that role, in which
173
+ case the override is honored as written. Aliases track "latest per tier" so no Plastic release
174
+ is required to advance a tier. The tier by role:
172
175
  `plastic-enforcer`, `plastic-brainstorming`, `plastic-planner` are `opus`;
173
176
  `plastic-spec-specialist`, `plastic-executor`, `plastic-intent-curator`,
174
177
  `plastic-future-intent-researcher`, `plastic-intent-discovery` are `sonnet`.
@@ -423,8 +426,8 @@ purge. See `docs/internals.md` for depth.
423
426
  | Transition | Trigger | Gate |
424
427
  |---|---|---|
425
428
  | What → Why | `spec.md` written | (none) |
426
- | Why → How | `plan.md` + `actions/` + `checklist.md` | `spec.md` must exist |
427
- | How → Exec | Checklist has items | Plan triplet must exist |
429
+ | Why → How | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `spec.md` must exist |
430
+ | How → Exec | Checklist has items | plan.md, checklist.md, and at least one real actions/ACTION_N.md must exist |
428
431
  | Exec → Done | `outcome.md` written | All checklist items checked |
429
432
 
430
433
  Hard blocking: hooks exit code 2 on gate failure.
@@ -528,7 +531,7 @@ each station.
528
531
  | Start (board) | none (a procedure, not a stage) | `plastic-lock fix` self-heals stale, corrupt, or legacy state; arm acquires `delivery.lock` (O_EXCL, session-keyed), provisions the code worktree, writes the bridge cache | lock-gate denies any write into an active intent dir without this intent's lock; every deny names the resolving command | savepoint confirms the boarding station |
529
532
  | What (create) | `<id>--<slug>.md`, born complete | no lock yet; no bridge | create-gate validates the proposed intent content (Write, Edit, and MCP edits) | savepoint `What` line; intent listed in INDEX `## Active` |
530
533
  | Why | `spec.md` | owner writes refresh the lease (lock file mtime heartbeat) | gate-check requires the intent file with `## Intent` before spec.md; lock-gate admits only the owner or a delegate | savepoint `Why started`, `Why spec.md created` |
531
- | How | `plan.md`, `actions/`, `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md plus checklist.md exist | gate-check requires spec.md before plan.md, and plan.md plus actions/ before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
534
+ | How | `plan.md`, `actions/ACTION_N.md` (at least one), `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md, checklist.md, and a real action file all exist | gate-check requires spec.md before plan.md, and plan.md plus a real actions/ACTION_N.md before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
532
535
  | Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock; bash, interpreter, and MCP writes gated the same way | code-gate, worktree-gate, bash-gate, lock-gate | checklist boxes; savepoint milestones |
533
536
  | End (done) | mandatory `outcome.md` (`disposition: delivered\|abandoned`), INDEX moves to Completed or Abandoned | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, then the bridge is purge-eligible, and the QMD reindex runs LAST (after purge) | gate-check blocks outcome.md while checklist items are unchecked | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
534
537
  | Maintenance (any stage) | `revisions.md` move-and-record entries | future `maintenance.lock` (short TTL), mutually exclusive with `delivery.lock` in either direction; 108 ships the schema seam only, the implementation follows intent 93 in a chained intent | acquisition refuses while the other lock type is fresh; a terminal intent with no lock held is read-only | dated, rule-tagged `revisions.md` entry; savepoint untouched |
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
 
@@ -29,8 +31,9 @@ subagents keep their pinned tier and never resolve to Fable.
29
31
  4. **Sequence the team** — dispatch specialists per the chosen topology with a constructed
30
32
  context bundle:
31
33
  - S/M: ONE thinker agent, one boot, two stations — it writes spec.md, then plan.md +
32
- checklist.md, in a single context. Sections may be one line each; plan.md carries the
33
- checklist rationale inline; `actions/` files appear only for L. S may skip the QMD
34
+ checklist.md + at least one real action file, in a single context. Sections may be one
35
+ line each; the thinker writes one consolidated `actions/ACTION_1.md` (never an empty
36
+ `actions/`), while L writes one `actions/ACTION_N.md` per task. S may skip the QMD
34
37
  discovery deposit when chain and sources are both empty. A sonnet executor implements.
35
38
  - L: today's full team, one specialist per stage (brainstorming, spec-specialist, planner,
36
39
  executor), each in a fresh context.
@@ -42,7 +45,8 @@ dispatch also resolve the target agent's model through the config chain (`read-c
42
45
  agents.models.<basename> --project <repo>`: project override, then global, then the shipped
43
46
  tier default) and pass it explicitly as the dispatch call's model parameter, alongside the
44
47
  spawn-preamble live-state injection. Never rely on the dispatched role's frontmatter alone. A
45
- resolved subagent model is never Fable.
48
+ resolved subagent model is never Fable, unless an explicit `agents.models.<name>` config
49
+ override names Fable for that role, in which case the override is honored as written.
46
50
  5. **Gate each handoff** — check each stage deliverable against its exit criteria before handing to the next stage
47
51
  6. **Run the final review** — at the final gate, dispatch an INDEPENDENT reviewer subagent (not a sixth standing role)
48
52
 
@@ -19,11 +19,11 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
19
19
 
20
20
  ## How You Work
21
21
 
22
- 1. Receive (input handoff): `plan.md` and `checklist.md` from the planner (plus
23
- `ACTION_N.md` files inside `actions/` at L; at S/M the `actions/` directory exists but
24
- stays empty, so read the tier fork from the inline plan-as-checklist in plan.md
25
- instead). For S/M intents you run on the sonnet default, the collapsed topology's
26
- implementer; behavior is otherwise unchanged.
22
+ 1. Receive (input handoff): `plan.md`, `checklist.md`, and at least one real `ACTION_N.md`
23
+ inside `actions/` from the planner (S/M hand you one consolidated `actions/ACTION_1.md`;
24
+ L hands you one `actions/ACTION_N.md` per task). Execute the action files in order. For
25
+ S/M intents you run on the sonnet default, the collapsed topology's implementer; behavior
26
+ is otherwise unchanged.
27
27
  2. Work one action at a time, preferring safe, non-destructive routes
28
28
  3. Edit project code (the gate is open now that plan and checklist exist)
29
29
  4. Run the full suite, iterate to zero failures and zero errors
@@ -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
@@ -14,14 +14,17 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
14
14
 
15
15
  1. **Decompose the spec** — break the approach into ordered, independent actions
16
16
  2. **Write the plan** — produce `plan.md` with numbered tasks and verification
17
- 3. **Write self-contained actions, tier-forked**:
18
- - S/M: `plan.md` carries the checklist rationale INLINE; do NOT write
19
- `actions/ACTION_N.md` files. Sections may be one line each.
20
- - L: keep today's shape one self-contained `actions/ACTION_N.md` per task.
17
+ 3. **Write at least one real action file at every tier, tier-forked**:
18
+ - S/M: write ONE self-contained `actions/ACTION_1.md` that consolidates the whole
19
+ ordered delivery (the steps plus the exact changes). You MAY split into a few files
20
+ when that reads cleaner, but one real action file is the floor.
21
+ - L: one self-contained `actions/ACTION_N.md` per task. A `.gitkeep` never counts as an
22
+ action, and an empty `actions/` fails the How gate.
21
23
  4. **Write the checklist** — `checklist.md` as the execution registry covering every action.
22
- `checklist.md` exists at every tier: the file set does not change by tier, only depth
23
- (inline vs action files) and agent topology do. plan.md + checklist.md are the two files
24
- that open the code gate at every tier.
24
+ `checklist.md` and at least one real action file exist at every tier: the file set does not
25
+ change by tier, only action DEPTH (one consolidated action for S/M vs one-per-task for L)
26
+ and agent topology do. plan.md, checklist.md, AND a real `actions/ACTION_N.md` are what open
27
+ the code gate at every tier.
25
28
 
26
29
  ## How You Work
27
30
 
@@ -38,7 +41,7 @@ END your turn with a structured completion report as your final message, per the
38
41
  - The ordered actions, one line each: what the action does and how it is verified
39
42
  - Decomposition rationale: why this order, and why the actions are independent
40
43
  - Checklist coverage: the item count, and that every action plus suite-green is covered
41
- - Which tier shape was produced: inline plan-as-checklist (S/M) or full `actions/` (L)
44
+ - Which tier shape was produced: one consolidated `actions/ACTION_1.md` (S/M) or one `actions/ACTION_N.md` per task (L)
42
45
 
43
46
  The plan is an argument; the orchestrator gates on whether that argument is sound before any code is written, so make the report make that case.
44
47
 
@@ -46,7 +49,8 @@ The plan is an argument; the orchestrator gates on whether that argument is soun
46
49
 
47
50
  - You are dispatched by the plastic-enforcer and your deliverable is gated before Exec begins
48
51
  - You write only intent-store files (`plan.md`, `actions/`, `checklist.md`); no project code
49
- - The code gate stays closed until `plan.md` and `checklist.md` exist, so produce both
52
+ - The code gate stays closed until `plan.md`, `checklist.md`, and at least one real `actions/ACTION_N.md` exist, so produce all three
50
53
  - Keep each action self-contained so the executor can run them one at a time
51
- - The `actions/` empty-directory git-tracking edge (present only within one same-session
52
- local delivery) is out of scope; do not add a `.gitkeep` or a guard for it here
54
+ - Write real `actions/ACTION_N.md` files, never a `.gitkeep`: keeping a freshly-scaffolded
55
+ empty `actions/` under git is `scripts/new-intent`'s job at intent birth, not the planner's,
56
+ and a `.gitkeep` never counts as an action
@@ -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.2",
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,9 @@ 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"
28
+ require_relative "lib/skill_lint"
26
29
 
27
30
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
28
31
  # run it hermetically (no eval, no global-constant rewriting).
@@ -41,6 +44,11 @@ class Doctor
41
44
  # (intent 60). Alias it here so the two can never drift.
42
45
  REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
43
46
 
47
+ # Single source of truth for the pre-161 legacy Done-bookend amnesty list
48
+ # lives in LegacyBookendAmnesty (intent 170a). Alias it here so the two can
49
+ # never drift.
50
+ LEGACY_BOOKEND_AMNESTY = LegacyBookendAmnesty::LIST
51
+
44
52
  CLAUDE_HOOK_SCRIPTS = %w[
45
53
  plastic-session-start
46
54
  plastic-check-update
@@ -66,9 +74,11 @@ class Doctor
66
74
 
67
75
  attr_reader :plastic_home, :agents
68
76
 
69
- def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS)
77
+ def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
78
+ bookend_amnesty: LEGACY_BOOKEND_AMNESTY)
70
79
  @plastic_home = plastic_home
71
80
  @agents = agents
81
+ @bookend_amnesty = bookend_amnesty
72
82
  end
73
83
 
74
84
  # --- Flag parsing ---
@@ -577,6 +587,7 @@ class Doctor
577
587
  conflicts = [] # canonical INDEX-wins disagreement (fail)
578
588
  gaps = [] # terminal completeness gaps on immutable/legacy history (warn)
579
589
  stalled = [] # terminal but End tail unfinished (warn)
590
+ phantoms = [] # savepoint.md line(s) disk evidence contradicts (warn, never fail; intent 134)
580
591
 
581
592
  done_signal_stores(scopes).each do |store|
582
593
  index_sections_by_dir(store[:index]).each do |dirname, in_sections|
@@ -596,6 +607,24 @@ class Doctor
596
607
  "(INDEX is canonical — move it to its terminal section or revert outcome.md)"
597
608
  end
598
609
 
610
+ # Savepoint truthfulness (advisory, never fail; intent 134): a line the disk contradicts
611
+ # (a phantom milestone, a duplicate pair, or a state line with an absent prerequisite).
612
+ # Live intents auto-rebuild via plastic-intent-savepoint; terminal intents are immutable,
613
+ # so a phantom there is report-only. Composition with intent 170a: a pre-161 legacy intent
614
+ # on the frozen bookend amnesty is grandfathered here too. Its immutable history carries
615
+ # known pre-convention drift (the same class the audit-echo gap forgives), so it must not
616
+ # resurface through this advisory. The suppression is scope-qualified exactly like the
617
+ # audit-echo amnesty; every live intent and every non-amnestied terminal still fires.
618
+ phantom_lines = Bridge.savepoint_phantom_lines(dir)
619
+ phantom_id = dirname.split("--", 2).first
620
+ phantom_amnestied = terminal && @bookend_amnesty.fetch(store[:scope], []).include?(phantom_id)
621
+ if phantom_lines.any? && !phantom_amnestied
622
+ detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
623
+ scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
624
+ phantoms << "#{label}: #{phantom_lines.size} phantom savepoint line(s) contradicted by " \
625
+ "disk, #{scope_note}: #{detail}"
626
+ end
627
+
599
628
  # Completeness gap (warn, not fail): a terminal intent whose outcome.md is
600
629
  # missing/placeholder. Legacy terminal intents predate this convention and
601
630
  # are immutable, so this is advisory, never breakage.
@@ -608,11 +637,18 @@ class Doctor
608
637
  next unless terminal
609
638
 
610
639
  # Audit echo (weakest signal, D1): the savepoint should carry a
611
- # `Done delivered|abandoned` line. Missing on legacy history -> advisory gap.
640
+ # `Done delivered|abandoned` line. Missing on legacy history -> advisory
641
+ # gap, unless the (scope, id) is on the frozen pre-161 amnesty list
642
+ # (intent 170a). The amnesty is scope-qualified: an id on the list for
643
+ # one scope does not grandfather the same id in another scope.
612
644
  savepoint = File.join(dir, "savepoint.md")
613
645
  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)"
646
+ id = dirname.split("--", 2).first
647
+ amnestied = @bookend_amnesty.fetch(store[:scope], []).include?(id)
648
+ unless amnestied
649
+ gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
650
+ "`Done delivered|abandoned` line (audit echo missing)"
651
+ end
616
652
  end
617
653
 
618
654
  # Stalled completion: the End tail never released the delivery lock, so the
@@ -682,6 +718,26 @@ class Doctor
682
718
  )
683
719
  end
684
720
 
721
+ # savepoint_truthful: advisory only, never fails the run (intent 134). A phantom line on a
722
+ # live intent auto-rebuilds; on terminal (immutable) history it stays report-only.
723
+ if phantoms.empty?
724
+ checks << check(
725
+ category: "done_signals", name: "savepoint_truthful", status: "pass",
726
+ message: "No savepoint.md lines are contradicted by disk (phantom-line check clean)"
727
+ )
728
+ else
729
+ checks << check(
730
+ category: "done_signals", name: "savepoint_truthful", status: "warn",
731
+ message: "#{phantoms.size} intent#{phantoms.size == 1 ? "" : "s"} carry savepoint.md " \
732
+ "line(s) contradicted by disk (advisory; terminal history stays report-only)",
733
+ details: phantoms, fixable: true,
734
+ fix_hint: "For a live (Active) intent, run plastic-intent-savepoint to rebuild via " \
735
+ "Bridge.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
736
+ "a phantom there stays advisory unless an explicit human grant authorizes the " \
737
+ "124a manual Done-bookend repair."
738
+ )
739
+ end
740
+
685
741
  checks
686
742
  end
687
743
 
@@ -1271,9 +1327,85 @@ class Doctor
1271
1327
  end
1272
1328
  end
1273
1329
 
1330
+ checks += check_agent_model_drift(agent_key)
1331
+
1274
1332
  checks
1275
1333
  end
1276
1334
 
1335
+ # agent_model_drift - advisory (never fail) config-vs-frontmatter comparison
1336
+ # for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1).
1337
+ # Resolution mirrors read-config's own precedence (project override -> global
1338
+ # override -> shipped AgentModels::TIER_DEFAULTS), reusing AgentModels'
1339
+ # `override_map` directly so this stays hermetically DI-testable (no
1340
+ # shell-out to `read-config`, no ENV/global seam). There is no live project
1341
+ # scope at doctor-run time for a globally-installed agent file, so the
1342
+ # project layer is empty here; the resolver still supports one, matching the
1343
+ # same `override_map(project_config:, global_config:)` shape the installer
1344
+ # already calls.
1345
+ #
1346
+ # Classification per installed agent basename:
1347
+ # - no `agents.models.<basename>` override AND frontmatter == resolved
1348
+ # default -> pass (clean)
1349
+ # - no override AND frontmatter != resolved default -> warn (real drift)
1350
+ # - an override IS configured -> pass/informational, LISTED as a
1351
+ # sanctioned override, regardless of whether frontmatter matches (that
1352
+ # mismatch is the override working, e.g. `plastic-brainstorming: fable`)
1353
+ # - zero installed agent files -> pass (nothing to check)
1354
+ # This check never returns "fail".
1355
+ def check_agent_model_drift(agent_key)
1356
+ agent_config = agents[agent_key]
1357
+ return [] unless agent_config
1358
+
1359
+ agents_dir = File.join(agent_config[:dir], "agents")
1360
+ installed = Dir.glob(File.join(agents_dir, "plastic-*.md")).sort
1361
+
1362
+ if installed.empty?
1363
+ return [check(
1364
+ category: "core_files", name: "agent_model_drift", status: "pass",
1365
+ message: "No installed plastic-* agent files to check for model drift"
1366
+ )]
1367
+ end
1368
+
1369
+ global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
1370
+ overrides = AgentModels.override_map(project_config: {}, global_config: global_config)
1371
+
1372
+ drifted = []
1373
+ sanctioned = []
1374
+
1375
+ installed.each do |path|
1376
+ basename = File.basename(path, ".md")
1377
+ installed_model = (parse_frontmatter(path) || {})["model"]
1378
+ resolved_default = AgentModels::TIER_DEFAULTS[basename]
1379
+ override = overrides[basename]
1380
+
1381
+ if override
1382
+ sanctioned << "#{basename}: frontmatter=#{installed_model.inspect}, sanctioned override=#{override.inspect}"
1383
+ elsif installed_model != resolved_default
1384
+ drifted << "#{basename}: frontmatter=#{installed_model.inspect}, resolved default=#{resolved_default.inspect}"
1385
+ end
1386
+ end
1387
+
1388
+ if drifted.empty?
1389
+ message = if sanctioned.empty?
1390
+ "No agent-model drift (#{installed.size} installed agent(s) match the resolved default)"
1391
+ else
1392
+ "No unsanctioned agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
1393
+ end
1394
+ [check(
1395
+ category: "core_files", name: "agent_model_drift", status: "pass",
1396
+ message: message,
1397
+ details: sanctioned
1398
+ )]
1399
+ else
1400
+ [check(
1401
+ category: "core_files", name: "agent_model_drift", status: "warn",
1402
+ message: "#{drifted.size} installed agent(s) have unsanctioned model drift vs the config-resolved default",
1403
+ details: drifted + sanctioned,
1404
+ fixable: false
1405
+ )]
1406
+ end
1407
+ end
1408
+
1277
1409
  # --- Check category: manifest sync (binary core integrity) ---
1278
1410
 
1279
1411
  # Verify, for BOTH the global manifest and the agent-side manifest, that every
@@ -1674,6 +1806,48 @@ class Doctor
1674
1806
  )
1675
1807
  end
1676
1808
 
1809
+ # --- Check category: skill-lint (advisory only; intent 85b) ---
1810
+ #
1811
+ # Reports SkillLint's five structural checks over the skills/ tree as a
1812
+ # single ADVISORY finding. Always status: "pass", so it can never contribute
1813
+ # to doctor's warn/fail exit code (summarize buckets purely by status; the
1814
+ # lint verdict belongs to a future ship gate, intent 100, not to doctor).
1815
+ # Resolves the same repo skills/ directory `scripts/skill-lint` defaults to,
1816
+ # relative to this script's own location. Skills install flat and renamed
1817
+ # (plastic-<name>) under each agent's own skills/ dir, a different directory
1818
+ # shape SkillLint's frontmatter name-check does not target, so an installed
1819
+ # ~/.plastic/scripts/doctor.rb with no sibling skills/ tree has nothing in
1820
+ # scope here and reports a silent pass rather than guessing at a mismatched
1821
+ # layout.
1822
+ def check_skill_lint
1823
+ dir = File.expand_path("../skills", __dir__)
1824
+ unless File.directory?(dir)
1825
+ return [check(
1826
+ category: "skill_lint", name: "skill_lint", status: "pass",
1827
+ message: "No skills/ directory found at #{tilde(dir)} to lint (nothing in scope for this advisory check)"
1828
+ )]
1829
+ end
1830
+
1831
+ result = SkillLint.new(skills_dir: dir).run
1832
+
1833
+ if result.ok?
1834
+ [check(
1835
+ category: "skill_lint", name: "skill_lint", status: "pass",
1836
+ message: "skill-lint: clean (0 violations across the skills/ tree)"
1837
+ )]
1838
+ else
1839
+ by_check = result.violations.group_by { |v| v[:check] }.transform_values(&:size)
1840
+ counts = by_check.map { |check_id, n| "#{check_id}: #{n}" }.join(", ")
1841
+ details = result.violations.map { |v| "#{v[:check]} #{v[:skill]} #{v[:file]}:#{v[:line]}: #{v[:message]}" }
1842
+ [check(
1843
+ category: "skill_lint", name: "skill_lint", status: "pass",
1844
+ message: "skill-lint: #{result.violations.size} advisory violation(s) found (#{counts}); " \
1845
+ "informational only, never affects doctor's exit code",
1846
+ details: details
1847
+ )]
1848
+ end
1849
+ end
1850
+
1677
1851
  # --- Run all checks ---
1678
1852
 
1679
1853
  def run_checks(agent_key)
@@ -1686,6 +1860,7 @@ class Doctor
1686
1860
  all_checks += check_deprecations
1687
1861
  all_checks += check_qmd
1688
1862
  all_checks += check_done_signals
1863
+ all_checks += check_skill_lint
1689
1864
 
1690
1865
  summarize(all_checks, agent_key)
1691
1866
  end
@@ -1801,4 +1976,4 @@ class Doctor
1801
1976
 
1802
1977
  end
1803
1978
 
1804
- Doctor.new.cli(ARGV) if $PROGRAM_NAME == __FILE__
1979
+ 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__