@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.
@@ -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(File.join(config[:home_dir], "agents"), models: agent_model_overrides(harness: "codex"))
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
- # Codex advisor support is out of scope for this release (intent 185): the
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 = (override && !override.to_s.empty? ? override : front["model"]).to_s
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 emitted verbatim as `model` only. Empty -> no line
1220
- # (the agent inherits the session default). If an alias somehow lacks a mapped model, the effort
1221
- # line still emits alone (backward-safe).
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
- effort = AgentModels.effort_for(effective)
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 (both plastic-advisor and
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 (plastic-advisor, plastic-faux-advisor) resolve through
1417
+ # Both advisor agents resolve through
1412
1418
  # this SAME generic map, like any other agent: a config author sets
1413
- # agents.models.claude.plastic-advisor (or the legacy flat
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
- global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
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
- load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml"))
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
- global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
1431
- project_config = project_dir ? load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml")) : {}
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
- # Agent-name shorthands for the --advisor flag: the two shipped choices,
1447
- # named for the role (real advisor vs. the cheaper imitation), never a model
1448
- # name.
1449
- ADVISOR_SHORTHANDS = { "real" => "plastic-advisor", "faux" => "plastic-faux-advisor" }.freeze
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 "real"/"faux")
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 = load_config_yaml(config_path)
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
@@ -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, plus the deterministic id minter
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 `plastic-advisor`, which
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, call_cap: calls_cap)
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, worktree: provisioned[:path],
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) : executor_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 resolve_model(config, key, shipped_default)
85
- value = AgentModels.models_section(config)[key]
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)