@zalom/plastic 2.0.0-alpha.9 → 2.0.2

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 (326) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +21 -16
  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 +8 -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/docs/help/completion-and-done.md +53 -0
  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 +11 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +5 -4
  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 +55 -2
  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 +378 -56
  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 +7 -0
  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 +74 -91
  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 +111 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +68 -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 +58 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +75 -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 +79 -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 +46 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +95 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +35 -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 +77 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +65 -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 +55 -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 +23 -14
  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 +235 -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 +543 -71
  152. package/scripts/lib/intent_screen.rb +4 -4
  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 +350 -54
  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 +921 -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 +1371 -47
  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 +170 -12
  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 +526 -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 +365 -28
  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 +67 -19
  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/untouched_scaffold.rb +51 -0
  212. package/scripts/lib/verify_intent.rb +36 -8
  213. package/scripts/lib/version_number.rb +48 -0
  214. package/scripts/lib/work_graph.rb +59 -0
  215. package/scripts/lib/work_graph_validator.rb +201 -0
  216. package/scripts/lib/worktree.rb +27 -32
  217. package/scripts/lib/worktree_sweep.rb +6 -5
  218. package/scripts/link-suggest +2 -1
  219. package/scripts/meter-watch +57 -0
  220. package/scripts/migrate-to-global +1 -1
  221. package/scripts/new-intent +4 -13
  222. package/scripts/node-input +92 -0
  223. package/scripts/node-run +225 -0
  224. package/scripts/node-transition +291 -0
  225. package/scripts/outcome-report +74 -0
  226. package/scripts/plastic-lock +65 -60
  227. package/scripts/project-links +6 -21
  228. package/scripts/promote-session-item +3 -2
  229. package/scripts/read-config +53 -9
  230. package/scripts/ready-set +126 -0
  231. package/scripts/release-check +123 -0
  232. package/scripts/report-screen +177 -16
  233. package/scripts/roadmap-graph +125 -0
  234. package/scripts/roadmap-savepoint +7 -0
  235. package/scripts/rollback.rb +5 -1
  236. package/scripts/runner +581 -0
  237. package/scripts/savepoint-note +11 -9
  238. package/scripts/session-commit +2 -1
  239. package/scripts/session-usage +56 -0
  240. package/scripts/skill-lint +115 -6
  241. package/scripts/spawn-preamble +2 -2
  242. package/scripts/update.rb +31 -4
  243. package/scripts/validate-work-graph +39 -0
  244. package/scripts/verify-intent +3 -2
  245. package/scripts/write-handoff +2 -1
  246. package/templates/agents.md +7 -7
  247. package/templates/config.yml +16 -9
  248. package/templates/dashboard-screen.md +22 -0
  249. package/templates/display-fixture.md +21 -0
  250. package/templates/graph.md +16 -0
  251. package/templates/index.md +1 -1
  252. package/templates/intent-screen.md +1 -1
  253. package/templates/node-decision.md +11 -0
  254. package/templates/node-research.md +13 -0
  255. package/templates/node-verify.md +13 -0
  256. package/templates/node-work.md +22 -0
  257. package/templates/outcome.md +8 -3
  258. package/templates/project.yml +1 -1
  259. package/templates/render.css +10 -0
  260. package/templates/report-plan.md +15 -0
  261. package/templates/report-roadmap-delivered.md +10 -0
  262. package/templates/report-roadmap-plan.md +9 -0
  263. package/templates/report-roadmap-state.md +9 -0
  264. package/templates/report-state.md +1 -1
  265. package/templates/roadmap.md +13 -0
  266. package/bin/plastic.js +0 -70
  267. package/scripts/lib/bridge.rb +0 -116
  268. package/skills/agent-advisor/SKILL.md +0 -92
  269. package/skills/auto/SKILL.md +0 -296
  270. package/skills/auto/evals/evals.json +0 -255
  271. package/skills/auto/references/end-tail.md +0 -66
  272. package/skills/auto/references/human-report-contract.md +0 -78
  273. package/skills/conventions/SKILL.md +0 -29
  274. package/skills/conventions/references/completion-and-done.md +0 -43
  275. package/skills/dashboard/SKILL.md +0 -169
  276. package/skills/dashboard/evals/evals.json +0 -38
  277. package/skills/dashboard/references/classification.md +0 -22
  278. package/skills/dashboard/templates/dashboard-global.md +0 -20
  279. package/skills/dashboard/templates/dashboard-project.md +0 -19
  280. package/skills/direct/SKILL.md +0 -66
  281. package/skills/direct/references/request-signals.md +0 -59
  282. package/skills/doctor/SKILL.md +0 -299
  283. package/skills/doctor/report.md +0 -102
  284. package/skills/feedback/SKILL.md +0 -98
  285. package/skills/feedback/references/transport-and-privacy.md +0 -65
  286. package/skills/feedback/report.md +0 -36
  287. package/skills/install/SKILL.md +0 -217
  288. package/skills/intent-continuing/SKILL.md +0 -154
  289. package/skills/intent-continuing/references/board-fill.md +0 -43
  290. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  291. package/skills/intent-continuing/references/context-management.md +0 -28
  292. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  293. package/skills/intent-creating/SKILL.md +0 -164
  294. package/skills/intent-creating/evals/evals.json +0 -72
  295. package/skills/intent-creating/references/lifecycle.md +0 -81
  296. package/skills/intent-creating/references/wikilinks.md +0 -8
  297. package/skills/intent-ending/SKILL.md +0 -176
  298. package/skills/intent-ending/evals/evals.json +0 -74
  299. package/skills/intent-executing/SKILL.md +0 -170
  300. package/skills/intent-executing/evals/evals.json +0 -66
  301. package/skills/intent-executing/implementer-prompt.md +0 -42
  302. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  303. package/skills/intent-speccing/SKILL.md +0 -130
  304. package/skills/intent-speccing/evals/evals.json +0 -126
  305. package/skills/intent-speccing/references/design-principles.md +0 -44
  306. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  307. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  308. package/skills/project-creating/SKILL.md +0 -162
  309. package/skills/project-creating/references/hubs-projects.md +0 -55
  310. package/skills/project-creating/references/project-scaffolding.md +0 -97
  311. package/skills/releasing/SKILL.md +0 -337
  312. package/skills/releasing/references/deprecations.md +0 -60
  313. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  314. package/skills/releasing/references/release-lines.md +0 -105
  315. package/skills/roadmap/SKILL.md +0 -64
  316. package/skills/roadmap/references/file-format.md +0 -124
  317. package/skills/roadmap/references/operations.md +0 -112
  318. package/skills/rollback/SKILL.md +0 -91
  319. package/skills/tutorial/SKILL.md +0 -65
  320. package/skills/tutorial/evals/evals.json +0 -186
  321. package/skills/uninstall/SKILL.md +0 -75
  322. package/skills/update/SKILL.md +0 -126
  323. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  324. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  325. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  326. /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
