@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.
- package/PLASTIC.md +4 -2
- package/README.md +3 -3
- package/agents/plastic-enforcer.md +5 -2
- package/agents/plastic-future-intent-researcher.md +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +1 -1
- package/package.json +1 -1
- package/scripts/dashboard.rb +29 -24
- package/scripts/doctor.rb +136 -5
- package/scripts/hook-continue +3 -3
- package/scripts/install.rb +2 -1
- package/scripts/lib/bridge.rb +81 -0
- package/scripts/lib/dashboard_banner.rb +8 -9
- package/scripts/lib/installer_core.rb +10 -3
- package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
- package/scripts/lib/release_guard.rb +62 -0
- package/scripts/lib/roadmap_queue.rb +285 -0
- package/scripts/lib/roadmap_savepoint.rb +213 -0
- package/scripts/lib/worktree.rb +21 -0
- package/scripts/new-intent +1 -0
- package/scripts/read-config +3 -3
- package/scripts/roadmap-next +44 -0
- package/scripts/roadmap-savepoint +64 -0
- package/skills/auto/SKILL.md +22 -4
- package/skills/continuing/SKILL.md +34 -0
- package/skills/continuing/evals/evals.json +91 -0
- package/skills/dashboard/SKILL.md +17 -14
- package/skills/dashboard/references/classification.md +3 -3
- package/skills/dashboard/templates/dashboard-global.md +8 -23
- package/skills/dashboard/templates/dashboard-project.md +7 -26
- package/skills/doctor/SKILL.md +1 -1
- package/skills/install/SKILL.md +10 -10
- package/skills/intent-continuing/SKILL.md +26 -68
- package/skills/intent-continuing/evals/evals.json +26 -26
- package/skills/intent-continuing/references/context-management.md +15 -19
- package/skills/intent-savepoint/SKILL.md +12 -0
- package/skills/intent-starting/evals/evals.json +1 -1
- package/skills/project-continuing/SKILL.md +104 -0
- package/skills/project-continuing/evals/evals.json +100 -0
- package/skills/project-continuing/references/board-fill.md +33 -0
- package/skills/releasing/SKILL.md +48 -0
- package/skills/releasing/references/release-lines.md +105 -0
- package/skills/roadmap/SKILL.md +7 -1
- package/skills/roadmap/references/file-format.md +30 -1
- package/skills/roadmap/references/operations.md +26 -6
- package/skills/roadmap-continuing/SKILL.md +85 -0
- package/skills/roadmap-continuing/evals/evals.json +82 -0
- package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
- package/skills/skill-evaluating/evals/evals.json +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/skills/uninstall/SKILL.md +2 -2
- package/skills/update/SKILL.md +2 -2
- package/templates/config.yml +2 -1
- 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
|
|
171
|
-
|
|
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
|
-
|
|
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
|
package/hooks/check-update
CHANGED
|
@@ -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
|
|
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
package/scripts/dashboard.rb
CHANGED
|
@@ -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-
|
|
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).
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
588
|
+
capped = list.first(NEXT_WORK_CAP)
|
|
592
589
|
lines = capped.map { |r| intent_line(r, bullet) }
|
|
593
|
-
if list.size >
|
|
590
|
+
if list.size > NEXT_WORK_CAP
|
|
594
591
|
lines << { id: "", intent: "", created: "", bullet: bullet, scope: "",
|
|
595
|
-
line: "#{bullet} +#{list.size -
|
|
592
|
+
line: "#{bullet} +#{list.size - NEXT_WORK_CAP} more" }
|
|
596
593
|
end
|
|
597
594
|
lines
|
|
598
595
|
end
|
|
599
596
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
615
|
-
|
|
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__
|
package/scripts/hook-continue
CHANGED
|
@@ -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-
|
|
23
|
-
"if active intents exist, read their state and resume; otherwise
|
|
24
|
-
"
|
|
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" => {
|
package/scripts/install.rb
CHANGED
|
@@ -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__)
|
|
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__
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
|
30
|
-
#
|
|
31
|
-
# already rank-sorted). Returns nil for any other shape rather than
|
|
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
|
-
|
|
34
|
-
return nil unless
|
|
35
|
-
|
|
36
|
-
return nil unless
|
|
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
|
|
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)
|
|
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
|
|
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
|