@zalom/plastic 2.0.0-alpha.25 → 2.0.0-alpha.27
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/README.md +3 -3
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-executor.md +1 -0
- package/agents/plastic-node-research.md +4 -2
- package/agents/plastic-node-verify.md +1 -0
- package/agents/plastic-node-work.md +1 -0
- package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +6 -8
- package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
- package/config_asks.yml +4 -4
- package/package.json +1 -1
- package/scripts/doctor.rb +9 -7
- package/scripts/install.rb +4 -4
- package/scripts/lib/action_graph_shim.rb +3 -1
- package/scripts/lib/agent_models.rb +44 -26
- package/scripts/lib/codex_adapter.rb +5 -4
- package/scripts/lib/doctor_core.rb +1 -1
- package/scripts/lib/graph_measure_models.rb +1 -1
- package/scripts/lib/installer_core.rb +102 -37
- package/scripts/lib/node_file.rb +22 -2
- package/scripts/lib/node_input.rb +8 -4
- package/scripts/lib/node_return.rb +8 -3
- package/scripts/lib/runner_absorb.rb +56 -0
- package/scripts/lib/runner_dispatch.rb +12 -8
- package/scripts/lib/runner_policy.rb +28 -10
- package/scripts/node-run +2 -1
- package/scripts/read-config +44 -6
- package/skills/agent-advisor/SKILL.md +17 -25
- package/skills/agent-advisor/references/advisor-protocol.md +17 -19
- package/skills/auto/SKILL.md +2 -2
- package/skills/install/SKILL.md +5 -7
- package/skills/update/SKILL.md +1 -1
- package/templates/config.yml +13 -6
- package/templates/node-research.md +4 -2
|
@@ -1132,7 +1132,12 @@ class InstallerCore
|
|
|
1132
1132
|
# Codex-scoped overrides only (agents.models.codex.*): a literal Claude
|
|
1133
1133
|
# model id set under agents.models.claude.* (or the legacy flat form,
|
|
1134
1134
|
# which resolves as claude) must never reach a Codex TOML.
|
|
1135
|
-
installed += generate_codex_agents(
|
|
1135
|
+
installed += generate_codex_agents(
|
|
1136
|
+
File.join(config[:home_dir], "agents"),
|
|
1137
|
+
models: agent_model_overrides(harness: "codex"),
|
|
1138
|
+
efforts: agent_effort_overrides(harness: "codex"),
|
|
1139
|
+
advisor_enabled: advisor_enabled?
|
|
1140
|
+
)
|
|
1136
1141
|
|
|
1137
1142
|
# Instruction injection (L1): Plastic standing conventions into ~/.codex/AGENTS.md.
|
|
1138
1143
|
# Partial-ownership file, so it is NOT manifest-tracked (stripped surgically on uninstall).
|
|
@@ -1162,24 +1167,17 @@ class InstallerCore
|
|
|
1162
1167
|
# a whole-file, Plastic-owned .toml per repo agents/*.md instead of copying markdown.
|
|
1163
1168
|
# The returned paths append to `installed`, so they are manifest-tracked and pruned on
|
|
1164
1169
|
# uninstall by the manifest whole-file-delete path, exactly like ~/.claude/agents/*.md.
|
|
1165
|
-
def generate_codex_agents(agents_root, models: {})
|
|
1170
|
+
def generate_codex_agents(agents_root, models: {}, efforts: {}, advisor_enabled: true)
|
|
1166
1171
|
sources = Dir.glob(File.join(package_root, "agents", "*.md"))
|
|
1167
1172
|
return [] if sources.empty?
|
|
1168
1173
|
|
|
1169
1174
|
FileUtils.mkdir_p(agents_root)
|
|
1170
1175
|
sources.filter_map do |src|
|
|
1171
1176
|
basename = File.basename(src, ".md")
|
|
1172
|
-
|
|
1173
|
-
# owner has not evaluated the Codex reasoning-model ecosystem long enough to
|
|
1174
|
-
# judge it. Skip every AgentModels::CONSULTATION_AGENTS file (both
|
|
1175
|
-
# plastic-advisor and plastic-faux-advisor) by name, a deliberate and
|
|
1176
|
-
# mechanical scope cut tracked at intent 186 (Codex advisor evaluation), not
|
|
1177
|
-
# a permanent exclusion and not conditioned on any frontmatter or override
|
|
1178
|
-
# value.
|
|
1179
|
-
next if AgentModels::CONSULTATION_AGENTS.include?(basename)
|
|
1177
|
+
next if !advisor_enabled && AgentModels::CONSULTATION_AGENTS.include?(basename)
|
|
1180
1178
|
|
|
1181
1179
|
dest = File.join(agents_root, "#{basename}.toml")
|
|
1182
|
-
write_text_atomic(dest, render_codex_agent_toml(src, models[basename]))
|
|
1180
|
+
write_text_atomic(dest, render_codex_agent_toml(src, models[basename], efforts[basename]))
|
|
1183
1181
|
dest
|
|
1184
1182
|
end
|
|
1185
1183
|
end
|
|
@@ -1187,16 +1185,25 @@ class InstallerCore
|
|
|
1187
1185
|
# Render one repo agents/*.md into a deterministic Codex agent TOML document. Fixed field
|
|
1188
1186
|
# order (name, description, the model field(s) from codex_model_fields, developer_instructions)
|
|
1189
1187
|
# so regenerate is byte-identical (idempotency).
|
|
1190
|
-
def render_codex_agent_toml(source_path, override)
|
|
1188
|
+
def render_codex_agent_toml(source_path, override, effort_override = nil)
|
|
1191
1189
|
front, body = split_frontmatter(File.read(source_path))
|
|
1192
1190
|
name = (front["name"] || File.basename(source_path, ".md")).to_s
|
|
1193
1191
|
description = (front["description"] || "").to_s
|
|
1194
|
-
effective =
|
|
1192
|
+
effective = if override && !override.to_s.empty?
|
|
1193
|
+
override
|
|
1194
|
+
else
|
|
1195
|
+
AgentModels.shipped_model_for(name, harness: "codex") || front["model"]
|
|
1196
|
+
end
|
|
1197
|
+
effort = if effort_override && !effort_override.to_s.empty?
|
|
1198
|
+
effort_override
|
|
1199
|
+
else
|
|
1200
|
+
front["effort"] || AgentModels.shipped_effort_for(name)
|
|
1201
|
+
end
|
|
1195
1202
|
|
|
1196
1203
|
parts = []
|
|
1197
1204
|
parts << %(name = "#{toml_inline_escape(name)}")
|
|
1198
1205
|
parts << %(description = "#{toml_inline_escape(description)}")
|
|
1199
|
-
parts << codex_model_fields(effective)
|
|
1206
|
+
parts << codex_model_fields(effective, effort: effort)
|
|
1200
1207
|
parts << "developer_instructions = \"\"\"\n#{toml_ml_escape(body.strip)}\n\"\"\""
|
|
1201
1208
|
parts.reject(&:empty?).join("\n") + "\n"
|
|
1202
1209
|
end
|
|
@@ -1216,20 +1223,20 @@ class InstallerCore
|
|
|
1216
1223
|
# The model-selection line(s). A known tier alias (opus/sonnet/haiku) emits BOTH a `model` line
|
|
1217
1224
|
# (from AgentModels.codex_model_for, the intent-186 per-role Codex identity) and a
|
|
1218
1225
|
# model_reasoning_effort line, model first for deterministic byte-identical regenerate. Any other
|
|
1219
|
-
# non-empty value is a literal Codex model id
|
|
1220
|
-
#
|
|
1221
|
-
|
|
1222
|
-
def codex_model_fields(effective)
|
|
1226
|
+
# non-empty value is a literal Codex model id. Every non-empty model also emits the resolved effort,
|
|
1227
|
+
# medium by default or the harness-scoped override. Empty emits no model fields.
|
|
1228
|
+
def codex_model_fields(effective, effort: AgentModels::DEFAULT_EFFORT)
|
|
1223
1229
|
return "" if effective.nil? || effective.to_s.empty?
|
|
1224
|
-
|
|
1225
|
-
if effort
|
|
1230
|
+
if AgentModels.effort_for(effective)
|
|
1226
1231
|
lines = []
|
|
1227
1232
|
model = AgentModels.codex_model_for(effective)
|
|
1228
1233
|
lines << %(model = "#{toml_inline_escape(model)}") if model && !model.to_s.empty?
|
|
1229
|
-
lines << %(model_reasoning_effort = "#{effort}")
|
|
1234
|
+
lines << %(model_reasoning_effort = "#{toml_inline_escape(effort)}") unless effort.to_s.empty?
|
|
1230
1235
|
lines.join("\n")
|
|
1231
1236
|
else
|
|
1232
|
-
%(model = "#{toml_inline_escape(effective.to_s)}")
|
|
1237
|
+
lines = [%(model = "#{toml_inline_escape(effective.to_s)}")]
|
|
1238
|
+
lines << %(model_reasoning_effort = "#{toml_inline_escape(effort)}") unless effort.to_s.empty?
|
|
1239
|
+
lines.join("\n")
|
|
1233
1240
|
end
|
|
1234
1241
|
end
|
|
1235
1242
|
|
|
@@ -1362,8 +1369,7 @@ class InstallerCore
|
|
|
1362
1369
|
# them to `installed` before write_manifest (manifest + prune are then automatic).
|
|
1363
1370
|
# No-op safe: returns [] when the package has no agents dir or it is empty.
|
|
1364
1371
|
# advisor_enabled: false (advisor.enabled config key) skips every
|
|
1365
|
-
# AgentModels::CONSULTATION_AGENTS file entirely
|
|
1366
|
-
# plastic-faux-advisor), so a user who declined the advisor never gets either
|
|
1372
|
+
# AgentModels::CONSULTATION_AGENTS file entirely, so a user who declined the advisor never gets either
|
|
1367
1373
|
# agent installed.
|
|
1368
1374
|
def install_agents(agents_root, models: {}, efforts: {}, advisor_enabled: true)
|
|
1369
1375
|
sources = Dir.glob(File.join(package_root, "agents", "*.md"))
|
|
@@ -1408,18 +1414,21 @@ class InstallerCore
|
|
|
1408
1414
|
# ("claude" or "codex"). Defaults are NOT included, so unconfigured agents
|
|
1409
1415
|
# keep their shipped frontmatter.
|
|
1410
1416
|
#
|
|
1411
|
-
# Both advisor agents
|
|
1417
|
+
# Both advisor agents resolve through
|
|
1412
1418
|
# this SAME generic map, like any other agent: a config author sets
|
|
1413
|
-
# agents.models.claude
|
|
1414
|
-
# agents.models.plastic-advisor, read as claude) to point either agent at a
|
|
1419
|
+
# agents.models.claude.<agent> (or the legacy flat form, read as Claude) to point either agent at a
|
|
1415
1420
|
# different literal model. There is no separate advisor-specific model key;
|
|
1416
1421
|
# which agent the advisor SKILL routes to by default is a routing decision
|
|
1417
1422
|
# (advisor.claude.default), never a model-selection one.
|
|
1418
1423
|
def agent_model_overrides(project_dir = nil, harness: "claude")
|
|
1419
|
-
|
|
1424
|
+
global_path = File.join(plastic_home, "config.yml")
|
|
1425
|
+
migrate_advisor_config_file(global_path)
|
|
1426
|
+
global_config = load_config_yaml(global_path)
|
|
1420
1427
|
project_config =
|
|
1421
1428
|
if project_dir
|
|
1422
|
-
|
|
1429
|
+
project_path = File.join(project_dir, ".plastic_store", "config.yml")
|
|
1430
|
+
migrate_advisor_config_file(project_path)
|
|
1431
|
+
load_config_yaml(project_path)
|
|
1423
1432
|
else
|
|
1424
1433
|
{}
|
|
1425
1434
|
end
|
|
@@ -1427,8 +1436,12 @@ class InstallerCore
|
|
|
1427
1436
|
end
|
|
1428
1437
|
|
|
1429
1438
|
def agent_effort_overrides(project_dir = nil, harness: "claude")
|
|
1430
|
-
|
|
1431
|
-
|
|
1439
|
+
global_path = File.join(plastic_home, "config.yml")
|
|
1440
|
+
migrate_advisor_config_file(global_path)
|
|
1441
|
+
global_config = load_config_yaml(global_path)
|
|
1442
|
+
project_path = project_dir && File.join(project_dir, ".plastic_store", "config.yml")
|
|
1443
|
+
migrate_advisor_config_file(project_path) if project_path
|
|
1444
|
+
project_config = project_path ? load_config_yaml(project_path) : {}
|
|
1432
1445
|
AgentModels.effort_override_map(project_config: project_config, global_config: global_config, harness: harness)
|
|
1433
1446
|
end
|
|
1434
1447
|
|
|
@@ -1443,10 +1456,14 @@ class InstallerCore
|
|
|
1443
1456
|
value != false
|
|
1444
1457
|
end
|
|
1445
1458
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1459
|
+
ADVISOR_NAME_MIGRATIONS = {
|
|
1460
|
+
"plastic-advisor" => "plastic-primary-advisor",
|
|
1461
|
+
"plastic-faux-advisor" => "plastic-secondary-advisor"
|
|
1462
|
+
}.freeze
|
|
1463
|
+
ADVISOR_SHORTHANDS = {
|
|
1464
|
+
"primary" => "plastic-primary-advisor", "secondary" => "plastic-secondary-advisor",
|
|
1465
|
+
"real" => "plastic-primary-advisor", "faux" => "plastic-secondary-advisor"
|
|
1466
|
+
}.freeze
|
|
1450
1467
|
|
|
1451
1468
|
# Write advisor.enabled / advisor.claude.default into the global config.yml
|
|
1452
1469
|
# from install-time flags. Absent flags change nothing: advisor.enabled
|
|
@@ -1454,7 +1471,7 @@ class InstallerCore
|
|
|
1454
1471
|
# (the skill's own fallback chain applies) when missing.
|
|
1455
1472
|
# --no-advisor -> advisor.enabled: false
|
|
1456
1473
|
# --advisor VALUE -> advisor.claude.default: VALUE (an agent name, or the
|
|
1457
|
-
# shorthand "
|
|
1474
|
+
# shorthand "primary"/"secondary")
|
|
1458
1475
|
def apply_config_flags(argv)
|
|
1459
1476
|
no_advisor = argv.include?("--no-advisor")
|
|
1460
1477
|
advisor_idx = argv.index("--advisor")
|
|
@@ -1462,7 +1479,13 @@ class InstallerCore
|
|
|
1462
1479
|
return unless no_advisor || advisor_value
|
|
1463
1480
|
|
|
1464
1481
|
config_path = File.join(plastic_home, "config.yml")
|
|
1465
|
-
config =
|
|
1482
|
+
config = if File.exist?(config_path)
|
|
1483
|
+
parsed = YAML.safe_load(File.read(config_path))
|
|
1484
|
+
return false unless parsed.nil? || parsed.is_a?(Hash)
|
|
1485
|
+
migrate_advisor_config(parsed || {})
|
|
1486
|
+
else
|
|
1487
|
+
{}
|
|
1488
|
+
end
|
|
1466
1489
|
|
|
1467
1490
|
if no_advisor
|
|
1468
1491
|
config["advisor"] ||= {}
|
|
@@ -1477,6 +1500,9 @@ class InstallerCore
|
|
|
1477
1500
|
|
|
1478
1501
|
FileUtils.mkdir_p(plastic_home)
|
|
1479
1502
|
File.write(config_path, YAML.dump(config))
|
|
1503
|
+
true
|
|
1504
|
+
rescue StandardError
|
|
1505
|
+
false
|
|
1480
1506
|
end
|
|
1481
1507
|
|
|
1482
1508
|
def load_config_yaml(path)
|
|
@@ -1486,6 +1512,45 @@ class InstallerCore
|
|
|
1486
1512
|
{}
|
|
1487
1513
|
end
|
|
1488
1514
|
|
|
1515
|
+
# Renames retired advisor keys without discarding a current key. It accepts
|
|
1516
|
+
# malformed config sections and leaves unrelated values untouched.
|
|
1517
|
+
def migrate_advisor_config(config)
|
|
1518
|
+
return {} unless config.is_a?(Hash)
|
|
1519
|
+
config = Marshal.load(Marshal.dump(config))
|
|
1520
|
+
advisor = config["advisor"]
|
|
1521
|
+
if advisor.is_a?(Hash) && advisor["claude"].is_a?(Hash)
|
|
1522
|
+
default = advisor["claude"]["default"]
|
|
1523
|
+
advisor["claude"]["default"] = ADVISOR_NAME_MIGRATIONS.fetch(default, default)
|
|
1524
|
+
end
|
|
1525
|
+
agents = config["agents"]
|
|
1526
|
+
%w[models efforts].each do |section_name|
|
|
1527
|
+
section = agents.is_a?(Hash) ? agents[section_name] : nil
|
|
1528
|
+
next unless section.is_a?(Hash)
|
|
1529
|
+
migrate_advisor_keys!(section)
|
|
1530
|
+
%w[claude codex].each { |harness| migrate_advisor_keys!(section[harness]) if section[harness].is_a?(Hash) }
|
|
1531
|
+
end
|
|
1532
|
+
config
|
|
1533
|
+
end
|
|
1534
|
+
|
|
1535
|
+
def migrate_advisor_config_file(path)
|
|
1536
|
+
return false unless path && File.file?(path)
|
|
1537
|
+
parsed = YAML.safe_load(File.read(path))
|
|
1538
|
+
return false unless parsed.is_a?(Hash)
|
|
1539
|
+
migrated = migrate_advisor_config(parsed)
|
|
1540
|
+
return false if migrated == parsed
|
|
1541
|
+
File.write(path, YAML.dump(migrated))
|
|
1542
|
+
true
|
|
1543
|
+
rescue StandardError
|
|
1544
|
+
false
|
|
1545
|
+
end
|
|
1546
|
+
|
|
1547
|
+
def migrate_advisor_keys!(section)
|
|
1548
|
+
ADVISOR_NAME_MIGRATIONS.each do |legacy, current|
|
|
1549
|
+
section[current] = section[legacy] if !section.key?(current) && section.key?(legacy)
|
|
1550
|
+
section.delete(legacy)
|
|
1551
|
+
end
|
|
1552
|
+
end
|
|
1553
|
+
|
|
1489
1554
|
# --- Legacy plugin migration ---
|
|
1490
1555
|
|
|
1491
1556
|
# Earlier versions registered Plastic as a local marketplace plugin
|
package/scripts/lib/node_file.rb
CHANGED
|
@@ -5,7 +5,8 @@ require "yaml"
|
|
|
5
5
|
require "date"
|
|
6
6
|
|
|
7
7
|
# NodeFile (intent 334, n3): one node file's YAML envelope (`node`, `kind`,
|
|
8
|
-
# `files`, `budget`) over a Markdown body,
|
|
8
|
+
# `files`, `budget`, and the optional research `report`) over a Markdown body,
|
|
9
|
+
# plus the deterministic id minter
|
|
9
10
|
# (327 D1r, D5r, D9r-D12r, D16r). The kind-prefix rule lives here, not in
|
|
10
11
|
# GraphEdges, which stays loose about id grammar so a numeric roadmap id
|
|
11
12
|
# parses the same way (D12r).
|
|
@@ -83,6 +84,8 @@ module NodeFile
|
|
|
83
84
|
|
|
84
85
|
budget, budget_errors = normalize_budget(fm["budget"])
|
|
85
86
|
errors.concat(budget_errors)
|
|
87
|
+
report, report_errors = normalize_report(fm["report"], kind)
|
|
88
|
+
errors.concat(report_errors)
|
|
86
89
|
|
|
87
90
|
{
|
|
88
91
|
ok: errors.empty?,
|
|
@@ -90,15 +93,32 @@ module NodeFile
|
|
|
90
93
|
kind: kind.empty? ? nil : kind,
|
|
91
94
|
files: files.is_a?(Array) ? files : nil,
|
|
92
95
|
budget: budget,
|
|
96
|
+
report: report,
|
|
93
97
|
body: body,
|
|
94
98
|
errors: errors,
|
|
95
99
|
}
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
def failure(errors)
|
|
99
|
-
{ ok: false, node: nil, kind: nil, files: nil, budget: nil, body: nil, errors: errors }
|
|
103
|
+
{ ok: false, node: nil, kind: nil, files: nil, budget: nil, report: nil, body: nil, errors: errors }
|
|
100
104
|
end
|
|
101
105
|
|
|
106
|
+
def normalize_report(raw, kind)
|
|
107
|
+
return [nil, []] if raw.nil?
|
|
108
|
+
return [nil, ["report: is allowed only on research nodes"]] unless kind == "research"
|
|
109
|
+
return [nil, ["report: must be a non-empty relative Markdown path under resources/"]] unless raw.is_a?(String)
|
|
110
|
+
|
|
111
|
+
path = raw
|
|
112
|
+
parts = path.split("/", -1)
|
|
113
|
+
valid = path == path.strip && parts.length >= 2 && parts.first == "resources" &&
|
|
114
|
+
parts.none? { |part| part.empty? || %w[. ..].include?(part) } &&
|
|
115
|
+
!path.include?("\\") && path.end_with?(".md")
|
|
116
|
+
return [nil, ["report: must be a relative .md path under resources/ with no traversal"]] unless valid
|
|
117
|
+
|
|
118
|
+
[path, []]
|
|
119
|
+
end
|
|
120
|
+
private_class_method :normalize_report
|
|
121
|
+
|
|
102
122
|
# <id>.md or <id>--<slug>.md, exactly - a longer id's file (n11--x.md) must
|
|
103
123
|
# never satisfy a shorter id (n1), so the id is matched as the whole prefix
|
|
104
124
|
# up to end-of-string or the literal "--" separator, never as a substring
|
|
@@ -118,22 +118,26 @@ module NodeInput
|
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
text = render_node_block(node: node, kind: parsed[:kind], files: parsed[:files], budget: parsed[:budget],
|
|
121
|
-
body: parsed[:body])
|
|
121
|
+
report: parsed[:report], body: parsed[:body])
|
|
122
122
|
{ ok: true, error_kind: nil, text: text, errors: [], kind: parsed[:kind], files: parsed[:files] || [],
|
|
123
|
-
budget: parsed[:budget] }
|
|
123
|
+
budget: parsed[:budget], report: parsed[:report] }
|
|
124
124
|
end
|
|
125
125
|
|
|
126
126
|
def failure_block(kind, errors)
|
|
127
|
-
{ ok: false, error_kind: kind, text: nil, errors: errors, kind: nil, files: nil, budget: nil }
|
|
127
|
+
{ ok: false, error_kind: kind, text: nil, errors: errors, kind: nil, files: nil, budget: nil, report: nil }
|
|
128
128
|
end
|
|
129
129
|
private_class_method :failure_block
|
|
130
130
|
|
|
131
|
-
def render_node_block(node:, kind:, files:, budget:, body:)
|
|
131
|
+
def render_node_block(node:, kind:, files:, budget:, body:, report: nil)
|
|
132
132
|
lines = []
|
|
133
133
|
lines << "# Node #{node}"
|
|
134
134
|
lines << "kind: #{kind}"
|
|
135
135
|
lines << "files: #{Array(files).join(', ')}"
|
|
136
136
|
lines << "budget: #{budget}"
|
|
137
|
+
if report
|
|
138
|
+
lines << "report: #{report}"
|
|
139
|
+
lines << "report delivery: return the complete Markdown body in the YAML report field; the runner writes it"
|
|
140
|
+
end
|
|
137
141
|
lines << ""
|
|
138
142
|
lines << body.to_s.strip
|
|
139
143
|
"#{lines.join("\n")}\n"
|
|
@@ -33,7 +33,7 @@ module NodeReturn
|
|
|
33
33
|
STATUSES = %w[done failed_verification needs_decision blocked].freeze
|
|
34
34
|
|
|
35
35
|
ALLOWED_KEYS = %w[
|
|
36
|
-
node status commit summary findings proposed_nodes proposed_edges question reason
|
|
36
|
+
node status commit summary findings report proposed_nodes proposed_edges question reason
|
|
37
37
|
].freeze
|
|
38
38
|
|
|
39
39
|
REQUIRED_FIELDS = {
|
|
@@ -51,7 +51,7 @@ module NodeReturn
|
|
|
51
51
|
|
|
52
52
|
Result = Struct.new(
|
|
53
53
|
:ok, :node, :status, :commit, :summary, :findings, :proposed_nodes, :proposed_edges,
|
|
54
|
-
:question, :reason, :errors,
|
|
54
|
+
:report, :question, :reason, :errors,
|
|
55
55
|
keyword_init: true
|
|
56
56
|
)
|
|
57
57
|
|
|
@@ -86,6 +86,10 @@ module NodeReturn
|
|
|
86
86
|
proposed_edges, edge_errors = normalize_proposed_edges(doc["proposed_edges"])
|
|
87
87
|
return failure(edge_errors) if edge_errors.any?
|
|
88
88
|
|
|
89
|
+
if !doc["report"].nil? && !doc["report"].is_a?(String)
|
|
90
|
+
return failure(["report: must be a Markdown string"])
|
|
91
|
+
end
|
|
92
|
+
|
|
89
93
|
Result.new(
|
|
90
94
|
ok: true,
|
|
91
95
|
node: doc["node"].to_s,
|
|
@@ -95,6 +99,7 @@ module NodeReturn
|
|
|
95
99
|
findings: normalize_findings(doc["findings"]),
|
|
96
100
|
proposed_nodes: proposed_nodes,
|
|
97
101
|
proposed_edges: proposed_edges,
|
|
102
|
+
report: doc["report"],
|
|
98
103
|
question: doc["question"],
|
|
99
104
|
reason: doc["reason"],
|
|
100
105
|
errors: [],
|
|
@@ -111,7 +116,7 @@ module NodeReturn
|
|
|
111
116
|
def failure(errors)
|
|
112
117
|
Result.new(
|
|
113
118
|
ok: false, node: nil, status: nil, commit: nil, summary: nil, findings: [],
|
|
114
|
-
proposed_nodes: [], proposed_edges: [], question: nil, reason: nil,
|
|
119
|
+
proposed_nodes: [], proposed_edges: [], report: nil, question: nil, reason: nil,
|
|
115
120
|
errors: Array(errors)
|
|
116
121
|
)
|
|
117
122
|
end
|
|
@@ -100,6 +100,7 @@ module RunnerAbsorb
|
|
|
100
100
|
node_decl = ((context.graph || {})[:nodes] || {})[node] || {}
|
|
101
101
|
kind = node_decl[:kind]
|
|
102
102
|
declared_files = normalize_files(node_decl[:files])
|
|
103
|
+
report_path = declared_report_path(intent_dir, node)
|
|
103
104
|
|
|
104
105
|
# v2 NEW-2: a `graph.md` that failed to parse, or a node whose kind
|
|
105
106
|
# cannot be resolved from it, refuses right here - before ANY check
|
|
@@ -143,6 +144,11 @@ module RunnerAbsorb
|
|
|
143
144
|
return fail_check(savepoint_path, context, node, "node_mismatch", checks_ran, holder, extra_fields, now, ledger)
|
|
144
145
|
end
|
|
145
146
|
|
|
147
|
+
report_reason = report_contract_violation(kind, report_path, parsed)
|
|
148
|
+
if report_reason
|
|
149
|
+
return fail_check(savepoint_path, context, node, report_reason, checks_ran, holder, extra_fields, now, ledger)
|
|
150
|
+
end
|
|
151
|
+
|
|
146
152
|
append_findings(intent_dir, node, parsed.findings, now: now)
|
|
147
153
|
|
|
148
154
|
proposal_result = nil
|
|
@@ -267,6 +273,14 @@ module RunnerAbsorb
|
|
|
267
273
|
end
|
|
268
274
|
|
|
269
275
|
commit_value = merge_commit || parsed.commit
|
|
276
|
+
if report_path
|
|
277
|
+
report_result = persist_report(intent_dir, report_path, parsed.report)
|
|
278
|
+
unless report_result[:ok]
|
|
279
|
+
return finish.call(fail_check(savepoint_path, context, node, report_result[:reason], gates.split("+") + ["report"],
|
|
280
|
+
holder, extra_fields, now, ledger))
|
|
281
|
+
end
|
|
282
|
+
gates = "#{gates}+report"
|
|
283
|
+
end
|
|
270
284
|
fields = { gates: gates, commit: commit_value, holder: holder, suite: suite_value }.merge(extra_fields)
|
|
271
285
|
result = write_transition(savepoint_path, context, node, "done", fields, now: now, ledger: ledger)
|
|
272
286
|
|
|
@@ -420,6 +434,48 @@ module RunnerAbsorb
|
|
|
420
434
|
end
|
|
421
435
|
private_class_method :normalize_scope_path
|
|
422
436
|
|
|
437
|
+
# --- research report -------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
def declared_report_path(intent_dir, node)
|
|
440
|
+
path = ReadySet.find_node_path(intent_dir, node)
|
|
441
|
+
return nil unless path
|
|
442
|
+
|
|
443
|
+
parsed = NodeFile.parse(path)
|
|
444
|
+
parsed[:ok] ? parsed[:report] : nil
|
|
445
|
+
end
|
|
446
|
+
private_class_method :declared_report_path
|
|
447
|
+
|
|
448
|
+
def report_contract_violation(kind, declared, parsed)
|
|
449
|
+
return "report_not_declared" if parsed.report && declared.nil?
|
|
450
|
+
return nil unless parsed.status == "done"
|
|
451
|
+
return "report_missing" if declared && parsed.report.to_s.strip.empty?
|
|
452
|
+
return "report_not_research" if parsed.report && kind.to_s != "research"
|
|
453
|
+
|
|
454
|
+
nil
|
|
455
|
+
end
|
|
456
|
+
private_class_method :report_contract_violation
|
|
457
|
+
|
|
458
|
+
def persist_report(intent_dir, relative_path, content)
|
|
459
|
+
resources_root = File.expand_path(File.join(intent_dir, "resources"))
|
|
460
|
+
target = File.expand_path(File.join(intent_dir, relative_path.to_s))
|
|
461
|
+
unless target.start_with?("#{resources_root}/") && target.end_with?(".md")
|
|
462
|
+
return { ok: false, reason: "report_path_invalid" }
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
if File.exist?(target)
|
|
466
|
+
return { ok: true, path: target } if File.binread(target) == content.to_s
|
|
467
|
+
|
|
468
|
+
return { ok: false, reason: "report_conflict" }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
472
|
+
AtomicWrite.write(target, content.to_s)
|
|
473
|
+
{ ok: true, path: target }
|
|
474
|
+
rescue StandardError
|
|
475
|
+
{ ok: false, reason: "report_write_failed" }
|
|
476
|
+
end
|
|
477
|
+
private_class_method :persist_report
|
|
478
|
+
|
|
423
479
|
# --- named tests -----------------------------------------------------------
|
|
424
480
|
|
|
425
481
|
# Row 4.19/minor 1: read the node's own failure-mode matrix table, take
|
|
@@ -37,7 +37,7 @@ module RunnerDispatch
|
|
|
37
37
|
RETURN_CONTRACT = <<~TEXT.freeze
|
|
38
38
|
RETURN CONTRACT: reply with exactly one YAML document as your final
|
|
39
39
|
message, nothing else around it. Keys: node, status, commit, summary,
|
|
40
|
-
findings, proposed_nodes, proposed_edges, question, reason. status is one
|
|
40
|
+
findings, report, proposed_nodes, proposed_edges, question, reason. status is one
|
|
41
41
|
of done, failed_verification, needs_decision, blocked. done requires
|
|
42
42
|
commit; needs_decision requires question; failed_verification and
|
|
43
43
|
blocked require reason. Anything that does not parse under this closed
|
|
@@ -47,7 +47,7 @@ module RunnerDispatch
|
|
|
47
47
|
HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
|
|
48
48
|
|
|
49
49
|
# D8 (355, n6): the agent every dispatched, non-decision node names - a
|
|
50
|
-
# role, never a harness (matrix 6.5), and never
|
|
50
|
+
# role, never a harness (matrix 6.5), and never a consultation advisor, which
|
|
51
51
|
# stays a deliberate, never-auto-dispatched consultation agent.
|
|
52
52
|
SPAWN_AGENT = "plastic-executor"
|
|
53
53
|
|
|
@@ -56,8 +56,8 @@ module RunnerDispatch
|
|
|
56
56
|
# (NodeInput.test_command_block, n4), and the call cap (n2) - fenced so a
|
|
57
57
|
# session pastes it straight into the Agent tool (327 D42: the runner
|
|
58
58
|
# itself never spawns).
|
|
59
|
-
def spawn_block(model:, input:, test_command:, call_cap:, agent: SPAWN_AGENT)
|
|
60
|
-
lines = ["agent: #{agent}", "model: #{model}", "input: #{input}", test_command,
|
|
59
|
+
def spawn_block(model:, input:, test_command:, call_cap:, effort: AgentModels::DEFAULT_EFFORT, agent: SPAWN_AGENT)
|
|
60
|
+
lines = ["agent: #{agent}", "model: #{model}", "effort: #{effort}", "input: #{input}", test_command,
|
|
61
61
|
NodeInput.call_cap_sentence(call_cap)]
|
|
62
62
|
(["```"] + lines + ["```"]).join("\n")
|
|
63
63
|
end
|
|
@@ -220,7 +220,8 @@ module RunnerDispatch
|
|
|
220
220
|
savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
|
|
221
221
|
|
|
222
222
|
holder = context.session
|
|
223
|
-
model = RunnerPolicy.model_for(kind, config: config)
|
|
223
|
+
model = RunnerPolicy.model_for(kind, config: config, harness: harness)
|
|
224
|
+
effort = RunnerPolicy.effort_for(kind, config: config, harness: harness)
|
|
224
225
|
expires = RunnerPolicy.lease_expires(kind, now: now)
|
|
225
226
|
calls_cap = RunnerPolicy.call_cap(kind, config: config)
|
|
226
227
|
|
|
@@ -267,7 +268,7 @@ module RunnerDispatch
|
|
|
267
268
|
# Row 1.19/1.20/D21: harness= rides alongside model= on every `running`
|
|
268
269
|
# line, resolved once by the caller through HarnessAdapter and threaded
|
|
269
270
|
# straight through here - never re-resolved, never a literal.
|
|
270
|
-
fields = { holder: holder, expires: expires, input: build_result[:sha], model: model, harness: harness,
|
|
271
|
+
fields = { holder: holder, expires: expires, input: build_result[:sha], model: model, effort: effort, harness: harness,
|
|
271
272
|
calls: calls_cap }
|
|
272
273
|
|
|
273
274
|
result = begin
|
|
@@ -287,11 +288,13 @@ module RunnerDispatch
|
|
|
287
288
|
end
|
|
288
289
|
|
|
289
290
|
test_command = NodeInput.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
|
|
290
|
-
spawn = spawn_block(model: model, input: build_result[:path], test_command: test_command,
|
|
291
|
+
spawn = spawn_block(model: model, effort: effort, input: build_result[:path], test_command: test_command,
|
|
292
|
+
call_cap: calls_cap)
|
|
291
293
|
|
|
292
294
|
{
|
|
293
295
|
ok: true,
|
|
294
|
-
entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model,
|
|
296
|
+
entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, effort: effort,
|
|
297
|
+
worktree: provisioned[:path],
|
|
295
298
|
input: build_result[:path], spawn: spawn },
|
|
296
299
|
}
|
|
297
300
|
end
|
|
@@ -507,6 +510,7 @@ module RunnerDispatch
|
|
|
507
510
|
"return_contract" => RETURN_CONTRACT,
|
|
508
511
|
"dispatch" => dispatched.map do |d|
|
|
509
512
|
{ "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
|
|
513
|
+
"effort" => d[:effort],
|
|
510
514
|
"worktree" => d[:worktree], "input" => d[:input] }
|
|
511
515
|
end,
|
|
512
516
|
"spawn" => dispatched.map { |d| d[:spawn] }
|
|
@@ -35,7 +35,7 @@ module RunnerPolicy
|
|
|
35
35
|
# resolves through AgentModels::TIER_DEFAULTS, the one place
|
|
36
36
|
# `plastic-executor`'s shipped tier is already declared (post-execution
|
|
37
37
|
# review minor 4), rather than a second, independently-drifting literal
|
|
38
|
-
# here. `plastic-advisor` carries no lifecycle-stage entry in
|
|
38
|
+
# here. `plastic-primary-advisor` carries no lifecycle-stage entry in
|
|
39
39
|
# TIER_DEFAULTS at all (it is a consultation agent, never auto-dispatched
|
|
40
40
|
# - see agent_models.rb's own docstring), so its default stays the one
|
|
41
41
|
# literal this table cannot source from anywhere else.
|
|
@@ -43,7 +43,7 @@ module RunnerPolicy
|
|
|
43
43
|
DEFAULT_ADVISOR_MODEL = "opus"
|
|
44
44
|
|
|
45
45
|
EXECUTOR_CONFIG_KEY = "plastic-executor"
|
|
46
|
-
ADVISOR_CONFIG_KEY = "plastic-advisor"
|
|
46
|
+
ADVISOR_CONFIG_KEY = "plastic-primary-advisor"
|
|
47
47
|
|
|
48
48
|
# {model_role:, worktree:, retry_cap:, diff_rule:, lease_minutes:} per kind
|
|
49
49
|
# (327 D12). `decision` carries no retry cap or lease: it is never
|
|
@@ -69,24 +69,42 @@ module RunnerPolicy
|
|
|
69
69
|
# verify resolves the advisor model, and a config with no override falls
|
|
70
70
|
# back to the shipped default rather than an empty string (`running`
|
|
71
71
|
# requires a non-blank `model=`).
|
|
72
|
-
def model_for(kind, config: {})
|
|
73
|
-
policy_for(kind)[:model_role] == :advisor ? advisor_model(config: config
|
|
72
|
+
def model_for(kind, config: {}, harness: "claude-code")
|
|
73
|
+
alias_or_id = policy_for(kind)[:model_role] == :advisor ? advisor_model(config: config, harness: harness) :
|
|
74
|
+
executor_model(config: config, harness: harness)
|
|
75
|
+
codex_harness?(harness) ? (AgentModels.codex_model_for(alias_or_id) || alias_or_id) : alias_or_id
|
|
74
76
|
end
|
|
75
77
|
|
|
76
|
-
def executor_model(config: {})
|
|
77
|
-
resolve_model(config, EXECUTOR_CONFIG_KEY, DEFAULT_EXECUTOR_MODEL)
|
|
78
|
+
def executor_model(config: {}, harness: "claude-code")
|
|
79
|
+
resolve_model(config, EXECUTOR_CONFIG_KEY, DEFAULT_EXECUTOR_MODEL, harness)
|
|
78
80
|
end
|
|
79
81
|
|
|
80
|
-
def advisor_model(config: {})
|
|
81
|
-
resolve_model(config, ADVISOR_CONFIG_KEY, DEFAULT_ADVISOR_MODEL)
|
|
82
|
+
def advisor_model(config: {}, harness: "claude-code")
|
|
83
|
+
resolve_model(config, ADVISOR_CONFIG_KEY, DEFAULT_ADVISOR_MODEL, harness)
|
|
82
84
|
end
|
|
83
85
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
+
def effort_for(kind, config: {}, harness: "claude-code")
|
|
87
|
+
key = policy_for(kind)[:model_role] == :advisor ? ADVISOR_CONFIG_KEY : EXECUTOR_CONFIG_KEY
|
|
88
|
+
value = AgentModels.efforts_section(config, harness_name(harness))[key]
|
|
89
|
+
present?(value) ? value : AgentModels::DEFAULT_EFFORT
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resolve_model(config, key, shipped_default, harness)
|
|
93
|
+
value = AgentModels.models_section(config, harness: harness_name(harness))[key]
|
|
86
94
|
present?(value) ? value : shipped_default
|
|
87
95
|
end
|
|
88
96
|
private_class_method :resolve_model
|
|
89
97
|
|
|
98
|
+
def harness_name(harness)
|
|
99
|
+
codex_harness?(harness) ? "codex" : "claude"
|
|
100
|
+
end
|
|
101
|
+
private_class_method :harness_name
|
|
102
|
+
|
|
103
|
+
def codex_harness?(harness)
|
|
104
|
+
harness.to_s == "codex"
|
|
105
|
+
end
|
|
106
|
+
private_class_method :codex_harness?
|
|
107
|
+
|
|
90
108
|
def present?(value)
|
|
91
109
|
!(value.nil? || value.to_s.strip.empty?)
|
|
92
110
|
end
|
package/scripts/node-run
CHANGED
|
@@ -201,7 +201,8 @@ module NodeRunCLI
|
|
|
201
201
|
message_path = "#{out_path}.msg"
|
|
202
202
|
|
|
203
203
|
timeout_seconds = timeout_flag ? timeout_flag.to_i : CodexAdapter.timeout_seconds(kind)
|
|
204
|
-
codex_argv = CodexAdapter.build_argv(kind: kind, worktree: worktree, output_last_message: message_path
|
|
204
|
+
codex_argv = CodexAdapter.build_argv(kind: kind, worktree: worktree, output_last_message: message_path,
|
|
205
|
+
model: fields["model"], effort: fields["effort"])
|
|
205
206
|
|
|
206
207
|
result = CodexAdapter.execute(codex_argv, stdin_data: input_bytes, timeout_seconds: timeout_seconds,
|
|
207
208
|
output_last_message_path: message_path)
|