@@ -15,16 +15,16 @@
15
15
  # arm take the intent for this session (intent 307): acquire
16
16
  # delivery.lock stamped with --mode (default auto) and the
17
17
  # provenance flags, provision the code worktree, and point the
18
- # session at the intent; exit 1 on a held, stale, excluded, or
19
- # corrupt lock with the resolving doctor hint
18
+ # session at the intent; exit 3 on a held, stale, or excluded
19
+ # lock (another owner's), exit 1 on a corrupt one, each with the
20
+ # public command that resolves it
20
21
  # who compact, read-only durable owner/activity view
21
22
  # 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
23
+ # whether this session is the one delivering the intent, and
24
+ # any live per-artifact claims
24
25
  # fix idempotent repair of the lock for the current session; never
25
26
  # 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)
27
+ # release owner clears the lock (End tail / abandoning a boarding)
28
28
  # reclaim explicit takeover of a stale lock, audited in savepoint.md
29
29
  # delegate owner registers a subagent session under the lock (D4)
30
30
  # claim acquire the named --artifact's claim token (intent 111);
@@ -32,18 +32,20 @@
32
32
  # including this same session; takes over a stale claim
33
33
  # release-claim free the named --artifact's claim token
34
34
  #
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.
35
+ # Without --intent-dir the intent is resolved from this session's held
36
+ # delivery lock (ActiveDelivery.resolve over the global store and the
37
+ # configured project roots): exactly one fresh lock this session owns or
38
+ # delegates for. Session resolution no longer reads any per-session state
39
+ # file; that path was retired in 2.0 (intent 307) and 344 G11.
40
+ # Exit 0 on success/report; exit 3 when another session owns the lock (arm,
41
+ # fix, release, reclaim); exit 1 when the verb is otherwise blocked.
40
42
 
