@zalom/plastic 1.2.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.
Files changed (63) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +68 -6
  3. package/README.md +5 -0
  4. package/agents/plastic-advisor.md +56 -0
  5. package/agents/plastic-enforcer.md +9 -1
  6. package/agents/plastic-faux-advisor.md +174 -0
  7. package/agents/plastic-future-intent-researcher.md +1 -0
  8. package/hooks/hooks.json +5 -0
  9. package/hooks/links-gate +3 -0
  10. package/hooks/statusline +1 -0
  11. package/package.json +1 -1
  12. package/scripts/doctor.rb +164 -58
  13. package/scripts/end-intent +347 -43
  14. package/scripts/hook-links-gate +74 -0
  15. package/scripts/install.rb +8 -0
  16. package/scripts/lib/agent_models.rb +36 -9
  17. package/scripts/lib/bridge.rb +29 -1
  18. package/scripts/lib/config_asks.rb +110 -0
  19. package/scripts/lib/graph_rebuild.rb +30 -6
  20. package/scripts/lib/hook_registry.rb +2 -1
  21. package/scripts/lib/installer_core.rb +130 -23
  22. package/scripts/lib/intent_validator.rb +38 -10
  23. package/scripts/lib/links_gate.rb +140 -0
  24. package/scripts/lib/links_projection.rb +71 -12
  25. package/scripts/lib/power_tools.rb +57 -14
  26. package/scripts/lib/project_validator.rb +113 -0
  27. package/scripts/lib/qmd_hook.rb +12 -8
  28. package/scripts/lib/restore_intent_v1.rb +154 -0
  29. package/scripts/lib/roadmap_queue.rb +1 -1
  30. package/scripts/lib/roadmap_savepoint.rb +38 -10
  31. package/scripts/lib/store_discovery.rb +77 -0
  32. package/scripts/lib/store_provisioning.rb +21 -12
  33. package/scripts/new-intent +10 -12
  34. package/scripts/project-links +132 -35
  35. package/scripts/provision-project-store +18 -5
  36. package/scripts/read-config +1 -0
  37. package/scripts/rebuild-graph +42 -17
  38. package/scripts/restore-intent-v1 +288 -0
  39. package/scripts/roadmap-next +9 -2
  40. package/scripts/roadmap-savepoint +9 -1
  41. package/scripts/update.rb +50 -1
  42. package/scripts/validate-intent +3 -1
  43. package/scripts/validate-project +53 -0
  44. package/scripts/write-config +105 -0
  45. package/skills/agent-advisor/SKILL.md +92 -0
  46. package/skills/agent-advisor/references/advisor-protocol.md +245 -0
  47. package/skills/auto/SKILL.md +26 -12
  48. package/skills/auto/references/end-tail.md +27 -13
  49. package/skills/install/SKILL.md +30 -2
  50. package/skills/intent-creating/SKILL.md +5 -0
  51. package/skills/intent-ending/SKILL.md +49 -36
  52. package/skills/project-creating/SKILL.md +29 -1
  53. package/skills/releasing/SKILL.md +37 -19
  54. package/skills/roadmap/SKILL.md +9 -7
  55. package/skills/roadmap/references/file-format.md +14 -10
  56. package/skills/roadmap/references/operations.md +22 -18
  57. package/skills/roadmap-continuing/SKILL.md +5 -5
  58. package/skills/roadmap-continuing/evals/evals.json +3 -3
  59. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  60. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
  61. package/skills/update/SKILL.md +34 -4
  62. package/templates/config.yml +31 -6
  63. package/templates/roadmap.md +8 -8
@@ -21,6 +21,18 @@ module AgentModels
21
21
  "plastic-intent-discovery" => "sonnet"
22
22
  }.freeze
23
23
 
