@zalom/plastic 1.3.0 → 1.4.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-reference.md +8 -6
- package/PLASTIC.md +52 -14
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +393 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +105 -27
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +34 -3
- package/scripts/lib/installer_core.rb +70 -13
- 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/lock.rb +186 -11
- 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/plastic-lock +76 -9
- 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 +45 -16
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- 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/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- 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/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +12 -11
- package/skills/update/SKILL.md +30 -17
- package/templates/roadmap.md +8 -8
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
|
|
@@ -1259,12 +1268,35 @@ class Doctor
|
|
|
1259
1268
|
end
|
|
1260
1269
|
end
|
|
1261
1270
|
|
|
1262
|
-
|
|
1271
|
+
hooks_check = codex_hooks_registered_check(config)
|
|
1272
|
+
checks << hooks_check
|
|
1273
|
+
checks << codex_hooks_implemented_check(config)
|
|
1274
|
+
checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
|
|
1263
1275
|
codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
|
|
1264
1276
|
|
|
1265
1277
|
checks
|
|
1266
1278
|
end
|
|
1267
1279
|
|
|
1280
|
+
# Hooks being REGISTERED (hooks.json content matches HookRegistry) is not
|
|
1281
|
+
# the same as hooks being TRUSTED: Codex gates every non-managed hook
|
|
1282
|
+
# command behind a human /hooks review, keyed by the command's current
|
|
1283
|
+
# hash, so a release that changes a hook command re-arms the review
|
|
1284
|
+
# (intent 198, Decision D2). Whether Codex persists a queryable trust
|
|
1285
|
+
# record anywhere under ~/.codex is undocumented and unverified, so this
|
|
1286
|
+
# can never be a real pass or fail on trust state; it is an advisory that
|
|
1287
|
+
# always names the /hooks step, and it fires only once hooks are actually
|
|
1288
|
+
# registered (an unregistered hook is already reported by
|
|
1289
|
+
# codex_hooks_registered_check, so reminding about trust on top of that
|
|
1290
|
+
# would be noise, not signal).
|
|
1291
|
+
def codex_hook_trust_advisory_check
|
|
1292
|
+
check(
|
|
1293
|
+
category: "agent_registration", name: "codex_hooks_trust", status: "warn",
|
|
1294
|
+
message: "Open Codex, run /hooks, and trust the Plastic hook definitions. " \
|
|
1295
|
+
"Codex re-arms this review whenever a hook's command changes.",
|
|
1296
|
+
fixable: false
|
|
1297
|
+
)
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1268
1300
|
# Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
|
|
1269
1301
|
# exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
|
|
1270
1302
|
# "structural" means the mandatory keys appear as lines and the multi-line
|
|
@@ -1306,6 +1338,15 @@ class Doctor
|
|
|
1306
1338
|
has_name && has_desc && !di.nil? && !balanced.nil?
|
|
1307
1339
|
end
|
|
1308
1340
|
|
|
1341
|
+
# Plain string extraction of the single model-selection line a generated
|
|
1342
|
+
# Codex agent TOML carries (model_reasoning_effort for a tier alias, model
|
|
1343
|
+
# for a literal override id; codex_model_fields never emits both). No TOML
|
|
1344
|
+
# parser dependency, mirroring codex_agent_toml_well_formed? above.
|
|
1345
|
+
def codex_agent_toml_model_value(content)
|
|
1346
|
+
m = content.match(/^model_reasoning_effort\s*=\s*"([^"]*)"/) || content.match(/^model\s*=\s*"([^"]*)"/)
|
|
1347
|
+
m && m[1]
|
|
1348
|
+
end
|
|
1349
|
+
|
|
1309
1350
|
# codex_hooks_registered (intent 102, the owner-facing first-run validation
|
|
1310
1351
|
# path, Decision 14): ~/.codex/hooks.json must carry EXACTLY the commands
|
|
1311
1352
|
# HookRegistry.codex_hooks_json defines, mirroring the Claude
|
|
@@ -1347,6 +1388,124 @@ class Doctor
|
|
|
1347
1388
|
end
|
|
1348
1389
|
end
|
|
1349
1390
|
|
|
1391
|
+
# codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
|
|
1392
|
+
# hooks.json content matches what HookRegistry would emit; both sides of THAT
|
|
1393
|
+
# comparison come from the registry, so a pass only proves the registry agrees
|
|
1394
|
+
# with itself. It never looks at the actual dispatcher, so it cannot see a
|
|
1395
|
+
# registered gate with no real branch there (links-gate shipped exactly this way,
|
|
1396
|
+
# dead, in v1.4.0/intent 192, invisible to doctor and the suite until 198 found it
|
|
1397
|
+
# by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
|
|
1398
|
+
# 203, in the opposite direction). This check closes both directions at once.
|
|
1399
|
+
|
|
1400
|
+
# The Codex gate names HookRegistry actually registers: the six apply_patch-gated
|
|
1401
|
+
# names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
|
|
1402
|
+
# (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
|
|
1403
|
+
# `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
|
|
1404
|
+
# own Ruby constants.
|
|
1405
|
+
def codex_registry_gate_names
|
|
1406
|
+
live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
|
|
1407
|
+
HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
1408
|
+
end
|
|
1409
|
+
(HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
|
|
1410
|
+
HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
# Source-text extraction of scripts/codex-hook's supported gate names (D3): the
|
|
1414
|
+
# dispatcher is an executable script with real top-level side effects (it reads
|
|
1415
|
+
# $stdin and may exit), so it can never be required or executed to introspect it,
|
|
1416
|
+
# only read as plain text, mirroring codex_agent_toml_well_formed? above. Pulls
|
|
1417
|
+
# gate names from the STATE_HOOKS and SHELL_HOOKS %w[] literals, plus the `when
|
|
1418
|
+
# "<name>"` labels of the top-level `case gate` statement (stopping at its
|
|
1419
|
+
# trailing `else`). Line-shape dependent, not AST-safe, disclosed as such in
|
|
1420
|
+
# docs; the healthy-install pass test against the REAL dispatcher is what proves
|
|
1421
|
+
# this shape still holds.
|
|
1422
|
+
#
|
|
1423
|
+
# SELF-CHECKING: returns nil, never [], when nothing recognizable is found. A
|
|
1424
|
+
# reshaped dispatcher (combined `when "a", "b"` arms, a multi-line array, a
|
|
1425
|
+
# Hash-dispatch rewrite) would otherwise silently read as zero gate names, and a
|
|
1426
|
+
# zero-gate dispatcher would make the diff below either flag every registered
|
|
1427
|
+
# hook as "missing" or, worse, quietly under-report a real gap. A check that
|
|
1428
|
+
# finds nothing and calls that healthy IS the exact disease this whole check
|
|
1429
|
+
# exists to catch, one level up, so nil forces the caller (below) to fail loudly
|
|
1430
|
+
# instead of reporting health it never actually verified.
|
|
1431
|
+
def codex_dispatcher_gate_names(source)
|
|
1432
|
+
names = []
|
|
1433
|
+
%w[STATE_HOOKS SHELL_HOOKS].each do |const|
|
|
1434
|
+
m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
|
|
1435
|
+
names.concat(m[1].split(/\s+/)) if m
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
case_start = source.index(/^case gate\b/)
|
|
1439
|
+
if case_start
|
|
1440
|
+
case_body = source[case_start..-1]
|
|
1441
|
+
else_idx = case_body.index(/^else\b/)
|
|
1442
|
+
scanned = else_idx ? case_body[0...else_idx] : case_body
|
|
1443
|
+
names.concat(scanned.scan(/^when\s+"([^"]+)"/).flatten)
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
names.uniq!
|
|
1447
|
+
names.empty? ? nil : names
|
|
1448
|
+
end
|
|
1449
|
+
|
|
1450
|
+
# The both-direction diff. Each mismatch becomes one detail line naming the
|
|
1451
|
+
# hook, the direction, the harness ("Codex"), and the concrete runtime effect,
|
|
1452
|
+
# never a generic "Codex hooks drift" message (D4).
|
|
1453
|
+
def codex_hooks_implemented_check(config)
|
|
1454
|
+
dispatcher_path = File.join(plastic_home, "scripts", "codex-hook")
|
|
1455
|
+
|
|
1456
|
+
unless File.exist?(dispatcher_path)
|
|
1457
|
+
return check(
|
|
1458
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1459
|
+
message: "scripts/codex-hook not found at #{tilde(dispatcher_path)}; cannot verify " \
|
|
1460
|
+
"the Codex hook registry and dispatcher agree",
|
|
1461
|
+
fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
|
|
1462
|
+
)
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
dispatcher_names = codex_dispatcher_gate_names(File.read(dispatcher_path))
|
|
1466
|
+
|
|
1467
|
+
if dispatcher_names.nil?
|
|
1468
|
+
return check(
|
|
1469
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1470
|
+
message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
|
|
1471
|
+
"STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
|
|
1472
|
+
"match the shape this check expects, so the registry could not be checked " \
|
|
1473
|
+
"against the real dispatcher. This is exactly the silent-pass failure this " \
|
|
1474
|
+
"check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
|
|
1475
|
+
"to the file's new shape.",
|
|
1476
|
+
fixable: false
|
|
1477
|
+
)
|
|
1478
|
+
end
|
|
1479
|
+
|
|
1480
|
+
registry_names = codex_registry_gate_names
|
|
1481
|
+
missing_dispatcher_branch = registry_names - dispatcher_names
|
|
1482
|
+
dead_branch = dispatcher_names - registry_names
|
|
1483
|
+
|
|
1484
|
+
if missing_dispatcher_branch.empty? && dead_branch.empty?
|
|
1485
|
+
return check(
|
|
1486
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "pass",
|
|
1487
|
+
message: "Every Codex-registered gate has a scripts/codex-hook branch, and every " \
|
|
1488
|
+
"dispatcher branch is registered"
|
|
1489
|
+
)
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
details = missing_dispatcher_branch.map do |name|
|
|
1493
|
+
"#{name} is registered in ~/.codex/hooks.json but scripts/codex-hook has no branch " \
|
|
1494
|
+
"for it, so it always falls through to the fail-open else and always allows the write"
|
|
1495
|
+
end
|
|
1496
|
+
details += dead_branch.map do |name|
|
|
1497
|
+
"#{name} has a branch in scripts/codex-hook but is not registered in HookRegistry for " \
|
|
1498
|
+
"Codex, so it is dead code Codex never reaches"
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
check(
|
|
1502
|
+
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
1503
|
+
message: "Codex's hook registry and scripts/codex-hook disagree on #{details.size} gate(s)",
|
|
1504
|
+
details: details,
|
|
1505
|
+
fixable: false
|
|
1506
|
+
)
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1350
1509
|
# config.toml advisory (intent 102, Decision 2, R2): READ ONLY. Warns on the two
|
|
1351
1510
|
# documented footguns (hooks disabled, sandbox read-only); never writes, and
|
|
1352
1511
|
# returns nil (no check emitted) when config.toml is absent or carries neither.
|
|
@@ -1489,29 +1648,57 @@ class Doctor
|
|
|
1489
1648
|
end
|
|
1490
1649
|
|
|
1491
1650
|
# 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.
|
|
1651
|
+
# for every installed <agent_dir>/agents/plastic-*.md file (intent 170, D1;
|
|
1652
|
+
# reclassified intent 191). Resolution mirrors read-config's own precedence
|
|
1653
|
+
# (project override -> global override -> shipped AgentModels::TIER_DEFAULTS),
|
|
1654
|
+
# reusing AgentModels' `override_map` directly so this stays hermetically
|
|
1655
|
+
# DI-testable (no shell-out to `read-config`, no ENV/global seam). There is
|
|
1656
|
+
# no live project scope at doctor-run time for a globally-installed agent
|
|
1657
|
+
# file, so the project layer is empty here; the resolver still supports one,
|
|
1658
|
+
# matching the same `override_map(project_config:, global_config:)` shape
|
|
1659
|
+
# the installer already calls.
|
|
1501
1660
|
#
|
|
1502
|
-
#
|
|
1503
|
-
#
|
|
1504
|
-
#
|
|
1505
|
-
#
|
|
1506
|
-
#
|
|
1507
|
-
#
|
|
1508
|
-
#
|
|
1661
|
+
# This check deliberately never diffs installed frontmatter against the
|
|
1662
|
+
# shipped agents/*.md source tree directly (considered and rejected, intent
|
|
1663
|
+
# 191): at real doctor run time doctor.rb lives at ~/.plastic/scripts/doctor.rb,
|
|
1664
|
+
# and the installer copies only scripts/ into ~/.plastic, never the agents/
|
|
1665
|
+
# tree, so a "compare against shipped frontmatter" check would find nothing
|
|
1666
|
+
# in the field and pass silently on every real installation while only ever
|
|
1667
|
+
# exercising itself inside the repo, the same dead-in-production shape
|
|
1668
|
+
# already documented above check_skill_lint. The only registry of sanctioned
|
|
1669
|
+
# defaults reachable at doctor run time is scripts/lib/agent_models.rb (it
|
|
1670
|
+
# IS copied into ~/.plastic/scripts/lib), so this check classifies each
|
|
1671
|
+
# installed basename using ONLY the two registries that module already
|
|
1672
|
+
# ships, TIER_DEFAULTS and CONSULTATION_AGENTS. No new model values are
|
|
1673
|
+
# added anywhere.
|
|
1674
|
+
#
|
|
1675
|
+
# Classification per installed agent basename, checked in this order:
|
|
1676
|
+
# 1. an `agents.models.<basename>` override IS configured -> sanctioned,
|
|
1677
|
+
# pass/informational, LISTED regardless of whether frontmatter matches
|
|
1678
|
+
# (that mismatch is the override working, e.g. plastic-brainstorming: fable).
|
|
1679
|
+
# 2. no override AND basename is a TIER_DEFAULTS key -> compare frontmatter
|
|
1680
|
+
# to that default; match is pass (clean), mismatch is real drift (warn).
|
|
1681
|
+
# 3. no override AND basename is a CONSULTATION_AGENTS member -> not a
|
|
1682
|
+
# lifecycle stage role (intent 185); its model is user configuration by
|
|
1683
|
+
# contract (PLASTIC.md: "their models are user configuration"), so this
|
|
1684
|
+
# check does not compare and does not warn, it only lists the agent
|
|
1685
|
+
# informationally as a consultation role.
|
|
1686
|
+
# 4. no override AND basename is in NEITHER registry -> unclassified. This
|
|
1687
|
+
# check has no ground truth for this agent, so it does not claim
|
|
1688
|
+
# "drift vs default nil" (that claim would be false); it reports,
|
|
1689
|
+
# advisory warn, that the agent is present in the installed agents dir
|
|
1690
|
+
# but absent from both registries in scripts/lib/agent_models.rb, and
|
|
1691
|
+
# names that file as the place to add it. Setting an
|
|
1692
|
+
# agents.models.<basename> override also silences this line, by
|
|
1693
|
+
# routing the agent through bucket 1.
|
|
1509
1694
|
# - zero installed agent files -> pass (nothing to check)
|
|
1510
|
-
# This check never returns "fail".
|
|
1695
|
+
# This check never returns "fail" (bucket 4 is an advisory warn, not a fail).
|
|
1511
1696
|
def check_agent_model_drift(agent_key)
|
|
1512
1697
|
agent_config = agents[agent_key]
|
|
1513
1698
|
return [] unless agent_config
|
|
1514
1699
|
|
|
1700
|
+
return check_agent_model_drift_codex(agent_config) if agent_key == "codex"
|
|
1701
|
+
|
|
1515
1702
|
agents_dir = File.join(agent_config[:dir], "agents")
|
|
1516
1703
|
installed = Dir.glob(File.join(agents_dir, "plastic-*.md")).sort
|
|
1517
1704
|
|
|
@@ -1527,36 +1714,124 @@ class Doctor
|
|
|
1527
1714
|
|
|
1528
1715
|
drifted = []
|
|
1529
1716
|
sanctioned = []
|
|
1717
|
+
consultation = []
|
|
1718
|
+
unclassified = []
|
|
1530
1719
|
|
|
1531
1720
|
installed.each do |path|
|
|
1532
1721
|
basename = File.basename(path, ".md")
|
|
1533
1722
|
installed_model = (parse_frontmatter(path) || {})["model"]
|
|
1534
|
-
resolved_default = AgentModels::TIER_DEFAULTS[basename]
|
|
1535
1723
|
override = overrides[basename]
|
|
1536
1724
|
|
|
1537
1725
|
if override
|
|
1538
1726
|
sanctioned << "#{basename}: frontmatter=#{installed_model.inspect}, sanctioned override=#{override.inspect}"
|
|
1539
|
-
elsif
|
|
1540
|
-
|
|
1727
|
+
elsif AgentModels::TIER_DEFAULTS.key?(basename)
|
|
1728
|
+
resolved_default = AgentModels::TIER_DEFAULTS[basename]
|
|
1729
|
+
if installed_model != resolved_default
|
|
1730
|
+
drifted << "#{basename}: frontmatter=#{installed_model.inspect}, resolved default=#{resolved_default.inspect}"
|
|
1731
|
+
end
|
|
1732
|
+
elsif AgentModels::CONSULTATION_AGENTS.include?(basename)
|
|
1733
|
+
consultation << "#{basename}: frontmatter=#{installed_model.inspect} (consultation role, not a lifecycle default, model is user configuration)"
|
|
1734
|
+
else
|
|
1735
|
+
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
1736
|
end
|
|
1542
1737
|
end
|
|
1543
1738
|
|
|
1544
|
-
if drifted.empty?
|
|
1739
|
+
if drifted.empty? && unclassified.empty?
|
|
1545
1740
|
message = if sanctioned.empty?
|
|
1546
1741
|
"No agent-model drift (#{installed.size} installed agent(s) match the resolved default)"
|
|
1547
1742
|
else
|
|
1548
1743
|
"No unsanctioned agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
|
|
1549
1744
|
end
|
|
1745
|
+
[check(
|
|
1746
|
+
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1747
|
+
message: message,
|
|
1748
|
+
details: sanctioned + consultation
|
|
1749
|
+
)]
|
|
1750
|
+
else
|
|
1751
|
+
parts = []
|
|
1752
|
+
parts << "#{drifted.size} installed agent(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
|
|
1753
|
+
parts << "#{unclassified.size} installed agent(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
|
|
1754
|
+
[check(
|
|
1755
|
+
category: "core_files", name: "agent_model_drift", status: "warn",
|
|
1756
|
+
message: parts.join("; "),
|
|
1757
|
+
details: drifted + unclassified + sanctioned + consultation,
|
|
1758
|
+
fixable: false
|
|
1759
|
+
)]
|
|
1760
|
+
end
|
|
1761
|
+
end
|
|
1762
|
+
|
|
1763
|
+
# Codex leg of agent_model_drift (intent 198, Decision D4): the shared .md
|
|
1764
|
+
# path above is structurally blind on Codex (codex agents are TOML under
|
|
1765
|
+
# ~/.codex/agents/, never ~/.agents/agents/*.md), so it always passed on an
|
|
1766
|
+
# empty glob without opening a single Codex file. This mirrors the same
|
|
1767
|
+
# four buckets (sanctioned override, matches default, drifted,
|
|
1768
|
+
# unclassified) against ~/.codex/agents/plastic-*.toml instead, reading
|
|
1769
|
+
# model / model_reasoning_effort with the same plain string matching
|
|
1770
|
+
# codex_agent_toml_well_formed? already uses (no TOML parser dependency).
|
|
1771
|
+
# The expected value resolves through AgentModels::TIER_DEFAULTS mapped
|
|
1772
|
+
# through AgentModels.effort_for, honoring the Codex-scoped
|
|
1773
|
+
# agents.models.codex.<name> override precedence install_codex's own
|
|
1774
|
+
# agent_model_overrides(harness: "codex") already applies.
|
|
1775
|
+
# AgentModels::CONSULTATION_AGENTS need no special-case bucket here:
|
|
1776
|
+
# generate_codex_agents already skips writing them for Codex entirely, so
|
|
1777
|
+
# the glob below never finds them and there is nothing to classify.
|
|
1778
|
+
def check_agent_model_drift_codex(agent_config)
|
|
1779
|
+
agents_dir = File.join(agent_config[:home_dir], "agents")
|
|
1780
|
+
installed = Dir.glob(File.join(agents_dir, "plastic-*.toml")).sort
|
|
1781
|
+
|
|
1782
|
+
if installed.empty?
|
|
1783
|
+
return [check(
|
|
1784
|
+
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1785
|
+
message: "No installed plastic-* agent TOML files to check for model drift"
|
|
1786
|
+
)]
|
|
1787
|
+
end
|
|
1788
|
+
|
|
1789
|
+
global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
|
|
1790
|
+
overrides = AgentModels.override_map(project_config: {}, global_config: global_config, harness: "codex")
|
|
1791
|
+
|
|
1792
|
+
drifted = []
|
|
1793
|
+
sanctioned = []
|
|
1794
|
+
unclassified = []
|
|
1795
|
+
|
|
1796
|
+
installed.each do |path|
|
|
1797
|
+
basename = File.basename(path, ".toml")
|
|
1798
|
+
installed_value = codex_agent_toml_model_value(File.read(path))
|
|
1799
|
+
override = overrides[basename]
|
|
1800
|
+
|
|
1801
|
+
if override
|
|
1802
|
+
sanctioned << "#{basename}: toml=#{installed_value.inspect}, sanctioned override=#{override.inspect}"
|
|
1803
|
+
elsif AgentModels::TIER_DEFAULTS.key?(basename)
|
|
1804
|
+
expected_effort = AgentModels.effort_for(AgentModels::TIER_DEFAULTS[basename])
|
|
1805
|
+
if installed_value != expected_effort
|
|
1806
|
+
drifted << "#{basename}: toml=#{installed_value.inspect}, resolved default effort=#{expected_effort.inspect}"
|
|
1807
|
+
end
|
|
1808
|
+
else
|
|
1809
|
+
unclassified << "#{basename}: toml=#{installed_value.inspect}, no resolved default (basename is in " \
|
|
1810
|
+
"neither AgentModels::TIER_DEFAULTS nor AgentModels::CONSULTATION_AGENTS in " \
|
|
1811
|
+
"scripts/lib/agent_models.rb; add it there, or set agents.models.codex.#{basename} " \
|
|
1812
|
+
"to sanction a model explicitly)"
|
|
1813
|
+
end
|
|
1814
|
+
end
|
|
1815
|
+
|
|
1816
|
+
if drifted.empty? && unclassified.empty?
|
|
1817
|
+
message = if sanctioned.empty?
|
|
1818
|
+
"No agent-model drift (#{installed.size} installed Codex agent TOML(s) match the resolved default)"
|
|
1819
|
+
else
|
|
1820
|
+
"No unsanctioned Codex agent-model drift; #{sanctioned.size} sanctioned override(s) in effect"
|
|
1821
|
+
end
|
|
1550
1822
|
[check(
|
|
1551
1823
|
category: "core_files", name: "agent_model_drift", status: "pass",
|
|
1552
1824
|
message: message,
|
|
1553
1825
|
details: sanctioned
|
|
1554
1826
|
)]
|
|
1555
1827
|
else
|
|
1828
|
+
parts = []
|
|
1829
|
+
parts << "#{drifted.size} installed Codex agent TOML(s) have unsanctioned model drift vs the config-resolved default" if drifted.any?
|
|
1830
|
+
parts << "#{unclassified.size} installed Codex agent TOML(s) have no resolved default in scripts/lib/agent_models.rb" if unclassified.any?
|
|
1556
1831
|
[check(
|
|
1557
1832
|
category: "core_files", name: "agent_model_drift", status: "warn",
|
|
1558
|
-
message:
|
|
1559
|
-
details: drifted + sanctioned,
|
|
1833
|
+
message: parts.join("; "),
|
|
1834
|
+
details: drifted + unclassified + sanctioned,
|
|
1560
1835
|
fixable: false
|
|
1561
1836
|
)]
|
|
1562
1837
|
end
|
|
@@ -1920,6 +2195,65 @@ class Doctor
|
|
|
1920
2195
|
a_pre <=> b_pre
|
|
1921
2196
|
end
|
|
1922
2197
|
|
|
2198
|
+
# --- Check category: config asks (release-introduced config questions) ---
|
|
2199
|
+
#
|
|
2200
|
+
# Reads config_asks.yml via ConfigAsks (intent 194): a shipped, declarative
|
|
2201
|
+
# manifest so a release can announce a new config question and collect the
|
|
2202
|
+
# answer without this file, update.rb, or any skill needing to change again.
|
|
2203
|
+
# Rolled up into one check (mirrors check_deprecations shape). Full-tier
|
|
2204
|
+
# (run_checks) ONLY, deliberately excluded from run_core_checks: the post-
|
|
2205
|
+
# update path (update.rb#run_post_update_doctor) defaults to the fast/core
|
|
2206
|
+
# tier, which rolls up with binary: true (any warn becomes overall "fail").
|
|
2207
|
+
# A config-asks warn in that tier would flip every post-update doctor to
|
|
2208
|
+
# "fail" on an otherwise healthy install until the question is answered,
|
|
2209
|
+
# re-noising the post-update surface intent 126 deliberately quieted. The
|
|
2210
|
+
# recoverable-later path for a pending question is a full `/plastic-doctor`
|
|
2211
|
+
# run, the declared maintenance front door; the moment-it-happens path is
|
|
2212
|
+
# update.rb#announce_pending_config_asks, which already runs on every hop.
|
|
2213
|
+
#
|
|
2214
|
+
# A manifest that exists but cannot be read or parsed is reported as its own
|
|
2215
|
+
# WARN (config_asks_manifest), never as a silent pass: an unreadable
|
|
2216
|
+
# manifest is not the same as "nothing declared", and reporting it as
|
|
2217
|
+
# healthy would be the exact silent-failure this whole batch exists to
|
|
2218
|
+
# close. agent_key filters entries by their optional agents scoping (an
|
|
2219
|
+
# entry with no agents field applies to every agent).
|
|
2220
|
+
def check_config_asks(agent_key = "claude")
|
|
2221
|
+
manifest_problem = ConfigAsks.manifest_error(plastic_home)
|
|
2222
|
+
if manifest_problem
|
|
2223
|
+
return [check(
|
|
2224
|
+
category: "config_asks", name: "config_asks_manifest", status: "warn",
|
|
2225
|
+
message: "config_asks.yml problem: #{manifest_problem}",
|
|
2226
|
+
fixable: false
|
|
2227
|
+
)]
|
|
2228
|
+
end
|
|
2229
|
+
|
|
2230
|
+
pending = ConfigAsks.pending(plastic_home, agent_key)
|
|
2231
|
+
|
|
2232
|
+
if pending.empty?
|
|
2233
|
+
return [check(
|
|
2234
|
+
category: "config_asks", name: "pending_asks", status: "pass",
|
|
2235
|
+
message: "No pending config questions"
|
|
2236
|
+
)]
|
|
2237
|
+
end
|
|
2238
|
+
|
|
2239
|
+
details = pending.map do |entry|
|
|
2240
|
+
lines = ["#{entry["question"]} (id: #{entry["id"]})"]
|
|
2241
|
+
Array(entry["options"]).each do |opt|
|
|
2242
|
+
lines << " - #{opt["label"]}"
|
|
2243
|
+
lines << " #{ConfigAsks.write_config_command(plastic_home, entry["key"], opt["value"])}"
|
|
2244
|
+
end
|
|
2245
|
+
lines << " - Not now (keep the default)"
|
|
2246
|
+
lines << " #{ConfigAsks.dismiss_command(plastic_home, entry["id"])}"
|
|
2247
|
+
lines.join("\n")
|
|
2248
|
+
end
|
|
2249
|
+
|
|
2250
|
+
[check(
|
|
2251
|
+
category: "config_asks", name: "pending_asks", status: "warn",
|
|
2252
|
+
message: "#{pending.size} pending config question(s)", details: details,
|
|
2253
|
+
fixable: false
|
|
2254
|
+
)]
|
|
2255
|
+
end
|
|
2256
|
+
|
|
1923
2257
|
# --- Check category: QMD integration (read-only, optional) ---
|
|
1924
2258
|
#
|
|
1925
2259
|
# QMD is an optional integration. When `qmd` is not on PATH we emit a single
|
|
@@ -2014,6 +2348,7 @@ class Doctor
|
|
|
2014
2348
|
all_checks += check_core_files(agent_key)
|
|
2015
2349
|
all_checks += check_project_stores
|
|
2016
2350
|
all_checks += check_deprecations
|
|
2351
|
+
all_checks += check_config_asks(agent_key)
|
|
2017
2352
|
all_checks += check_qmd
|
|
2018
2353
|
all_checks += check_done_signals
|
|
2019
2354
|
all_checks += check_skill_lint
|