43
+ require_relative "lib/store_layout"
41
44
  require "json"
42
45
  require "yaml"
43
- require_relative "lib/bridge"
44
46
  require_relative "lib/lock"
45
47
  require_relative "lib/arm"
46
- require_relative "lib/session_ledger"
48
+ require_relative "lib/active_delivery"
47
49
 
48
50
  VERBS = %w[arm status who fix release reclaim delegate claim release-claim].freeze
49
51
 
@@ -70,6 +72,7 @@ until ARGV.empty?
70
72
  when "--model" then opts[:model] = ARGV.shift
71
73
  when "--thread" then opts[:thread] = ARGV.shift
72
74
  when "--mode" then opts[:mode] = ARGV.shift
75
+ when "--allow-inline" then opts[:allow_inline] = true
73
76
  when "--status" then opts[:status] = ARGV.shift
74
77
  else
75
78
  warn "unknown flag #{flag}"
@@ -81,22 +84,12 @@ usage! if opts[:mode] && !%w[auto guided].include?(opts[:mode])
81
84
  session = opts[:session]
82
85
  session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
83
86
  harness = opts[:harness]
84
- hint_harness = harness || "claude"
85
87
  # The plastic home every hook uses (PLASTIC_HOME, else ~/.plastic); Arm takes the
86
88
  # OS home above it, the same convention Worktree resolves projects.yml from.
87
89
  plastic_home_env = ENV["PLASTIC_HOME"].to_s.strip
88
90
  home = plastic_home_env.empty? ? Dir.home : File.dirname(File.expand_path(plastic_home_env))
89
91
 
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
92
+ plastic_home = File.join(home, ".plastic")
100
93
 
101
94
  dir = opts[:dir]
102
95
  if dir.nil? || dir.strip.empty?
@@ -104,11 +97,21 @@ if dir.nil? || dir.strip.empty?
104
97
  warn "plastic-lock: #{verb} needs --intent-dir <intent dir>"
105
98
  exit 1
106
99
  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
100
+ roots = ActiveDelivery.project_roots(plastic_home)
101
+ global_store = Plastic::StoreLayout.global_store(plastic_home)
102
+ dir = ActiveDelivery.resolve(global_store: global_store, project_roots: roots, session: session)
103
+ if dir.nil?
104
+ candidates = ActiveDelivery.candidate_intent_dirs(global_store: global_store, project_roots: roots).uniq
105
+ held = candidates.select do |candidate|
106
+ Lock.fresh?(candidate) && Lock.holds?(candidate, session: session)
107
+ end
108
+ if held.size > 1
109
+ warn "plastic-lock: more than one held delivery lock names this session; pass --intent-dir <intent dir>"
110
+ else
111
+ warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
112
+ end
113
+ exit 1
114
+ end
112
115
  end
113
116
  dir = File.expand_path(dir)
114
117
 
@@ -143,45 +146,54 @@ if verb == "who"
143
146
  exit 0
144
147
  end
145
148
 
146
- intent_id = Bridge.intent_id_from_dir(dir)
149
+ intent_id = Arm.intent_id_for(dir)
147
150
  store = File.dirname(dir)
148
151
  key = Arm.resolve_session(session, store: store, intent_id: intent_id)
149
152
 
150
- def doctor_hint(verb_text, harness)
151
- "run #{Lock.skill_ref('plastic-doctor', harness: harness)} #{verb_text}"
153
+ # Exit 3 is Plastic's refusal: the lock belongs to another session, and only
154
+ # its owner (or the person who owns the work) may resolve it.
155
+ def refuse(message)
156
+ warn "plastic-lock: #{message}"
157
+ exit 3
152
158
  end
153
159
 
154
160
  case verb
155
161
  when "arm"
156
162
  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])
163
+ harness: harness, agent: opts[:agent], model: opts[:model], thread: opts[:thread],
164
+ allow_inline: opts[:allow_inline] || false)
158
165
  case result[:status]
159
166
  when :acquired, :owned
160
167
  wt = result[:worktree] || {}