24
+ # The two advisor agents (intent 185 final design): plastic-advisor (the real
25
+ # advisor, ships `model: fable`) and plastic-faux-advisor (the imitation
26
+ # advisor, an ordinary model carrying the same reasoning discipline inline,
27
+ # ships `model: opus`). Both are shipped DEFAULTS in frontmatter, never a
28
+ # hard-wired identity: agents.models.claude.<name> (or the legacy flat form)
29
+ # overrides either one through the same install-time frontmatter rewrite
30
+ # every agent override uses. Neither is a lifecycle-stage role: never
31
+ # dispatched by the auto pipeline, not part of TIER_DEFAULTS. Claude-only for
32
+ # this release (generate_codex_agents skips both by name; the Codex advisor
33
+ # case is intent 186, not a permanent exclusion).
34
+ CONSULTATION_AGENTS = %w[plastic-advisor plastic-faux-advisor].freeze
35
+
24
36
  # Codex reasoning-effort per tier alias (intent 102a). model_reasoning_effort is a
25
37
  # depth-of-thinking dial independent of model selection (181 line 317-318), so mapping
26
38
  # the tier here never encodes a rotting Codex model id (116 D1). opus is the deepest
@@ -35,22 +47,37 @@ module AgentModels
35
47
 
36
48
  module_function
37
49
 
38
- # Pull the `agents.models` sub-hash out of a loaded config hash, tolerating a
39
- # missing or malformed shape. Returns a plain { basename => model } hash.
40
- def models_section(config)
50
+ # Pull { basename => model } out of a loaded config hash's `agents.models`
51
+ # section, scoped to `harness` ("claude" or "codex"), tolerating a missing or
52
+ # malformed shape. `agents.models` can mix two shapes: legacy FLAT scalar
53
+ # entries (agents.models.plastic-executor: sonnet), honored as the claude
54
+ # harness only, and harness-scoped sub-hashes (agents.models.claude.*,
55
+ # agents.models.codex.*). Nested wins over flat for the same agent on the
56
+ # claude harness; a non-claude harness reads ONLY its own nested sub-hash,
57
+ # never the flat entries, so a literal model id written under the flat form
58
+ # (or agents.models.claude.*) can never leak into another harness's config.
59
+ def models_section(config, harness: "claude")
41
60
  return {} unless config.is_a?(Hash)
42
61
  agents = config["agents"]
43
62
  return {} unless agents.is_a?(Hash)
44
63
  section = agents["models"]
45
- section.is_a?(Hash) ? section : {}
64
+ return {} unless section.is_a?(Hash)
65
+
66
+ nested = section[harness]
67
+ nested = nested.is_a?(Hash) ? nested : {}
68
+ return nested unless harness == "claude"
69
+
70
+ flat = section.reject { |_key, value| value.is_a?(Hash) }
71
+ flat.merge(nested)
46
72
  end
47
73
 
48
74
  # Override map for the installer: global overrides overlaid by project
49
- # overrides (project wins). Defaults are intentionally excluded. Unknown agent
50
- # keys are carried through as-is; install_agents simply never matches them to a
51
- # copied file, so they are ignored without raising.
52
- def override_map(project_config: {}, global_config: {})
53
- models_section(global_config).merge(models_section(project_config))
75
+ # overrides (project wins), scoped to `harness`. Defaults are intentionally
76
+ # excluded. Unknown agent keys are carried through as-is; install_agents
77
+ # simply never matches them to a copied file, so they are ignored without
78
+ # raising.
79
+ def override_map(project_config: {}, global_config: {}, harness: "claude")
80
+ models_section(global_config, harness: harness).merge(models_section(project_config, harness: harness))
54
81
  end
55
82
 
56
83
  # The model_reasoning_effort for a Plastic tier alias, or nil for any value that is not
@@ -259,6 +259,34 @@ module Bridge
259
259
  auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
260
260
  end
261
261
 
