@zalom/plastic 2.0.0-alpha.8 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +6 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +23 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +62 -1
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +238 -22
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +19 -5
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +48 -83
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +71 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +46 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +30 -16
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +229 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +532 -71
  152. package/scripts/lib/intent_screen.rb +9 -6
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +377 -173
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +913 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1460 -74
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +158 -8
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +525 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +555 -0
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +25 -18
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +192 -30
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +15 -2
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -295
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -173
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require "time"
6
+ require_relative "lib/savepoint"
7
+ require_relative "lib/node_ledger"
8
+ require_relative "lib/ready_set"
9
+ require_relative "lib/guarded_append"
10
+ require_relative "lib/lock"
11
+ require_relative "lib/arm"
12
+
13
+ # node-transition - the CLI over NodeLedger that REFUSES what the graph does not
14
+ # allow (intent 335, G2). An orchestrator can run this by hand today, before the
15
+ # runner (intent 340) exists.
16
+ #
17
+ # Usage:
18
+ # node-transition <intent_dir> --node <id|Intent> --state <state> [--field k=v]...
19
+ # [--comment "..."] [--needs a,b] [--session SID] [--now ISO8601]
20
+ # node-transition report <intent_dir>
21
+ #
22
+ # Exit codes (spec D18, matching the tree: scripts/append-ledger:125 uses 3 for a
23
+ # guard that could not be taken, scripts/end-intent:675 uses 4 for a lock-ownership
24
+ # refusal):
25
+ # 0 - the line was appended
26
+ # 2 - usage: not an intent directory, bad subject, unknown state, missing
27
+ # required field, a tab or newline in a value
28
+ # 3 - the ledger guard was unavailable; nothing was written
29
+ # 4 - running, and the caller does not hold delivery.lock
30
+ # 5 - running, and the subject is not ready
31
+ # 6 - reclaimed, and the subject's last running line has not expired
32
+ module NodeTransition
33
+ module_function
34
+
35
+ # The readiness precondition for `running` (intent 336, G3): ReadySet.ready?
36
+ # applied to `subject` against `edges`/`nodes` already parsed once, before
37
+ # the guard is ever taken (spec: "parsed once, before the guard"). Takes
38
+ # `content` (the ledger's raw text), never a path, so this method can be run
39
+ # twice: once as a cheap unguarded pre-check for a fast, readable refusal,
40
+ # and once - the AUTHORITATIVE decision - as the guard's precondition,
41
+ # evaluated against the exact content GuardedAppend just read under its
42
+ # lock hold (post-execution review row 7.1, carried forward by 336). A
43
+ # path-based re-read here would defeat the guard: two callers could each
44
+ # open their own handle, both see "planned", and both go on to append
45
+ # `running`.
46
+ def running_ready?(content, subject, edges, nodes, caps: ReadySet::DEFAULT_CAPS)
47
+ ReadySet.ready?(content: content, subject: subject, graph: { edges: edges }, nodes: nodes, caps: caps)
48
+ end
49
+
50
+ # graph.md and nodes/ for `subject`'s readiness (intent 336, G3): a genuinely
51
+ # missing graph.md degrades gracefully to no known needs/kind/files (the
52
+ # same fallback the temporary needs_from_graph reader gave, so a legacy
53
+ # intent with no graph.md still dispatches ad hoc node ids), while a
54
+ # PRESENT but broken graph.md (no ## Graph section, a cycle) is a clean
55
+ # refusal, never a raise - {edges:, nodes:, error:}.
56
+ def resolve_graph_and_nodes(intent_dir)
57
+ graph_path = File.join(intent_dir, "graph.md")
58
+ return { edges: {}, nodes: {}, error: nil } unless File.exist?(graph_path)
59
+
60
+ loaded = ReadySet.load_graph(intent_dir)
61
+ return { edges: loaded[:edges] || {}, nodes: loaded[:nodes] || {}, error: nil } if loaded[:ok]
62
+
63
+ { edges: {}, nodes: {}, error: loaded[:errors].join("; ") }
64
+ end
65
+
66
+ # Session resolution per spec D16: an explicit --session is authoritative and
67
+ # NEVER falls through (an explicit non-owner is refused outright, never
68
+ # silently granted ownership through a fallback). Without it, try the
69
+ # environment's CLAUDE_CODE_SESSION_ID, then the derived `auto-` key, and keep
70
+ # the first candidate that actually HOLDS the lock (owner or delegate,
71
+ # Lock.holds?) - so the fallback can never grant ownership to a session that
72
+ # does not have it.
73
+ def resolve_owning_session(intent_dir, explicit:, env_session:)
74
+ if explicit && !explicit.to_s.strip.empty?
75
+ return Lock.holds?(intent_dir, session: explicit) ? explicit : nil
76
+ end
77
+
78
+ store = Arm.store_for(intent_dir)
79
+ intent_id = Arm.intent_id_for(intent_dir)
80
+ candidates = [env_session, Arm.derive_key(store, intent_id)].reject { |c| c.to_s.strip.empty? }
81
+ candidates.find { |c| Lock.holds?(intent_dir, session: c) }
82
+ end
83
+
84
+ def intent_directory?(dir)
85
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
86
+ end
87
+
88
+ def valid_subject?(subject)
89
+ subject == Savepoint::INTENT_SUBJECT || subject.to_s.match?(Savepoint::NODE_SUBJECT_RE)
90
+ end
91
+
92
+ def opt(args, name)
93
+ (i = args.index(name)) && args[i + 1]
94
+ end
95
+
96
+ def opt_all(args, name)
97
+ args.each_index.select { |i| args[i] == name }.map { |i| args[i + 1] }
98
+ end
99
+
100
+ def parse_fields(args)
101
+ opt_all(args, "--field").each_with_object({}) do |pair, memo|
102
+ key, value = pair.to_s.split("=", 2)
103
+ memo[key] = value if key && !key.empty?
104
+ end
105
+ end
106
+
107
+ def usage
108
+ warn "Usage: node-transition <intent_dir> --node <id|Intent> --state <state> " \
109
+ "[--field k=v]... [--comment \"...\"] [--needs a,b] [--session SID] [--now ISO8601]"
110
+ warn " node-transition report <intent_dir>"
111
+ end
112
+
113
+ def run_report(intent_dir)
114
+ unless intent_directory?(intent_dir)
115
+ warn "node-transition: #{intent_dir.inspect} is not an intent directory"
116
+ usage
117
+ exit 2
118
+ end
119
+
120
+ savepoint_path = File.join(intent_dir, "savepoint.md")
121
+ anomalies = NodeLedger.anomalies(savepoint_path)
122
+ if anomalies.empty?
123
+ puts "node-transition report: no torn or unattributed transition lines"
124
+ else
125
+ anomalies.each { |a| puts "#{a[:reason]}: #{a[:line]}" }
126
+ end
127
+ exit 0
128
+ end
129
+
130
+ def main(argv)
131
+ args = argv.dup
132
+ head = args.shift
133
+
134
+ if head == "report"
135
+ report_dir_arg = args.shift
136
+ run_report(report_dir_arg && File.expand_path(report_dir_arg))
137
+ return
138
+ end
139
+
140
+ intent_dir_arg = head
141
+ unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
142
+ warn "node-transition: #{intent_dir_arg.inspect} is not an intent directory"
143
+ usage
144
+ exit 2
145
+ end
146
+ intent_dir = File.expand_path(intent_dir_arg)
147
+
148
+ subject = opt(args, "--node")
149
+ state = opt(args, "--state")
150
+ comment = opt(args, "--comment")
151
+ needs_flag = opt(args, "--needs")
152
+ session_flag = opt(args, "--session")
153
+ now_flag = opt(args, "--now")
154
+ fields = parse_fields(args)
155
+
156
+ if subject.nil? || state.nil?
157
+ warn "node-transition: --node and --state are required"
158
+ usage
159
+ exit 2
160
+ end
161
+
162
+ unless valid_subject?(subject)
163
+ warn "node-transition: #{subject.inspect} is neither #{Savepoint::INTENT_SUBJECT} nor a valid node id"
164
+ exit 2
165
+ end
166
+
167
+ unless NodeLedger::STATES.include?(state)
168
+ warn "node-transition: unknown state #{state.inspect}"
169
+ exit 2
170
+ end
171
+
172
+ now = begin
173
+ now_flag ? Time.iso8601(now_flag) : Time.now
174
+ rescue ArgumentError
175
+ warn "node-transition: --now must be ISO 8601"
176
+ exit 2
177
+ end
178
+
179
+ begin
180
+ fields.each_value { |v| NodeLedger.normalize_value(v) }
181
+ NodeLedger.normalize_value(comment) if comment
182
+ rescue ArgumentError => e
183
+ warn "node-transition: #{e.message}"
184
+ exit 2
185
+ end
186
+
187
+ missing = NodeLedger.missing_fields(state, fields)
188
+ unless missing.empty?
189
+ warn "node-transition: state #{state} requires #{missing.join(', ')}"
190
+ exit 2
191
+ end
192
+
193
+ savepoint_path = File.join(intent_dir, "savepoint.md")
194
+ precondition = nil
195
+
196
+ case state
197
+ when "running"
198
+ owning_session = resolve_owning_session(intent_dir, explicit: session_flag,
199
+ env_session: ENV["CLAUDE_CODE_SESSION_ID"])
200
+ unless owning_session
201
+ warn "node-transition: the caller does not hold delivery.lock for #{intent_dir}"
202
+ exit 4
203
+ end
204
+
205
+ if subject == Savepoint::INTENT_SUBJECT
206
+ # The node readiness rule (four conditions over the work graph) is
207
+ # scoped to node subjects; the intent-scope subject is never gated by
208
+ # it, so a legacy intent with no graph.md at all can still write an
209
+ # Intent line (spec Acceptance Criteria; matrix "Transition the
210
+ # Intent subject").
211
+ precondition = nil
212
+ else
213
+ resolved = resolve_graph_and_nodes(intent_dir)
214
+ if resolved[:error]
215
+ warn "node-transition: #{resolved[:error]}"
216
+ exit 5
217
+ end
218
+
219
+ needs_override = needs_flag ? needs_flag.split(",").map(&:strip).reject(&:empty?) : nil
220
+ edges = needs_override ? resolved[:edges].merge(subject => needs_override) : resolved[:edges]
221
+ nodes = resolved[:nodes]
222
+
223
+ # Fast, unguarded pre-check: a cheap, readable refusal for the common
224
+ # case, but NEVER the authoritative decision (post-execution review
225
+ # row 7.1, carried forward by 336). The authoritative check is the
226
+ # `precondition` below, run inside GuardedAppend's lock hold against
227
+ # the content it actually read. `edges` and `nodes` are parsed once,
228
+ # above, before the guard is ever taken (spec D1).
229
+ precheck_content = File.exist?(savepoint_path) ? File.read(savepoint_path) : ""
230
+ result = running_ready?(precheck_content, subject, edges, nodes)
231
+ unless result[:ready]
232
+ warn "node-transition: #{result[:blockers].join('; ')}"
233
+ exit 5
234
+ end
235
+
236
+ precondition = ->(content) { running_ready?(content, subject, edges, nodes)[:ready] }
237
+ end
238
+ when "reclaimed"
239
+ running_entry = NodeLedger.last_running(savepoint_path, subject)
240
+ unless running_entry
241
+ warn "node-transition: #{subject} has no running line to reclaim"
242
+ exit 6
243
+ end
244
+
245
+ # Post-execution review row 7.4: expiry alone is not enough. A `done`
246
+ # node whose last `running` line's expires= happens to be in the past
247
+ # must not revert to `planned` and become re-dispatchable - reclaim is
248
+ # deliberately lock-free (spec D43), so this status check is the only
249
+ # thing that stops it. Row 7.5 pins that a genuinely running, expired
250
+ # subject is still reclaimable, so the crash sweep keeps working.
251
+ current_status = NodeLedger.status_for(savepoint_path, subject)
252
+ unless current_status == "running"
253
+ warn "node-transition: #{subject} is #{current_status}, not running; nothing to reclaim"
254
+ exit 6
255
+ end
256
+
257
+ expires_raw = (running_entry[:fields] || {})["expires"]
258
+ expiry_time = begin
259
+ expires_raw && Time.iso8601(expires_raw)
260
+ rescue ArgumentError
261
+ nil
262
+ end
263
+
264
+ unless expiry_time && now > expiry_time
265
+ warn "node-transition: #{subject}'s running line has not expired yet"
266
+ exit 6
267
+ end
268
+ end
269
+
270
+ result = begin
271
+ NodeLedger.append_transition(savepoint_path, subject: subject, state: state, fields: fields,
272
+ comment: comment, now: now, precondition: precondition)
273
+ rescue GuardedAppend::Unavailable => e
274
+ warn "node-transition: #{e.message}"
275
+ exit 3
276
+ rescue ArgumentError => e
277
+ warn "node-transition: #{e.message}"
278
+ exit 2
279
+ end
280
+
281
+ if result == :refused
282
+ warn "node-transition: #{subject} was no longer ready by the time the write lock was taken"
283
+ exit 5
284
+ end
285
+
286
+ puts "node-transition: appended #{state} for #{subject}"
287
+ exit 0
288
+ end
289
+ end
290
+
291
+ NodeTransition.main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # outcome-report - the CLI over scripts/lib/outcome_report.rb (intent 339, G6).
6
+ # Prints the generated outcome.md by default; --write puts it on disk through
7
+ # AtomicWrite. `end-intent` calls the library directly (never shells out to
8
+ # this), so this command is for a human or an agent checking the generated
9
+ # report before or instead of a close.
10
+ #
11
+ # Usage:
12
+ # outcome-report <intent_dir> [--write] [--disposition delivered|abandoned]
13
+ #
14
+ # Exit codes:
15
+ # 0 - printed (or written) successfully
16
+ # 2 - usage error: no path given, an unknown flag, or the path is not an
17
+ # intent directory
18
+ # 3 - the report model could not be built (a malformed or absent graph.md)
19
+
20
+ require_relative "lib/outcome_report"
21
+ require_relative "lib/intent_screen"
22
+ require_relative "lib/outcome_guard"
23
+
24
+ def usage_abort(message)
25
+ warn "outcome-report: #{message}"
26
+ exit 2
27
+ end
28
+
29
+ args = ARGV.dup
30
+ write_flag = false
31
+ disposition = "delivered"
32
+ positional = []
33
+
34
+ while (arg = args.shift)
35
+ case arg
36
+ when "--write"
37
+ write_flag = true
38
+ when "--disposition"
39
+ disposition = args.shift or usage_abort("--disposition needs a value")
40
+ else
41
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
42
+ positional << arg
43
+ end
44
+ end
45
+
46
+ target = positional.first
47
+ usage_abort("usage: outcome-report <intent_dir> [--write] [--disposition delivered|abandoned]") unless target
48
+ # Row v1f.9 (N3): refuse an unknown --disposition here rather than write a
49
+ # record OutcomeGuard then refuses. OutcomeGuard::DISPOSITIONS is already
50
+ # loaded in-process, so this is one comparison, not a duplicated list.
51
+ unless OutcomeGuard::DISPOSITIONS.include?(disposition)
52
+ usage_abort("--disposition must be one of #{OutcomeGuard::DISPOSITIONS.join('|')}, got #{disposition.inspect}")
53
+ end
54
+
55
+ intent_dir = File.expand_path(target)
56
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
57
+
58
+ model = OutcomeReport.model(intent_dir)
59
+ unless model[:ok]
60
+ warn "outcome-report: the report model could not be built: #{model[:errors].join('; ')}"
61
+ exit 3
62
+ end
63
+
64
+ if write_flag
65
+ OutcomeReport.write(intent_dir, disposition: disposition)
66
+ puts File.join(intent_dir, "outcome.md")
67
+ else
68
+ outcome_path = File.join(intent_dir, "outcome.md")
69
+ existing = File.exist?(outcome_path) ? File.read(outcome_path) : nil
70
+ puts OutcomeReport.render(model, disposition: disposition, existing: existing,
71
+ findings: OutcomeReport.findings(intent_dir), intent_dir: intent_dir)
72
+ end
73
+
74
+ exit 0
@@ -19,12 +19,11 @@
19
19
  # corrupt lock with the resolving doctor hint