161
168
  puts JSON.pretty_generate(
162
169
  "intent_dir" => dir, "session" => result[:session], "status" => result[:status].to_s,
163
170
  "run_mode" => result[:lock] && result[:lock]["run_mode"],
164
- "worktree" => wt, "pointer" => result[:pointer]
171
+ "worktree" => wt
165
172
  )
166
173
  when :held
167
- warn "plastic-lock: delivery lock for intent #{intent_id} is held by session " \
168
- "#{result[:lock] && result[:lock]['owner_session']}; #{doctor_hint('check the lock status', hint_harness)}"
169
- exit 1
174
+ refuse "delivery lock for intent #{intent_id} is held by session " \
175
+ "#{result[:lock] && result[:lock]['owner_session']}; plastic auto lock status #{intent_id} shows it"
170
176
  when :stale
171
- warn "plastic-lock: delivery lock for intent #{intent_id} is stale (owner " \
172
- "#{result[:lock] && result[:lock]['owner_session']}); #{doctor_hint('reclaim the lock to take it over with an audit', hint_harness)}"
173
- exit 1
177
+ refuse "delivery lock for intent #{intent_id} is stale (owner " \
178
+ "#{result[:lock] && result[:lock]['owner_session']}); #{Arm.stale_hint(intent_id)}"
174
179
  when :excluded
175
- warn "plastic-lock: a #{result[:lock] && result[:lock]['type']} lock is active on intent " \
176
- "#{intent_id}; #{doctor_hint('check the lock status', hint_harness)}"
177
- exit 1
180
+ refuse "a #{result[:lock] && result[:lock]['type']} lock is active on intent " \
181
+ "#{intent_id}; plastic auto lock status #{intent_id} shows it"
178
182
  when :corrupt
179
- warn "plastic-lock: delivery.lock for intent #{intent_id} is unreadable; #{doctor_hint('fix the lock', hint_harness)}"
183
+ warn "plastic-lock: delivery.lock for intent #{intent_id} is unreadable; plastic auto lock fix #{intent_id} rewrites it"
180
184
  exit 1
185
+ when :inline_refused
186
+ warn "plastic-lock: refusing to arm intent #{intent_id} from a conversation session " \
187
+ "(owner rule 2026-08-31: no inline intent delivery). Dispatch the delivery team " \
188
+ "instead (plastic-auto), or pass --allow-inline with explicit owner approval."
189
+ exit 3
181
190
  end
182
191
  when "status"
183
192
  lock = Lock.read(dir)
184
- pointer = Arm.read_pointer(key, home: home)
193
+ resolved = ActiveDelivery.resolve(global_store: Plastic::StoreLayout.global_store(plastic_home),
194
+ project_roots: ActiveDelivery.project_roots(plastic_home),
195
+ session: key)
196
+ delivering = !resolved.nil? && File.expand_path(resolved) == File.expand_path(dir)
185
197
  report = {
186
198
  "intent_dir" => dir,
187
199
  "session" => key,
@@ -189,41 +201,34 @@ when "status"
189
201
  "lock_fresh" => Lock.fresh?(dir),
190
202
  "lock_corrupt" => Lock.corrupt?(dir),
191
203
  "worktree" => Arm.worktree_block(intent_dir: dir, home: home),
192
- "pointer" => pointer,
193
- "pointer_names_intent" => pointer == intent_id,
204
+ "delivering" => delivering,
194
205
  "claims" => Claim.claims_status(dir),
195
206
  }
196
207
  puts JSON.pretty_generate(report)
197
208
  when "fix"
198
209
  report = Arm.repair(intent_dir: dir, session: key, home: home, harness: harness,
199
210
  agent: opts[:agent], model: opts[:model], thread: opts[:thread],
200
- run_mode: opts[:mode], hint_harness: hint_harness)
211
+ run_mode: opts[:mode])
201
212
  puts JSON.pretty_generate(report)
202
213
  unless report["status"] == "repaired"
