@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.
- package/PLASTIC-reference.md +8 -6
- package/PLASTIC.md +68 -6
- package/README.md +5 -0
- package/agents/plastic-advisor.md +56 -0
- package/agents/plastic-enforcer.md +9 -1
- package/agents/plastic-faux-advisor.md +174 -0
- package/agents/plastic-future-intent-researcher.md +1 -0
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/hooks/statusline +1 -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/install.rb +8 -0
- package/scripts/lib/agent_models.rb +36 -9
- 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 +130 -23
- 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/agent-advisor/SKILL.md +92 -0
- package/skills/agent-advisor/references/advisor-protocol.md +245 -0
- package/skills/auto/SKILL.md +26 -12
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/install/SKILL.md +30 -2
- 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 +34 -4
- package/templates/config.yml +31 -6
- 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
|
|
39
|
-
#
|
|
40
|
-
|
|
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)
|
|
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)
|
|
50
|
-
# keys are carried through as-is; install_agents
|
|
51
|
-
# copied file, so they are ignored without
|
|
52
|
-
|
|
53
|
-
|
|
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
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
|
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
|
-
# -
|
|
201
|
-
#
|
|
202
|
-
# - target id present
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
installed +=
|
|
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.
|
|
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
|
-
|
|
643
|
-
installed +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
704
|
-
#
|
|
705
|
-
|
|
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)
|