20
20
  # who compact, read-only durable owner/activity view
21
21
  # status report the lock file, its freshness, the derived worktree,
22
- # whether this session's pointer names the intent, and any
23
- # live per-artifact claims
22
+ # whether this session is the one delivering the intent, and
23
+ # any live per-artifact claims
24
24
  # fix idempotent repair of the lock for the current session; never
25
25
  # touches a fresh foreign lock
26
- # release owner clears the lock and points the session back at the day
27
- # ledger (End tail / abandoning a boarding)
26
+ # release owner clears the lock (End tail / abandoning a boarding)
28
27
  # reclaim explicit takeover of a stale lock, audited in savepoint.md
29
28
  # delegate owner registers a subagent session under the lock (D4)
30
29
  # claim acquire the named --artifact's claim token (intent 111);
@@ -32,18 +31,19 @@
32
31
  # including this same session; takes over a stale claim
33
32
  # release-claim free the named --artifact's claim token
34
33
  #
35
- # Without --intent-dir the intent is resolved from this session's pointer
36
- # (~/.plastic/store/.tmp/<session>/current) when it names an intent id, looked
37
- # up in the working directory's project store and then the global store. The
38
- # /tmp bridge JSON this CLI used to resolve through was removed in 2.0
39
- # (intent 307). Exit 0 on success/report; exit 1 when the verb is blocked.
34
+ # Without --intent-dir the intent is resolved from this session's held
35
+ # delivery lock (ActiveDelivery.resolve over the global store and the
36
+ # configured project roots): exactly one fresh lock this session owns or
37
+ # delegates for. Session resolution no longer reads any per-session state
38
+ # file; that path was retired in 2.0 (intent 307) and 344 G11.
39
+ # Exit 0 on success/report; exit 1 when the verb is blocked.
40
40
 