262
+ # --- Shared INDEX entry matcher (intent 188, D12/D13) -----------------------
263
+ #
264
+ # ONE definition site for the "- [ID <sep> Title](link)" shape both
265
+ # `intent_active?` (below) and `scripts/end-intent`'s own INDEX-move parser
266
+ # depend on, so the two regexes can never drift apart again (the em-dash-only
267
+ # bug was flagged and deferred at intents 96 and 169, then independently
268
+ # rediscovered as a SEPARATE end-intent defect at intent 188). Accepts a real
269
+ # em dash (U+2014) OR a plain hyphen as the id/title separator on READ.
270
+ # Hardening this widens intent_active?'s fail-open case: a hyphen-formatted
271
+ # `## Active` line used to read as not-active (lock gate failed open); it now
272
+ # reads as active (gate correctly blocks). Accepted as a bug fix (D13): no
273
+ # passing test relied on the old fail-open behavior. Every WRITE still emits
274
+ # the real em dash (D10); only what this matcher can PARSE has widened.
275
+ #
276
+ # The separator is built from the codepoint, not a literal byte in this
277
+ # source file, so this new code stays em-dash free (the shipped-file
278
+ # convention; store files like INDEX.md are the exempt surface this matcher
279
+ # READS, not where this constant lives). Matches the existing convention in
280
+ # scripts/end-intent.
281
+ EM_DASH = "\u2014".freeze
282
+ INDEX_ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
283
+
284
+ # Match `line` (already chomped) against the shared INDEX entry shape.
285
+ # Returns a MatchData (captures: 1 = id, 2 = title, 3 = link) or nil.
286
+ def self.index_entry_match(line)
287
+ line.to_s.match(INDEX_ENTRY_RE)
288
+ end
289
+
262
290
  # --- Terminal-state bridge purge (intent 80) -------------------------------
263
291
 
264
292
  # True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
@@ -284,7 +312,7 @@ module Bridge
284
312
  end
285
313
  next unless in_active
286
314
  break if stripped.start_with?("## ") # next section ends the Active block