203
- warn "plastic-lock: #{report['status']} by #{report['owner']}" \
204
- "#{report['hint'] ? " (#{report['hint']})" : ''}"
205
- exit 1
214
+ refuse "#{report['status']} by #{report['owner']}" \
215
+ "#{report['hint'] ? " (#{report['hint']})" : ''}"
206
216
  end
207
217
  when "release"
208
218
  result = Lock.release(dir, session: key)
209
- if result == :not_owner
210
- warn "plastic-lock: not the owner; run plastic-lock status"
211
- exit 1
212
- end
213
- Arm.reset_pointer(key, intent_id, home: home)
219
+ refuse "not the owner; plastic auto lock status #{intent_id} shows who is" if result == :not_owner
214
220
  puts "released (#{result})"
215
221
  when "reclaim"
216
222
  status, lock_data = Lock.takeover(dir, session: key, harness: harness,
217
223
  agent: opts[:agent], model: opts[:model],
218
224
  thread: opts[:thread], run_mode: opts[:mode])
219
225
  if status == :fresh
220
- warn "plastic-lock: lock is FRESH and held by #{lock_data['owner_session']}; " \
221
- "back off (no silent reclaim)"
222
- exit 1
226
+ refuse "lock is FRESH and held by #{lock_data['owner_session']}; " \
227
+ "back off (no silent reclaim)"
223
228
  end
224
229
  report = Arm.repair(intent_dir: dir, session: key, home: home, harness: harness,
225
230
  agent: opts[:agent], model: opts[:model], thread: opts[:thread],
226
- run_mode: opts[:mode], hint_harness: hint_harness)
231
+ run_mode: opts[:mode])
227
232
  puts JSON.pretty_generate(report)
228
233
  when "delegate"
229
234
  usage! if opts[:delegate].nil?
@@ -48,10 +48,6 @@ require_relative "lib/revisions_writer"
48
48
  class ProjectLinks
49
49
  DEFAULT_HOME = File.join(Dir.home, ".plastic")
50
50
 
51
- # The 72 intent dir audit destination (relative to plastic_home).
52
- DEFAULT_AUDIT_REL =
53
- "projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
54
-
55
51
  def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil,
56
52
  drop_unbacked_links: false, intent: nil, store: nil)
57
53
  @plastic_home = plastic_home
@@ -60,20 +56,7 @@ class ProjectLinks
60
56
  @intent = intent && intent.to_s
61
57
  @store = store
62
58
 
63
- # A dry run must NOT stomp the canonical audit (humans run --dry-run to review
64
- # the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
65
- # sibling, leaving the canonical real-run audit untouched. An explicit
66
- # --audit-path is always honored verbatim (tests inject it). Mirrors
67
- # RebuildGraph's discipline exactly.
68
- canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
69
- @audit_path =
70
- if audit_path
71
- audit_path
72
- elsif dry_run
73
- canonical.sub(/\.md\z/, ".dry-run.md")
74
- else
75
- canonical
76
- end
59
+ @audit_path = audit_path || File.join(Plastic::StoreLayout.global_root(plastic_home), "resources", "audit--links-projection.md")
77
60
  end
78
61
 
79
62
  attr_reader :plastic_home, :dry_run, :audit_path, :drop_unbacked_links, :intent, :store
@@ -330,9 +313,11 @@ class ProjectLinks
330
313
  "(ambiguous: multiple ## Links headings)"
331
314
  end
332
315
 
333
- # Always write the audit (even in dry-run, so the human reviews the plan).
316
+ # Return the audit for previews; save it only when applying changes.
334
317
  def emit_audit(store_list, results)
335
318
  text = render_audit(store_list, results)
319
+ return text if dry_run
320
+
336
321
  FileUtils.mkdir_p(File.dirname(audit_path))
337
322
  File.write(audit_path, text)
338
323
  text
@@ -348,7 +333,7 @@ class ProjectLinks
348
333
 
349
334
  def render_audit(store_list, results)
350
335
  lines = []
351
- lines << "# Audit: store-wide ## Links projection (intent 72)"
336
+ lines << "# Audit: store-wide Links projection"
352
337
  lines << ""
353
338
  lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
354
339
  lines << "Scoped to intent #{intent} only (--intent)." if intent
@@ -482,6 +467,6 @@ if $PROGRAM_NAME == __FILE__
482
467
  "regenerated #{totals[:regenerated]}, added #{totals[:added]}, " \
483
468
  "unchanged #{totals[:unchanged]}, failed #{totals[:failed]} " \
484
469
  "across #{results.size} store(s)."
485
- puts "Audit: #{tool.audit_path}"
470
+ puts(dry ? "Dry run: no files written." : "Audit: #{tool.audit_path}")
486
471
  exit 1 if tool.any_failed?(results)
487
472
  end