41
+ require_relative "lib/store_layout"
41
42
  require "json"
42
43
  require "yaml"
43
- require_relative "lib/bridge"
44
44
  require_relative "lib/lock"
45
45
  require_relative "lib/arm"
46
- require_relative "lib/session_ledger"
46
+ require_relative "lib/active_delivery"
47
47
 
48
48
  VERBS = %w[arm status who fix release reclaim delegate claim release-claim].freeze
49
49
 
@@ -70,6 +70,7 @@ until ARGV.empty?
70
70
  when "--model" then opts[:model] = ARGV.shift
71
71
  when "--thread" then opts[:thread] = ARGV.shift
72
72
  when "--mode" then opts[:mode] = ARGV.shift
73
+ when "--allow-inline" then opts[:allow_inline] = true
73
74
  when "--status" then opts[:status] = ARGV.shift
74
75
  else
75
76
  warn "unknown flag #{flag}"
@@ -87,16 +88,7 @@ hint_harness = harness || "claude"
87
88
  plastic_home_env = ENV["PLASTIC_HOME"].to_s.strip
88
89
  home = plastic_home_env.empty? ? Dir.home : File.dirname(File.expand_path(plastic_home_env))
89
90
 
90
- # The stores this session's pointer is resolved against: the project store of
91
- # the working directory (projects.yml, longest path match), then the global one.
92
- def candidate_stores(home, cwd)
93
- plastic_home = File.join(home, ".plastic")
94
- stores = []
95
- slug = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
96
- stores << File.join(plastic_home, "projects", slug, "store") if slug && slug != "global"
97
- stores << File.join(plastic_home, "store")
98
- stores
99
- end
91
+ plastic_home = File.join(home, ".plastic")
100
92
 