287
- m = stripped.match(/^- \[(\S+) +—/)
315
+ m = index_entry_match(stripped)
288
316
  return true if m && m[1] == target
289
317
  end
290
318
  false
@@ -0,0 +1,110 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+
6
+ # Shared resolution for config_asks.yml: a shipped, declarative manifest that
7
+ # lets a release announce a brand new config question without editing
8
+ # update.rb, doctor.rb, or any skill. Sibling of deprecations.yml (announce
9
+ # only); this one also tracks whether the user has answered or dismissed each
10
+ # entry, via config_asks_dismissed (mirrors deprecations_dismissed).
11
+ #
12
+ # Footgun for whoever adds the next entry: pending reads config.yml directly
13
+ # and never merges in read-config's DEFAULTS, so keying a new entry on
14
+ # something that already has a non-nil value in read-config's DEFAULTS would
15
+ # make that entry look unset, and therefore pending, forever, even though the
16
+ # rest of Plastic already treats it as answered by that default. See the
17
+ # matching note in config_asks.yml's schema header.
18
+ module ConfigAsks
19
+ FILENAME = "config_asks.yml"
20
+
21
+ # nil if the manifest is absent (a legitimate no-op: no release has declared
22
+ # a config question yet) or valid. A short description of the problem if the
23
+ # file exists but could not be read or parsed, or does not declare a
24
+ # config_asks array. Callers use this to tell "nothing declared" apart from
25
+ # "declared but broken" -- the manifest being unreadable must never look
26
+ # like a clean pass.
27
+ def self.manifest_error(plastic_home)
28
+ path = File.join(plastic_home, FILENAME)
29
+ return nil unless File.exist?(path)
30
+
31
+ data = YAML.safe_load(File.read(path))
32
+ return "#{FILENAME} does not declare a config_asks list" unless data.is_a?(Hash) && data["config_asks"].is_a?(Array)
33
+
34
+ nil
35
+ rescue StandardError => e
36
+ "#{FILENAME} could not be read: #{e.message}"
37
+ end
38
+
39
+ # All declared entries, or [] if the manifest is missing or malformed. Use
40
+ # manifest_error alongside this when the difference between "no entries"
41
+ # and "could not read the manifest" matters (it always does for a health
42
+ # check or an announcement -- see manifest_error above).
43
+ def self.load_entries(plastic_home)
44
+ path = File.join(plastic_home, FILENAME)
45
+ return [] unless File.exist?(path)
46
+
47
+ data = YAML.safe_load(File.read(path)) || {}
48
+ entries = data["config_asks"]
49
+ entries.is_a?(Array) ? entries : []
50
+ rescue StandardError
51
+ []
52
+ end
53
+
54
+ # Entries whose key is unset in config.yml AND whose id is not dismissed AND
55
+ # whose agents (if any) include agent_key. Deliberately ignores "introduced"
56
+ # -- see the schema comment in config_asks.yml for why (retro-fire).
57
+ #
58
+ # agent_key: nil means "do not filter by agent" (every entry applies); pass
59
+ # the caller's actual agent ("claude", "codex", "hermes") to respect an
60
+ # entry's agents scoping.
61
+ def self.pending(plastic_home, agent_key = nil)
62
+ config = load_config(plastic_home)
63
+ dismissed = Array(config["config_asks_dismissed"])
64
+
65
+ load_entries(plastic_home).select do |entry|
66
+ next false if dismissed.include?(entry["id"])
67
+ next false unless applies_to_agent?(entry, agent_key)
68
+
69
+ value = dig(config, entry["key"].to_s)
70
+ value.nil? || value == ""
71
+ end
72
+ end
73
+
74
+ # The exact command that answers one option of one entry.
75
+ def self.write_config_command(plastic_home, key, value)
76
+ "ruby #{File.join(plastic_home, "scripts", "write-config")} #{key} #{value}"
77
+ end
78
+
79
+ # The exact command that dismisses one entry ("not now" / keep default).
80
+ def self.dismiss_command(plastic_home, id)
81
+ "ruby #{File.join(plastic_home, "scripts", "write-config")} config_asks_dismissed --push #{id}"
82
+ end
83
+
84
+ # An entry with no agents field (or an empty one) applies to every agent.
85
+ # Otherwise it applies only when agent_key is nil (no filtering requested)
86
+ # or is present in the entry's agents list.
87
+ def self.applies_to_agent?(entry, agent_key)
88
+ scoped = Array(entry["agents"])
89
+ return true if scoped.empty?
90
+ return true if agent_key.nil?
91
+
92
+ scoped.include?(agent_key)
93
+ end
94
+ private_class_method :applies_to_agent?
95
+
96
+ def self.load_config(plastic_home)
97
+ path = File.join(plastic_home, "config.yml")
98
+ return {} unless File.exist?(path)
99
+
100
+ YAML.safe_load(File.read(path)) || {}
101
+ rescue StandardError
102
+ {}
103
+ end
104
+ private_class_method :load_config
105
+
106
+ def self.dig(hash, dotted_key)
107
+ dotted_key.split(".").reduce(hash) { |acc, k| acc.is_a?(Hash) ? acc[k] : nil }
108
+ end
109
+ private_class_method :dig
110
+ end
@@ -197,11 +197,17 @@ module GraphRebuild
197
197
 
198
198
  # Classify a resolved (store, id) relative to the referer, using the live store
199
199
  # index to detect dead targets:
200
- # - target id present in the referer's OWN store -> :same_store (collapse to bare)
201
- # - target id present in a DIFFERENT store -> :cross_store (keep slug:id)
202
- # - target id present NOWHERE -> :dead (drop)
200
+ # - the store token resolves to no known store -> :unknown_store (NEVER
201
+ # dropped; this is what makes a future discovery miss non-destructive, intent 189 D2)
202
+ # - target id present in the referer's OWN known store -> :same_store (collapse)
203
+ # - target id present in a DIFFERENT known store -> :cross_store (keep slug:id)
204
+ # - target id present NOWHERE in a known store -> :dead (drop)
203
205
  def classify(store_tok, bare, referer_store, store_index, original_ref)
204
206
  canonical = canonical_store_key(store_tok, store_index)
207
+ unless known_store?(canonical, store_index)
208
+ return { status: :unknown_store, ref: original_ref.to_s, store: store_tok }
209
+ end
210
+
205
211
  if ids_in(store_index, canonical).include?(bare)
206
212
  if canonical == referer_store
207
213
  { status: :same_store, id: bare }
@@ -213,6 +219,15 @@ module GraphRebuild
213
219
  end
214
220
  end
215
221
 
222
+ # True iff `canonical` names a store this run actually knows about (it is "global", or a
223
+ # literal key in `store_index`). A ref whose store token canonicalizes to anything else
224
+ # has never been discovered by this run, and must be classified :unknown_store, never
225
+ # :dead: those are different facts (store unknown vs. id absent from a known store) and
226
+ # only the second one means the ref is genuinely gone.
227
+ def known_store?(canonical, store_index)
228
+ canonical == "global" || (store_index || {}).key?(canonical)
229
+ end
230
+
216
231
  def ids_in(store_index, store_key)
217
232
  Array((store_index || {})[store_key])
218
233
  end
@@ -225,8 +240,11 @@ module GraphRebuild
225
240
  # relocation_map — from build_relocation_map (spans all stores)
226
241
  # store_index — { store_key => bare ids present } (spans all stores)
227
242
  #
228
- # Returns { nodes: <new map>, changes: [ {intent:, kind:, before:, after:} ] }.
229
- # kinds: :dedupe, :i3, :repoint, :collapse, :drop, :i1_backlink.
243
+ # Returns { nodes: <new map>, changes: [ {intent:, kind:, before:, after:} ],
244
+ # preserved: [ {intent:, field:, ref:, store:} ] }.
245
+ # kinds (changes, real mutations only): :dedupe, :i3, :repoint, :collapse, :drop,
246
+ # :i1_backlink. `preserved` is DIFFERENT: an unknown-store ref left byte-for-byte
247
+ # unchanged, reported for visibility, never counted as a "change" (nothing mutated).
230
248
  #
231
249
  # Order is load-bearing (spec Phase 2):
232
250
  # 1. dedupe each array order-preserving
@@ -247,6 +265,7 @@ module GraphRebuild
247
265
  end
248
266
 
249
267
  changes = []
268
+ preserved = []
250
269
 
251
270
  out.each do |id, edges|
252
271
  # 1. dedupe order-preserving
@@ -289,6 +308,11 @@ module GraphRebuild
289
308
  changes << { intent: id, kind: :repoint, field: field, before: ref, after: res[:ref] }
290
309
  end
291
310
  rebuilt << res[:ref]
311
+ when :unknown_store
312
+ # NEVER drop: the store is unrecognized, not the id absent from a known store.
313
+ # Preserve byte-for-byte and report separately from `changes` (nothing mutated).
314
+ preserved << { intent: id, field: field, ref: ref, store: res[:store] }
315
+ rebuilt << ref
292
316
  when :dead
293
317
  changes << { intent: id, kind: :drop, field: field, before: ref, after: nil }
294
318
  # dropped: not appended
@@ -323,6 +347,6 @@ module GraphRebuild
323
347
  end
324
348
  end
325
349
 
326
- { nodes: out, changes: changes }
350
+ { nodes: out, changes: changes, preserved: preserved }
327
351
  end
328
352
  end
@@ -48,6 +48,7 @@ module HookRegistry
48
48
  ] },
