@zalom/plastic 1.3.0 → 1.4.0
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-reference.md +8 -6
- package/PLASTIC.md +24 -3
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +164 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/lib/bridge.rb +29 -1
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +2 -1
- package/scripts/lib/installer_core.rb +30 -7
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/auto/SKILL.md +16 -10
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/install/SKILL.md +4 -4
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
- package/skills/update/SKILL.md +30 -17
- package/templates/roadmap.md +8 -8
package/PLASTIC-reference.md
CHANGED
|
@@ -67,16 +67,18 @@ root, `~/.plastic/projects/{slug}/roadmaps/`, beside `project.yml`; for the glob
|
|
|
67
67
|
in-flight) roadmaps: once a roadmap's goal is reached, it moves to `roadmaps/archived/{slug}.md`,
|
|
68
68
|
a sibling subdirectory scaffolded once with a `.gitkeep`.
|
|
69
69
|
|
|
70
|
-
A roadmap file has four sections, in order: a title/meta header, `## Goal`, `##
|
|
70
|
+
A roadmap file has four sections, in order: a title/meta header, `## Goal`, `## Batches`, and an
|
|
71
71
|
append-only dated `## Log`. `## Goal` is a checkable prose condition read by a human or agent, not
|
|
72
|
-
an executable checker. `##
|
|
73
|
-
|
|
72
|
+
an executable checker. `## Batches` holds ordered batches; entries inside a batch are
|
|
73
|
+
parallel-safe, batches run sequentially, top to bottom. A roadmap written before owner ruling 145
|
|
74
|
+
may instead use the legacy `## Waves` heading; the tooling accepts both, but never renames an
|
|
75
|
+
existing roadmap file to migrate it.
|
|
74
76
|
|
|
75
|
-
Each
|
|
77
|
+
Each batch entry carries a status token (`queued`/`delivering`/`delivered`/`abandoned`/`blocked`)
|
|
76
78
|
that mirrors that intent's status in `INDEX.md`. `INDEX.md` is the single writer of intent status;
|
|
77
79
|
on any conflict INDEX wins and the roadmap entry is corrected to match.
|
|
78
80
|
|
|
79
|
-
**Human-comprehension surface.** A roadmap is also written to be read cold.
|
|
81
|
+
**Human-comprehension surface.** A roadmap is also written to be read cold. Batch entries render as
|
|
80
82
|
checkboxes (checked once delivered, unchecked otherwise) next to the status token, and each `## Log`
|
|
81
83
|
line is one plain-language sentence, starting `YYYY-MM-DD HH:MM UTC`, written the way an
|
|
82
84
|
engineering manager would brief a non-expert executive: what shipped and why it matters, no jargon
|
|
@@ -86,7 +88,7 @@ opening the file with no other context can tell what shipped, what is running no
|
|
|
86
88
|
next in under a minute.
|
|
87
89
|
|
|
88
90
|
**Relationship to loop engineering (intent 69).** A roadmap is the planning half of the work; the
|
|
89
|
-
loop is its runtime.
|
|
91
|
+
loop is its runtime. Batches lay out the parallelism plan: what can run together, and in what order.
|
|
90
92
|
Loop engineering (intent 69, not yet delivered) is expected to consume that plan and supply the
|
|
91
93
|
running parts, the heartbeat, how many dispatches run at once, checking the goal, and resuming
|
|
92
94
|
after a stop. This section only states the relationship and points to intent 69 as the future
|
package/PLASTIC.md
CHANGED
|
@@ -456,9 +456,10 @@ Advisory. Hard gates guard writes, locks, and structure, never reads or searches
|
|
|
456
456
|
Grep, Glob, and bash search are always allowed, including over the stores. When QMD is
|
|
457
457
|
present and fresh, a content search over store markdown receives an advisory hint pointing
|
|
458
458
|
at `qmd search` alongside its result; when QMD is present but stale, a background reindex
|
|
459
|
-
fires so the next turn's hint runs against a fresh index (never synchronous). QMD
|
|
460
|
-
Serena are recommendations, not obligations: the UserPromptSubmit power-tools hook
|
|
461
|
-
one recommendation line per present tool
|
|
459
|
+
fires so the next turn's hint runs against a fresh index (never synchronous). QMD, Enola,
|
|
460
|
+
and Serena are recommendations, not obligations: the UserPromptSubmit power-tools hook
|
|
461
|
+
appends one recommendation line per present tool, naming Enola only, not both, when Enola
|
|
462
|
+
and Serena are both present (Enola-first, one code-navigation slot). The legacy trailing `# qmd-ok` token is still
|
|
462
463
|
accepted on Bash commands and simply silences the hint. Scope stays the agent's own tool
|
|
463
464
|
calls; Ruby `File.read` inside a script is invisible to the hook by design.
|
|
464
465
|
|
|
@@ -603,6 +604,14 @@ purge: `outcome.md -> INDEX terminal -> savepoint Done -> commit -> disarm (Work
|
|
|
603
604
|
-> Lock.release -> purge) -> QMD reindex`. Running the reindex last keeps the index from
|
|
604
605
|
ever referencing a bridge or lock that disarm is about to remove.
|
|
605
606
|
|
|
607
|
+
`scripts/end-intent` performs this order's disarm step (verify the code worktree is clean,
|
|
608
|
+
then merge/remove worktrees, then clear the lock) as its own step 5, mechanically, since
|
|
609
|
+
intent 188: a session no longer needs a separate one-liner for it, and the script's own
|
|
610
|
+
exit code (0) is the single fact a caller needs that the intent is closed AND its delivery
|
|
611
|
+
lock is gone. A pre-flight lock guard runs before anything is written (refuses a live
|
|
612
|
+
foreign session, reclaims a stale one with an audit line), and a dirty code worktree
|
|
613
|
+
refuses before removal rather than force-discarding uncommitted changes.
|
|
614
|
+
|
|
606
615
|
The post-done access window is lock-bounded: `[INDEX terminal -> Lock.release]`. Through it
|
|
607
616
|
the completing session keeps full read and write access to the terminal directory and no
|
|
608
617
|
purge can fire (108's lock-held keep-guard keeps the bridge while `delivery.lock` exists).
|
|
@@ -621,6 +630,18 @@ always allowed and unbounded (curator reindex, dashboards, and future intents th
|
|
|
621
630
|
its id or chain), so a done intent stays fully readable forever. Intent 93 states this rule;
|
|
622
631
|
intent 112 builds the gate that enforces it.
|
|
623
632
|
|
|
633
|
+
Restore-to-v1 (the owner rule that a completed intent is immutable: a late ruling goes to a
|
|
634
|
+
new `--parent` branch intent, and the completed intent is restored to v1) is performed ONLY by
|
|
635
|
+
`scripts/restore-intent-v1`, run under the maintenance lock. Its prose (the intent narrative,
|
|
636
|
+
checklist.md, outcome.md, spec.md, plan.md) is immutable and reverts to v1; its frontmatter
|
|
637
|
+
graph (`sources`/`chain`) is metadata about OTHER intents, not content of this one, and is
|
|
638
|
+
APPEND-ONLY: it is preserved as the union of the v1 snapshot and the current snapshot, never
|
|
639
|
+
subtracted. A hand-run whole-file `git checkout`/revert of a completed intent is FORBIDDEN,
|
|
640
|
+
because it cannot distinguish prose from graph metadata and silently destroys backlinks written
|
|
641
|
+
after v1 (proven on intent 124: a legitimately accrued chain edge was destroyed by a hand-run
|
|
642
|
+
restore and went undetected for a week). This governs the restore mechanism only; it does not
|
|
643
|
+
loosen terminal immutability itself.
|
|
644
|
+
|
|
624
645
|
Fail-safe lock doctrine (the contract intent 111 implements): the lock system never traps a
|
|
625
646
|
session or burns credits. When a gate cannot verify lock integrity it fails open, degrading
|
|
626
647
|
to advisory (warn) rather than hard-blocking. Repair is orchestrator-driven: on a lock-issue
|
package/hooks/hooks.json
CHANGED
|
@@ -52,6 +52,11 @@
|
|
|
52
52
|
"type": "command",
|
|
53
53
|
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" savepoint-pre",
|
|
54
54
|
"statusMessage": "Recording stage start..."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "command",
|
|
58
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" links-gate",
|
|
59
|
+
"statusMessage": "Checking Links gate..."
|
|
55
60
|
}
|
|
56
61
|
]
|
|
57
62
|
},
|
package/hooks/links-gate
ADDED
package/package.json
CHANGED
package/scripts/doctor.rb
CHANGED
|
@@ -18,6 +18,7 @@ require "digest"
|
|
|
18
18
|
require_relative "lib/qmd_sync"
|
|
19
19
|
require_relative "lib/intent_validator"
|
|
20
20
|
require_relative "lib/graph_rebuild"
|
|
21
|
+
require_relative "lib/store_discovery"
|
|
21
22
|
require_relative "lib/links_projection"
|
|
22
23
|
require_relative "lib/links_section"
|
|
23
24
|
require_relative "lib/hook_registry"
|
|
@@ -26,6 +27,7 @@ require_relative "lib/bridge"
|
|
|
26
27
|
require_relative "lib/agent_models"
|
|
27
28
|
require_relative "lib/legacy_bookend_amnesty"
|
|
28
29
|
require_relative "lib/skill_lint"
|
|
30
|
+
require_relative "lib/config_asks"
|
|
29
31
|
|
|
30
32
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
31
33
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -228,33 +230,38 @@ class Doctor
|
|
|
228
230
|
end
|
|
229
231
|
end
|
|
230
232
|
|
|
233
|
+
# Single source of truth for "what stores exist" (intent 189), shared with
|
|
234
|
+
# rebuild-graph, project-links, and new-intent via StoreDiscovery. Memoized: one Doctor
|
|
235
|
+
# instance runs many checks against the same plastic_home in a single pass.
|
|
236
|
+
def store_discovery
|
|
237
|
+
@store_discovery ||= StoreDiscovery.discover(plastic_home)
|
|
238
|
+
end
|
|
239
|
+
|
|
231
240
|
def all_intent_dirs
|
|
232
241
|
dirs = []
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
full = File.join(global_store, entry)
|
|
238
|
-
dirs << { path: full, name: entry, scope: "global" }
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
projects_root = File.join(plastic_home, "projects")
|
|
243
|
-
if File.directory?(projects_root)
|
|
244
|
-
Dir.children(projects_root).each do |project|
|
|
245
|
-
project_store = File.join(projects_root, project, "store")
|
|
246
|
-
next unless File.directory?(project_store)
|
|
247
|
-
|
|
248
|
-
store_intent_dirs(project_store).each do |entry|
|
|
249
|
-
full = File.join(project_store, entry)
|
|
250
|
-
dirs << { path: full, name: entry, scope: "project:#{project}" }
|
|
251
|
-
end
|
|
242
|
+
store_discovery[:stores].each do |s|
|
|
243
|
+
store_intent_dirs(s[:store]).each do |entry|
|
|
244
|
+
full = File.join(s[:store], entry)
|
|
245
|
+
dirs << { path: full, name: entry, scope: s[:key] }
|
|
252
246
|
end
|
|
253
247
|
end
|
|
254
|
-
|
|
255
248
|
dirs
|
|
256
249
|
end
|
|
257
250
|
|
|
251
|
+
# A store_index Hash seeded with EVERY store StoreDiscovery reports, mapping a
|
|
252
|
+
# store with zero intents to [] rather than leaving its key absent. Mirrors
|
|
253
|
+
# RebuildGraph#run and ProjectLinks#run, which both set `store_index[key] =
|
|
254
|
+
# nodes.keys` for every discovered store (empty array for an empty store). Without
|
|
255
|
+
# this seed, a store_index built only from intents that parse has no key for a
|
|
256
|
+
# freshly provisioned, still-empty store, so GraphRebuild.classify calls it
|
|
257
|
+
# :unknown_store instead of :dead, disagreeing with the repair tools about the
|
|
258
|
+
# same ref (intent 189 review, finding 1).
|
|
259
|
+
def seeded_store_index
|
|
260
|
+
idx = Hash.new { |h, k| h[k] = [] }
|
|
261
|
+
store_discovery[:stores].each { |s| idx[s[:key]] }
|
|
262
|
+
idx
|
|
263
|
+
end
|
|
264
|
+
|
|
258
265
|
# --- Check category 1: Global store ---
|
|
259
266
|
|
|
260
267
|
def check_global_store
|
|
@@ -432,12 +439,13 @@ class Doctor
|
|
|
432
439
|
# well-formed arrays of id strings). Delegates to IntentValidator so the
|
|
433
440
|
# born-complete contract is defined in exactly one place. Read-only: shape
|
|
434
441
|
# repair is not a single-field inject, so this check is not fixable here.
|
|
442
|
+
known_stores = store_discovery[:stores].map { |s| s[:slug] }
|
|
435
443
|
malformed = []
|
|
436
444
|
intent_dirs.each do |d|
|
|
437
445
|
md_path = File.join(d[:path], "#{d[:name]}.md")
|
|
438
446
|
next unless File.exist?(md_path)
|
|
439
447
|
|
|
440
|
-
result = IntentValidator.validate_frontmatter(parse_frontmatter(md_path))
|
|
448
|
+
result = IntentValidator.validate_frontmatter(parse_frontmatter(md_path), known_stores: known_stores)
|
|
441
449
|
shape_errors = result[:errors].select { |e| e.include?("must be an array") || e.include?("invalid id") }
|
|
442
450
|
malformed << { dir: tilde(d[:path]), errors: shape_errors } unless shape_errors.empty?
|
|
443
451
|
end
|
|
@@ -750,7 +758,7 @@ class Doctor
|
|
|
750
758
|
def links_projection_check(scopes: nil)
|
|
751
759
|
all_dirs = all_intent_dirs
|
|
752
760
|
|
|
753
|
-
store_index =
|
|
761
|
+
store_index = seeded_store_index
|
|
754
762
|
node_index = Hash.new { |h, k| h[k] = {} }
|
|
755
763
|
intents = [] # { scope:, id:, sources:, chain:, path: }
|
|
756
764
|
|
|
@@ -803,7 +811,11 @@ class Doctor
|
|
|
803
811
|
graph_finding_check(
|
|
804
812
|
"graph_links_projection", findings,
|
|
805
813
|
"Every intent's ## Links equals its frontmatter projection (membership and ordering)",
|
|
806
|
-
"Run scripts/project-links to regenerate the canonical ## Links sections"
|
|
814
|
+
"Run scripts/project-links to regenerate the canonical ## Links sections. By default " \
|
|
815
|
+
"it PRESERVES any line unbacked by frontmatter that still resolves to a real intent " \
|
|
816
|
+
"(reported as an orphan candidate, never silently deleted); add the missing " \
|
|
817
|
+
"sources/chain edge if the relationship is real, or pass --drop-unbacked-links to " \
|
|
818
|
+
"delete an orphan candidate deliberately."
|
|
807
819
|
)
|
|
808
820
|
end
|
|
809
821
|
|
|
@@ -829,7 +841,7 @@ class Doctor
|
|
|
829
841
|
|
|
830
842
|
# Per-scope node maps + store_index over the WHOLE family.
|
|
831
843
|
nodes_by_scope = Hash.new { |h, k| h[k] = {} }
|
|
832
|
-
store_index =
|
|
844
|
+
store_index = seeded_store_index
|
|
833
845
|
all_dirs.each do |d|
|
|
834
846
|
md = File.join(d[:path], "#{d[:name]}.md")
|
|
835
847
|
next unless File.exist?(md)
|
|
@@ -860,6 +872,9 @@ class Doctor
|
|
|
860
872
|
relocation_map: relocation_map,
|
|
861
873
|
store_index: store_index)
|
|
862
874
|
case res[:status]
|
|
875
|
+
when :unknown_store
|
|
876
|
+
findings << "#{id}.#{field} cross-store ref #{ref} names a store this scan does " \
|
|
877
|
+
"not recognize (#{res[:store]}); left untouched, verify store discovery"
|
|
863
878
|
when :dead
|
|
864
879
|
findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
|
|
865
880
|
when :same_store
|
|
@@ -880,18 +895,12 @@ class Doctor
|
|
|
880
895
|
end
|
|
881
896
|
|
|
882
897
|
# { store_key => INDEX.md text } for every store (global + all projects), for the
|
|
883
|
-
# relocation-map builder.
|
|
898
|
+
# relocation-map builder. Sourced from the same StoreDiscovery list all_intent_dirs
|
|
899
|
+
# uses, so the two can never enumerate a different set of stores.
|
|
884
900
|
def cross_store_index_texts
|
|
885
901
|
texts = {}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
projects_root = File.join(plastic_home, "projects")
|
|
890
|
-
if File.directory?(projects_root)
|
|
891
|
-
Dir.children(projects_root).each do |project|
|
|
892
|
-
idx = File.join(projects_root, project, "INDEX.md")
|
|
893
|
-
texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
|
|
894
|
-
end
|
|
902
|
+
store_discovery[:stores].each do |s|
|
|
903
|
+
texts[s[:key]] = File.read(s[:index]) if File.exist?(s[:index])
|
|
895
904
|
end
|
|
896
905
|
texts
|
|
897
906
|
end
|
|
@@ -1489,25 +1498,51 @@ class Doctor
|
|
|
1489
1498
|
end
|
|
1490
1499
|
|
|
1491
1500
|
# agent_model_drift - advisory (never fail) config-vs-frontmatter comparison
|
|
1492
|
-
# for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1
|
|
1493
|
-
# Resolution mirrors read-config's own precedence
|
|
1494
|
-
# override -> shipped AgentModels::TIER_DEFAULTS),
|
|
1495
|
-
# `override_map` directly so this stays hermetically
|
|
1496
|
-
# shell-out to `read-config`, no ENV/global seam). There is
|
|
1497
|
-
# scope at doctor-run time for a globally-installed agent
|
|
1498
|
-
# project layer is empty here; the resolver still supports one,
|
|
1499
|
-
# same `override_map(project_config:, global_config:)` shape
|
|
1500
|
-
# already calls.
|
|
1501
|
+
# for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1;
|
|
1502
|
+
# reclassified intent 191). Resolution mirrors read-config's own precedence
|
|
1503
|
+
# (project override -> global override -> shipped AgentModels::TIER_DEFAULTS),
|
|
1504
|
+
# reusing AgentModels' `override_map` directly so this stays hermetically
|
|
1505
|
+
# DI-testable (no shell-out to `read-config`, no ENV/global seam). There is
|
|
1506
|
+
# no live project scope at doctor-run time for a globally-installed agent
|
|
1507
|
+
# file, so the project layer is empty here; the resolver still supports one,
|
|
1508
|
+
# matching the same `override_map(project_config:, global_config:)` shape
|
|
1509
|
+
# the installer already calls.
|
|
1501
1510
|
#
|
|
1502
|
-
#
|
|
1503
|
-
#
|
|
1504
|
-
#
|
|
1505
|
-
#
|
|
1506
|
-
#
|
|
1507
|
-
#
|
|
1508
|
-
#
|
|
1511
|
+
# This check deliberately never diffs installed frontmatter against the
|
|
1512
|
+
# shipped agents/*.md source tree directly (considered and rejected, intent
|
|
1513
|
+
# 191): at real doctor run time doctor.rb lives at ~/.plastic/scripts/doctor.rb,
|
|
1514
|
+
# and the installer copies only scripts/ into ~/.plastic, never the agents/
|
|
1515
|
+
# tree, so a "compare against shipped frontmatter" check would find nothing
|
|
1516
|
+
# in the field and pass silently on every real installation while only ever
|
|
1517
|
+
# exercising itself inside the repo, the same dead-in-production shape
|
|
1518
|
+
# already documented above check_skill_lint. The only registry of sanctioned
|
|
1519
|
+
# defaults reachable at doctor run time is scripts/lib/agent_models.rb (it
|
|
1520
|
+
# IS copied into ~/.plastic/scripts/lib), so this check classifies each
|
|
1521
|
+
# installed basename using ONLY the two registries that module already
|
|
1522
|
+
# ships, TIER_DEFAULTS and CONSULTATION_AGENTS. No new model values are
|
|
1523
|
+
# added anywhere.
|
|
1524
|
+
#
|
|
1525
|
+
# Classification per installed agent basename, checked in this order:
|
|
1526
|
+
# 1. an `agents.models.<basename>` override IS configured -> sanctioned,
|
|
1527
|
+
# pass/informational, LISTED regardless of whether frontmatter matches
|
|
1528
|
+
# (that mismatch is the override working, e.g. plastic-brainstorming: fable).
|
|
1529
|
+
# 2. no override AND basename is a TIER_DEFAULTS key -> compare frontmatter
|
|
1530
|
+
# to that default; match is pass (clean), mismatch is real drift (warn).
|
|
1531
|
+
# 3. no override AND basename is a CONSULTATION_AGENTS member -> not a
|
|
1532
|
+
# lifecycle stage role (intent 185); its model is user configuration by
|
|
1533
|
+
# contract (PLASTIC.md: "their models are user configuration"), so this
|
|
1534
|
+
# check does not compare and does not warn, it only lists the agent
|
|
1535
|
+
# informationally as a consultation role.
|
|
1536
|
+
# 4. no override AND basename is in NEITHER registry -> unclassified. This
|
|
1537
|
+
# check has no ground truth for this agent, so it does not claim
|
|
1538
|
+
# "drift vs default nil" (that claim would be false); it reports,
|
|
1539
|
+
# advisory warn, that the agent is present in the installed agents dir
|
|
1540
|
+
# but absent from both registries in scripts/lib/agent_models.rb, and
|
|
1541
|
+
# names that file as the place to add it. Setting an
|
|
1542
|
+
# agents.models.<basename> override also silences this line, by
|
|
1543
|
+
# routing the agent through bucket 1.
|
|
1509
1544
|
# - zero installed agent files -> pass (nothing to check)
|
|
1510
|
-
# This check never returns "fail".
|
|
1545
|
+
# This check never returns "fail" (bucket 4 is an advisory warn, not a fail).
|
|
1511
1546
|
def check_agent_model_drift(agent_key)
|
|
1512
1547
|
agent_config = agents[agent_key]
|
|
1513
1548
|
return [] unless agent_config
|
|
@@ -1527,21 +1562,29 @@ class Doctor
|
|
|
1527
1562
|
|
|
1528
1563
|
drifted = []
|
|
1529
1564
|
sanctioned = []
|
|
1565
|
+
consultation = []
|
|
1566
|
+
unclassified = []
|
|
1530
1567
|
|
|
1531
1568
|
installed.each do |path|
|
|
1532
1569
|
basename = File.basename(path, ".md")
|
|
1533
1570
|
installed_model = (parse_frontmatter(path) || {})["model"]
|
|
1534
|
-
resolved_default = AgentModels::TIER_DEFAULTS[basename]
|
|
1535
1571
|
override = overrides[basename]
|
|
1536
1572
|
|
|
1537
1573
|
if override
|
|
1538
1574
|
sanctioned << "#{basename}: frontmatter=#{installed_model.inspect}, sanctioned override=#{override.inspect}"
|
|
1539
|
-
elsif
|
|
1540
|
-
|
|
1575
|
+
elsif AgentModels::TIER_DEFAULTS.key?(basename)
|
|
1576
|
+
resolved_default = AgentModels::TIER_DEFAULTS[basename]
|
|
1577
|
+
if installed_model != resolved_default
|
|
1578
|
+
drifted << "#{basename}: frontmatter=#{installed_model.inspect}, resolved default=#{resolved_default.inspect}"
|
|
1579
|
+
end
|
|
1580
|
+
elsif AgentModels::CONSULTATION_AGENTS.include?(basename)
|
|
1581
|
+
consultation << "#{basename}: frontmatter=#{installed_model.inspect} (consultation role, not a lifecycle default, model is user configuration)"
|
|
1582
|
+
else
|
|
1583
|
+
unclassified << "#{basename}: frontmatter=#{installed_model.inspect}, no resolved default (basename is in neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in scripts/lib/agent_models.rb; add it there, or set agents.models.#{basename} to sanction a model explicitly)"
|
|
1541
1584
|
end
|
|
1542
1585
|
end
|
|
1543
1586
|
|
|
1544
|
-
if drifted.empty?
|
|
1587
|
+
if drifted.empty? && unclassified.empty?
|
|
1545
1588
|
message = if sanctioned.empty?
|
|
1546
1589
|
"No agent-model drift (#{installed.size} installed agent(s) match the resolved default)"
|
|
1547
1590
|
else
|
|
@@ -1550,13 +1593,16 @@ class Doctor
|
|
|
1550
1593
|
[check(
|
|
1551
1594
|
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1552
1595
|
message: message,
|
|
1553
|
-
details: sanctioned
|
|
1596
|
+
details: sanctioned + consultation
|
|
1554
1597
|
)]
|
|
1555
1598
|
else
|
|
1599
|
+
parts = []
|
|
1600
|
+
parts << "#{drifted.size} installed agent(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
|
|
1601
|
+
parts << "#{unclassified.size} installed agent(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
|
|
1556
1602
|
[check(
|
|
1557
1603
|
category: "core_files", name: "agent_model_drift", status: "warn",
|
|
1558
|
-
message:
|
|
1559
|
-
details: drifted + sanctioned,
|
|
1604
|
+
message: parts.join("; "),
|
|
1605
|
+
details: drifted + unclassified + sanctioned + consultation,
|
|
1560
1606
|
fixable: false
|
|
1561
1607
|
)]
|
|
1562
1608
|
end
|
|
@@ -1920,6 +1966,65 @@ class Doctor
|
|
|
1920
1966
|
a_pre <=> b_pre
|
|
1921
1967
|
end
|
|
1922
1968
|
|
|
1969
|
+
# --- Check category: config asks (release-introduced config questions) ---
|
|
1970
|
+
#
|
|
1971
|
+
# Reads config_asks.yml via ConfigAsks (intent 194): a shipped, declarative
|
|
1972
|
+
# manifest so a release can announce a new config question and collect the
|
|
1973
|
+
# answer without this file, update.rb, or any skill needing to change again.
|
|
1974
|
+
# Rolled up into one check (mirrors check_deprecations shape). Full-tier
|
|
1975
|
+
# (run_checks) ONLY, deliberately excluded from run_core_checks: the post-
|
|
1976
|
+
# update path (update.rb#run_post_update_doctor) defaults to the fast/core
|
|
1977
|
+
# tier, which rolls up with binary: true (any warn becomes overall "fail").
|
|
1978
|
+
# A config-asks warn in that tier would flip every post-update doctor to
|
|
1979
|
+
# "fail" on an otherwise healthy install until the question is answered,
|
|
1980
|
+
# re-noising the post-update surface intent 126 deliberately quieted. The
|
|
1981
|
+
# recoverable-later path for a pending question is a full `/plastic-doctor`
|
|
1982
|
+
# run, the declared maintenance front door; the moment-it-happens path is
|
|
1983
|
+
# update.rb#announce_pending_config_asks, which already runs on every hop.
|
|
1984
|
+
#
|
|
1985
|
+
# A manifest that exists but cannot be read or parsed is reported as its own
|
|
1986
|
+
# WARN (config_asks_manifest), never as a silent pass: an unreadable
|
|
1987
|
+
# manifest is not the same as "nothing declared", and reporting it as
|
|
1988
|
+
# healthy would be the exact silent-failure this whole batch exists to
|
|
1989
|
+
# close. agent_key filters entries by their optional agents scoping (an
|
|
1990
|
+
# entry with no agents field applies to every agent).
|
|
1991
|
+
def check_config_asks(agent_key = "claude")
|
|
1992
|
+
manifest_problem = ConfigAsks.manifest_error(plastic_home)
|
|
1993
|
+
if manifest_problem
|
|
1994
|
+
return [check(
|
|
1995
|
+
category: "config_asks", name: "config_asks_manifest", status: "warn",
|
|
1996
|
+
message: "config_asks.yml problem: #{manifest_problem}",
|
|
1997
|
+
fixable: false
|
|
1998
|
+
)]
|
|
1999
|
+
end
|
|
2000
|
+
|
|
2001
|
+
pending = ConfigAsks.pending(plastic_home, agent_key)
|
|
2002
|
+
|
|
2003
|
+
if pending.empty?
|
|
2004
|
+
return [check(
|
|
2005
|
+
category: "config_asks", name: "pending_asks", status: "pass",
|
|
2006
|
+
message: "No pending config questions"
|
|
2007
|
+
)]
|
|
2008
|
+
end
|
|
2009
|
+
|
|
2010
|
+
details = pending.map do |entry|
|
|
2011
|
+
lines = ["#{entry["question"]} (id: #{entry["id"]})"]
|
|
2012
|
+
Array(entry["options"]).each do |opt|
|
|
2013
|
+
lines << " - #{opt["label"]}"
|
|
2014
|
+
lines << " #{ConfigAsks.write_config_command(plastic_home, entry["key"], opt["value"])}"
|
|
2015
|
+
end
|
|
2016
|
+
lines << " - Not now (keep the default)"
|
|
2017
|
+
lines << " #{ConfigAsks.dismiss_command(plastic_home, entry["id"])}"
|
|
2018
|
+
lines.join("\n")
|
|
2019
|
+
end
|
|
2020
|
+
|
|
2021
|
+
[check(
|
|
2022
|
+
category: "config_asks", name: "pending_asks", status: "warn",
|
|
2023
|
+
message: "#{pending.size} pending config question(s)", details: details,
|
|
2024
|
+
fixable: false
|
|
2025
|
+
)]
|
|
2026
|
+
end
|
|
2027
|
+
|
|
1923
2028
|
# --- Check category: QMD integration (read-only, optional) ---
|
|
1924
2029
|
#
|
|
1925
2030
|
# QMD is an optional integration. When `qmd` is not on PATH we emit a single
|
|
@@ -2014,6 +2119,7 @@ class Doctor
|
|
|
2014
2119
|
all_checks += check_core_files(agent_key)
|
|
2015
2120
|
all_checks += check_project_stores
|
|
2016
2121
|
all_checks += check_deprecations
|
|
2122
|
+
all_checks += check_config_asks(agent_key)
|
|
2017
2123
|
all_checks += check_qmd
|
|
2018
2124
|
all_checks += check_done_signals
|
|
2019
2125
|
all_checks += check_skill_lint
|