101
93
  dir = opts[:dir]
102
94
  if dir.nil? || dir.strip.empty?
@@ -104,11 +96,21 @@ if dir.nil? || dir.strip.empty?
104
96
  warn "plastic-lock: #{verb} needs --intent-dir <intent dir>"
105
97
  exit 1
106
98
  end
107
- dir = Arm.intent_dir_from_pointer(session, home: home, stores: candidate_stores(home, Dir.pwd))
108
- end
109
- if dir.nil?
110
- warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
111
- exit 1
99
+ roots = ActiveDelivery.project_roots(plastic_home)
100
+ global_store = Plastic::StoreLayout.global_store(plastic_home)
101
+ dir = ActiveDelivery.resolve(global_store: global_store, project_roots: roots, session: session)
102
+ if dir.nil?
103
+ candidates = ActiveDelivery.candidate_intent_dirs(global_store: global_store, project_roots: roots).uniq
104
+ held = candidates.select do |candidate|
105
+ Lock.fresh?(candidate) && Lock.holds?(candidate, session: session)
106
+ end
107
+ if held.size > 1
108
+ warn "plastic-lock: more than one held delivery lock names this session; pass --intent-dir <intent dir>"
109
+ else
110
+ warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
111
+ end
112
+ exit 1
113
+ end
112
114
  end