49
49
  { "matcher" => "Write|Edit", "hooks" => [
50
50
  { "name" => "savepoint-pre", "status" => "Recording stage start..." },
51
+ { "name" => "links-gate", "status" => "Checking Links gate..." },
51
52
  ] },
52
53
  { "matcher" => CREATE_MATCHER, "hooks" => [
53
54
  { "name" => "create-gate", "status" => "Checking create gate..." },
@@ -84,7 +85,7 @@ module HookRegistry
84
85
  # [{"matcher","hooks":[{"type":"command","command","statusMessage"}]}]}},
85
86
  # identical to Claude's shape, string command. Single source of truth (108 D7):
86
87
  # any drift from `events` is a bug, pinned by test.
87
- CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre create-gate].freeze
88
+ CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre links-gate create-gate].freeze
88
89
  CODEX_POST_HOOKS = %w[gate-check].freeze
89
90
 
90
91
  def codex_hooks_json(dispatcher_path:)
@@ -251,16 +251,37 @@ class InstallerCore
251
251
  puts " \u{2705} Core files synced (v#{version})"
252
252
  end
253
253
 
254
+ # Templates ship in full: every file under templates/ in the repo must reach
255
+ # ~/.plastic/templates/ on install/update. Derived from Dir.glob so a new
256
+ # template file added later is registered automatically, closing the
257
+ # whack-a-mole pattern that hid templates/index.md and templates/project.yml
258
+ # from every install for five weeks (intent 190).
259
+ def template_files
260
+ Dir.glob(File.join(package_root, "templates", "*")).each_with_object({}) do |path, acc|
261
+ next unless File.file?(path)
262
+
263
+ rel = File.join("templates", File.basename(path))
264
+ acc[rel] = rel
265
+ end
266
+ end
267
+
254
268
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
255
269
  # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