113
115
  dir = File.expand_path(dir)
114
116
 
@@ -143,7 +145,7 @@ if verb == "who"
143
145
  exit 0
144
146
  end
145
147
 
146
- intent_id = Bridge.intent_id_from_dir(dir)
148
+ intent_id = Arm.intent_id_for(dir)
147
149
  store = File.dirname(dir)
148
150
  key = Arm.resolve_session(session, store: store, intent_id: intent_id)
149
151
 
@@ -154,14 +156,15 @@ end
154
156
  case verb
155
157
  when "arm"
156
158
  result = Arm.arm(intent_dir: dir, session: key, mode: opts[:mode] || "auto", home: home,
157
- harness: harness, agent: opts[:agent], model: opts[:model], thread: opts[:thread])
159
+ harness: harness, agent: opts[:agent], model: opts[:model], thread: opts[:thread],
160
+ allow_inline: opts[:allow_inline] || false)
158
161
  case result[:status]
159
162
  when :acquired, :owned
160
163
  wt = result[:worktree] || {}
161
164
  puts JSON.pretty_generate(
162
165
  "intent_dir" => dir, "session" => result[:session], "status" => result[:status].to_s,
163
166
  "run_mode" => result[:lock] && result[:lock]["run_mode"],
164
- "worktree" => wt, "pointer" => result[:pointer]
167
+ "worktree" => wt
165
168
  )
166
169
  when :held
167
170
  warn "plastic-lock: delivery lock for intent #{intent_id} is held by session " \
@@ -178,10 +181,18 @@ when "arm"
178
181
  when :corrupt
179
182
  warn "plastic-lock: delivery.lock for intent #{intent_id} is unreadable; #{doctor_hint('fix the lock', hint_harness)}"
180
183
  exit 1
184
+ when :inline_refused
185
+ warn "plastic-lock: refusing to arm intent #{intent_id} from a conversation session " \
186
+ "(owner rule 2026-08-31: no inline intent delivery). Dispatch the delivery team " \
187
+ "instead (plastic-auto), or pass --allow-inline with explicit owner approval."
188
+ exit 3
181
189
  end
182
190
  when "status"
183
191
  lock = Lock.read(dir)
184
- pointer = Arm.read_pointer(key, home: home)
192
+ resolved = ActiveDelivery.resolve(global_store: Plastic::StoreLayout.global_store(plastic_home),
193
+ project_roots: ActiveDelivery.project_roots(plastic_home),
194
+ session: key)
195
+ delivering = !resolved.nil? && File.expand_path(resolved) == File.expand_path(dir)
185
196
  report = {
186
197
  "intent_dir" => dir,
187
198
  "session" => key,
@@ -189,8 +200,7 @@ when "status"
189
200
  "lock_fresh" => Lock.fresh?(dir),
190
201
  "lock_corrupt" => Lock.corrupt?(dir),
191
202
  "worktree" => Arm.worktree_block(intent_dir: dir, home: home),
192
- "pointer" => pointer,
193
- "pointer_names_intent" => pointer == intent_id,
203
+ "delivering" => delivering,
194
204
  "claims" => Claim.claims_status(dir),
195
205
  }