256
- # by install_sync_test).
270
+ # by install_sync_test). The templates half is glob-derived (template_files above); the
271
+ # rest stays a hand-written literal.
257
272
  def core_files
273
+ hand_registered_files.merge(template_files)
274
+ end
275
+
276
+ def hand_registered_files
258
277
  {
259
278
  "PLASTIC.md" => "PLASTIC.md",
260
279
  "PLASTIC-reference.md" => "PLASTIC-reference.md",
261
280
  "deprecations.yml" => "deprecations.yml",
281
+ "config_asks.yml" => "config_asks.yml",
262
282
  "scripts/folgezettel-id" => "scripts/folgezettel-id",
263
283
  "scripts/read-config" => "scripts/read-config",
284
+ "scripts/write-config" => "scripts/write-config",
264
285
  "scripts/select-update-target" => "scripts/select-update-target",
265
286
  "scripts/hook-session-start" => "scripts/hook-session-start",
266
287
  "scripts/hook-continue" => "scripts/hook-continue",
@@ -271,6 +292,7 @@ class InstallerCore
271
292
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
272
293
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
273
294
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
295
+ "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
274
296
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
275
297
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
276
298
  "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
@@ -296,6 +318,7 @@ class InstallerCore
296
318
  "scripts/roadmap-next" => "scripts/roadmap-next",
297
319
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
298
320
  "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
321
+ "scripts/lib/store_discovery.rb" => "scripts/lib/store_discovery.rb",
299
322
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
300
323
  "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
301
324
  "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
@@ -303,21 +326,21 @@ class InstallerCore
303
326
  "scripts/project-links" => "scripts/project-links",
304
327
  "scripts/link-suggest" => "scripts/link-suggest",
305
328
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
329
+ "scripts/lib/restore_intent_v1.rb" => "scripts/lib/restore_intent_v1.rb",
330
+ "scripts/restore-intent-v1" => "scripts/restore-intent-v1",
306
331
  "scripts/validate-intent" => "scripts/validate-intent",
307
332
  "scripts/new-intent" => "scripts/new-intent",
308
333
  "scripts/end-intent" => "scripts/end-intent",
309
334
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
335
+ "scripts/hook-links-gate" => "scripts/hook-links-gate",
336
+ "scripts/lib/links_gate.rb" => "scripts/lib/links_gate.rb",
310
337
  "scripts/lib/apply_patch_envelope.rb" => "scripts/lib/apply_patch_envelope.rb",
311
338
  "scripts/codex-hook" => "scripts/codex-hook",
312
- "templates/intent.md" => "templates/intent.md",
313
- "templates/spec.md" => "templates/spec.md",
314
- "templates/plan.md" => "templates/plan.md",
315
- "templates/checklist.md" => "templates/checklist.md",
316
- "templates/outcome.md" => "templates/outcome.md",
317
- "templates/revisions.md" => "templates/revisions.md",
318
339
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
319
340
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
320
341
  "scripts/provision-project-store" => "scripts/provision-project-store",
342
+ "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
343
+ "scripts/validate-project" => "scripts/validate-project",
321
344
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
322
345
  "scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
323
346
  "scripts/install.rb" => "scripts/install.rb",
@@ -465,12 +488,16 @@ class InstallerCore
465
488
  installed << dest
466
489
  end
467
490
 
468
- # Copy skills as flat, hyphen-namespaced personal skills (plastic-<name>/)
491
+ # Copy skills as flat, hyphen-namespaced personal skills (plastic-<name>/).
492
+ # advisor.enabled: false skips the agent-advisor skill along with both
493
+ # advisor agents below, so a user who declined the advisor never sees a
494
+ # dead-end skill pointing at nothing installed.
469
495
  skills_source = File.join(package_root, "skills")
470
- installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
496
+ skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
497
+ installed += install_skills_flat(skills_source, skills_root, exclude: skill_exclude) if File.directory?(skills_source)
471
498
 
472
499
  # Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
473
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
500
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
474
501
 
475
502
  # Write VERSION
476
503
  version_file = File.join(plastic_dir, "VERSION")
@@ -495,8 +522,12 @@ class InstallerCore
495
522
  def install_codex(config, force)
496
523
  installed = []
497
524
  skills_source = File.join(package_root, "skills")
498
- installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
499
- installed += generate_codex_agents(File.join(config[:home_dir], "agents"), models: agent_model_overrides)
525
+ skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
526
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
527
+ # Codex-scoped overrides only (agents.models.codex.*): a literal Claude
528
+ # model id set under agents.models.claude.* (or the legacy flat form,
529
+ # which resolves as claude) must never reach a Codex TOML.
530
+ installed += generate_codex_agents(File.join(config[:home_dir], "agents"), models: agent_model_overrides(harness: "codex"))
500
531
 
501
532
  # Instruction injection (L1): Plastic standing conventions into ~/.codex/AGENTS.md.
502
533
  # Partial-ownership file, so it is NOT manifest-tracked (stripped surgically on uninstall).
@@ -525,8 +556,17 @@ class InstallerCore
525
556
  return [] if sources.empty?
526
557
 
527
558
  FileUtils.mkdir_p(agents_root)
528
- sources.map do |src|
559
+ sources.filter_map do |src|
529
560
  basename = File.basename(src, ".md")
561
+ # Codex advisor support is out of scope for this release (intent 185): the
562
+ # owner has not evaluated the Codex reasoning-model ecosystem long enough to
563
+ # judge it. Skip every AgentModels::CONSULTATION_AGENTS file (both
564
+ # plastic-advisor and plastic-faux-advisor) by name, a deliberate and
565
+ # mechanical scope cut tracked at intent 186 (Codex advisor evaluation), not
566
+ # a permanent exclusion and not conditioned on any frontmatter or override
567
+ # value.
568
+ next if AgentModels::CONSULTATION_AGENTS.include?(basename)
569
+
530
570
  dest = File.join(agents_root, "#{basename}.toml")
531
571
  write_text_atomic(dest, render_codex_agent_toml(src, models[basename]))
532
572
  dest
@@ -639,8 +679,9 @@ class InstallerCore
639
679
  def install_hermes(config, force)
640
680
  installed = []
641
681
  skills_source = File.join(package_root, "skills")
642
- installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
643
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
682
+ skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
683
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
684
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
644
685
 
645
686
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
646
687
  { agent: config[:name], success: true, files: installed.size }
@@ -650,12 +691,14 @@ class InstallerCore
650
691
  # directory name -- the only personal-skill namespacing Claude Code supports).
651
692
  # Any top-level underscore-prefixed markdown fragment (e.g. `_active-intent-gate.md`,
652
693
  # `_decision-tables.md`) is a shared non-skill fragment and relocates to ~/.plastic/
653
- # instead, so every skill can read it from one shared location.
654
- def install_skills_flat(skills_source, skills_root)
694
+ # instead, so every skill can read it from one shared location. `exclude` skips
695
+ # named top-level skill directories entirely (intent 185: the agent-advisor skill
696
+ # when advisor.enabled is false).
697
+ def install_skills_flat(skills_source, skills_root, exclude: [])
655
698
  installed = []
656
699
  FileUtils.mkdir_p(skills_root)
657
700
 
658
- Dir.children(skills_source).reject { |e| e.start_with?(".") }.each do |entry|
701
+ Dir.children(skills_source).reject { |e| e.start_with?(".") || exclude.include?(e) }.each do |entry|
659
702
  src = File.join(skills_source, entry)
660
703
  if File.directory?(src)
661
704
  installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
@@ -675,10 +718,18 @@ class InstallerCore
675
718
  # equivalents). Returns the installed destination paths so callers can append
676
719
  # them to `installed` before write_manifest (manifest + prune are then automatic).
677
720
  # No-op safe: returns [] when the package has no agents dir or it is empty.
678
- def install_agents(agents_root, models: {})
721
+ # advisor_enabled: false (advisor.enabled config key) skips every
722
+ # AgentModels::CONSULTATION_AGENTS file entirely (both plastic-advisor and
723
+ # plastic-faux-advisor), so a user who declined the advisor never gets either
724
+ # agent installed.
725
+ def install_agents(agents_root, models: {}, advisor_enabled: true)
679
726
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
680
727
  return [] if sources.empty?
681
728
 
729
+ unless advisor_enabled
730
+ sources = sources.reject { |src| AgentModels::CONSULTATION_AGENTS.include?(File.basename(src, ".md")) }
731
+ end
732
+
682
733
  FileUtils.mkdir_p(agents_root)
683
734
  sources.map do |src|
684
735
  dest = File.join(agents_root, File.basename(src))
@@ -700,9 +751,18 @@ class InstallerCore
700
751
  end
701
752
 
702
753
  # Resolve per-agent model overrides for this install: project config (when a
703
- # project dir is known) overlaid on global config. Defaults are NOT included,
704
- # so unconfigured agents keep their shipped frontmatter.
705
- def agent_model_overrides(project_dir = nil)
754
+ # project dir is known) overlaid on global config, scoped to `harness`
755
+ # ("claude" or "codex"). Defaults are NOT included, so unconfigured agents
756
+ # keep their shipped frontmatter.
757
+ #
758
+ # Both advisor agents (plastic-advisor, plastic-faux-advisor) resolve through
759
+ # this SAME generic map, like any other agent: a config author sets
760
+ # agents.models.claude.plastic-advisor (or the legacy flat
761
+ # agents.models.plastic-advisor, read as claude) to point either agent at a
762
+ # different literal model. There is no separate advisor-specific model key;
763
+ # which agent the advisor SKILL routes to by default is a routing decision
764
+ # (advisor.claude.default), never a model-selection one.
765
+ def agent_model_overrides(project_dir = nil, harness: "claude")
706
766
  global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
707
767
  project_config =
708
768
  if project_dir
@@ -710,7 +770,54 @@ class InstallerCore
710
770
  else
711
771
  {}
712
772
  end
713
- AgentModels.override_map(project_config: project_config, global_config: global_config)
773
+ AgentModels.override_map(project_config: project_config, global_config: global_config, harness: harness)
774
+ end
775
+
776
+ # advisor.enabled: project overlays global, missing or malformed counts as
777
+ # enabled (fail-open). Harness-blind: false skips both advisor agents and the
778
+ # agent-advisor skill on every installed harness.
779
+ def advisor_enabled?(project_dir = nil)
780
+ global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
781
+ project_config = project_dir ? load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml")) : {}
782
+ value = project_config.dig("advisor", "enabled")
783
+ value = global_config.dig("advisor", "enabled") if value.nil?
784
+ value != false
785
+ end
786
+
787
+ # Agent-name shorthands for the --advisor flag: the two shipped choices,
788
+ # named for the role (real advisor vs. the cheaper imitation), never a model
789
+ # name.
790
+ ADVISOR_SHORTHANDS = { "real" => "plastic-advisor", "faux" => "plastic-faux-advisor" }.freeze
791
+
792
+ # Write advisor.enabled / advisor.claude.default into the global config.yml
793
+ # from install-time flags. Absent flags change nothing: advisor.enabled
794
+ # defaults to enabled when missing, and advisor.claude.default is left unset
795
+ # (the skill's own fallback chain applies) when missing.
796
+ # --no-advisor -> advisor.enabled: false
797
+ # --advisor VALUE -> advisor.claude.default: VALUE (an agent name, or the
798
+ # shorthand "real"/"faux")
799
+ def apply_config_flags(argv)
800
+ no_advisor = argv.include?("--no-advisor")
801
+ advisor_idx = argv.index("--advisor")
802
+ advisor_value = advisor_idx && argv[advisor_idx + 1]
803
+ return unless no_advisor || advisor_value
804
+
805
+ config_path = File.join(plastic_home, "config.yml")
806
+ config = load_config_yaml(config_path)
807
+
808
+ if no_advisor
809
+ config["advisor"] ||= {}
810
+ config["advisor"]["enabled"] = false
811
+ end
812
+ if advisor_value
813
+ agent_name = ADVISOR_SHORTHANDS[advisor_value] || advisor_value
814
+ config["advisor"] ||= {}
815
+ config["advisor"]["claude"] ||= {}
816
+ config["advisor"]["claude"]["default"] = agent_name
817
+ end
818
+
819
+ FileUtils.mkdir_p(plastic_home)
820
+ File.write(config_path, YAML.dump(config))
714
821
  end
715
822
 
716
823
  def load_config_yaml(path)