196
206
  puts JSON.pretty_generate(report)
@@ -210,7 +220,6 @@ when "release"
210
220
  warn "plastic-lock: not the owner; run plastic-lock status"
211
221
  exit 1
212
222
  end
213
- Arm.reset_pointer(key, intent_id, home: home)
214
223
  puts "released (#{result})"
215
224
  when "reclaim"
216
225
  status, lock_data = Lock.takeover(dir, session: key, harness: harness,
@@ -21,6 +21,7 @@
21
21
  # Exit codes: 0 promoted (prints the intent dir last), 1 new-intent failed,
22
22
  # 2 usage error or nothing to promote.
23
23
 
24
+ require_relative "lib/store_layout"
24
25
  require "open3"
25
26
  require "yaml"
26
27
  require_relative "lib/session_ledger"
@@ -61,7 +62,7 @@ usage_abort("--day must be a real calendar date, YYYYMMDD") unless SessionLedger
61
62
  usage_abort("--match is required") if opts[:match].nil? || opts[:match].empty?
62
63
 
63
64
  plastic_home = File.expand_path(opts[:plastic_home] || ENV.fetch("PLASTIC_HOME", "~/.plastic"))
64
- store = opts[:store] || File.join(plastic_home, "store")
65
+ store = opts[:store] || Plastic::StoreLayout.global_store(plastic_home)
65
66
  templates = opts[:templates] || File.expand_path("../templates", __dir__)
66
67
  session = opts[:session] ? SessionLedger.short_session_id(opts[:session]) : nil
67
68
 
@@ -69,7 +70,7 @@ target_store = store
69
70
  if opts[:project]
70
71
  projects = StoreProvisioning.load_projects(plastic_home)
71
72
  usage_abort("--project #{opts[:project]} is not a registered project") unless projects.key?(opts[:project])
72
- target_store = File.join(plastic_home, "projects", opts[:project], "store")
73
+ target_store = File.join(Plastic::StoreLayout.project_root(plastic_home, opts[:project]), "store")
73
74
  end
74
75
 
75
76
  # --- pick the target line ---------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
- # Usage: read-config <key> [--default VALUE] [--project PATH]
2
+ # Usage: read-config <key> [--default VALUE] [--project PATH] [--harness claude|codex]
3
3
  # Resolves a config value: project -> global -> built-in defaults.
4
4
  # Nested keys use dot notation: agent.type, architect.style
5
5
  # Returns scalar values as strings, hash/array values as JSON.
@@ -14,9 +14,9 @@ require_relative "lib/agent_models"
14
14
  DEFAULTS = {
15
15
  "version" => 3,
16
16
  "stale_threshold_days" => 3,
17
- # Absolute token counts for a 1M window, 35 and 50 percent (intent 312, 296 D38).
18
- "context_offer_tokens" => 350_000,
19
- "context_insist_tokens" => 500_000,
17
+ # Absolute token counts for a 1M window, 15 and 25 percent (intent 355, n5, D7).
18
+ "context_offer_tokens" => 150_000,
19
+ "context_insist_tokens" => 250_000,
20
20
  "execution_mode" => "subagent-driven",
21
21
  "hash_length" => 6,
22
22
  "hash_algorithm" => "sha256-base36",
@@ -33,6 +33,12 @@ DEFAULTS = {
33
33
  },
34
34
  "architect" => {
35
35
  "style" => nil
36
+ },
37
+ # Intent 340b (G7c, n4, D9): the Stop hook's runtime arm. Off until the
38
+ # owner's C32 ruling; read-config emits it as the string "false", which
39
+ # StopGate parses as a boolean itself rather than trusting.
40
+ "runner" => {
41
+ "stop_hook" => false
36
42
  }
37
43
  }.freeze
38
44
 
@@ -40,6 +46,7 @@ def parse_args(argv)
40
46
  key = nil
41
47
  default_val = nil
42
48
  project_dir = nil
49
+ harness = nil
43
50
 
44
51
  i = 0
45
52
  while i < argv.length
@@ -52,13 +59,17 @@ def parse_args(argv)
52
59
  abort "Error: --project requires a value" unless argv[i + 1]
53
60
  project_dir = argv[i + 1]
54
61
  i += 2
62
+ when "--harness"
63
+ abort "Error: --harness requires a value" unless argv[i + 1]
64
+ harness = argv[i + 1]
65
+ i += 2
55
66
  else
56
67
  key = argv[i]
57
68
  i += 1
58
69
  end
59
70
  end
60
71
 
61
- [key, default_val, project_dir]
72
+ [key, default_val, project_dir, harness]
62
73
  end
63
74
 
64
75
  def load_yaml(path)
@@ -113,10 +124,10 @@ if ARGV.include?("--migrate")
113
124
  exit 0
114
125
  end
115
126
 
116
- key, explicit_default, project_dir = parse_args(ARGV)
127
+ key, explicit_default, project_dir, harness = parse_args(ARGV)
117
128
 
118
129
  if key.nil? || key.empty?
119
- $stderr.puts "Usage: read-config <key> [--default VALUE] [--project PATH]"
130
+ $stderr.puts "Usage: read-config <key> [--default VALUE] [--project PATH] [--harness claude|codex]"
120
131
  exit 1
121
132
  end
122
133
 
@@ -129,9 +140,42 @@ if project_dir
129
140
  project_config = load_yaml(project_config_path)
130
141
  end
131
142
 
132
- value = dig_key(project_config, key)
133
- value = dig_key(global_config, key) if value.nil?
143
+ def canonical_harness(value)
144
+ return nil if value.nil?
145
+ return "claude" if %w[claude claude-code].include?(value.to_s)
146
+ return "codex" if value.to_s == "codex"
147
+
148
+ abort "Error: --harness must be claude or codex"
149
+ end
150
+
151
+ def harness_agent_value(config, key, harness)
152
+ match = key.match(/\Aagents\.(models|efforts)\.([^.]+)\z/)
153
+ return nil unless match
154
+
155
+ section, agent = match.captures
156
+ if section == "models"
157
+ AgentModels.models_section(config, harness: harness)[agent]
158
+ else
159
+ AgentModels.efforts_section(config, harness)[agent]
160
+ end
161
+ end
162
+
163
+ harness = canonical_harness(harness)
164
+ if harness
165
+ value = harness_agent_value(project_config, key, harness)
166
+ value = harness_agent_value(global_config, key, harness) if value.nil?
167
+ else
168
+ value = dig_key(project_config, key)
169
+ value = dig_key(global_config, key) if value.nil?
170
+ end
134
171
  value = explicit_default if value.nil? && explicit_default
172
+ if value.nil? && harness && (match = key.match(/\Aagents\.(models|efforts)\.([^.]+)\z/))
173
+ value = if match[1] == "models"
174
+ AgentModels.shipped_model_for(match[2], harness: harness)
175
+ else
176
+ AgentModels::DEFAULT_EFFORT
177
+ end
178
+ end
135
179
  value = dig_key(DEFAULTS, key) if value.nil?
136
180
 
137
181
  puts format_value(value)