@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,201 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_file"
5
+ require_relative "graph_edges"
6
+ require_relative "node_file"
7
+ require_relative "action_graph_shim"
8
+
9
+ # WorkGraphValidator (intent 334, n4): the in-batch reader over one intent's
10
+ # graph.md and nodes/ - 327's rule for budget, files, and the decision and
11
+ # research kinds (fold A17). {ok:, missing:, errors:}, the same Result shape
12
+ # ProjectValidator returns (fold B7). Named apart from
13
+ # IntentValidator#validate_graph, which already exists for the KNOWLEDGE
14
+ # graph and stays unrelated to this one under D40 (fold B9).
15
+ #
16
+ # Built over GraphFile, GraphEdges, and NodeFile only - never IntentValidator,
17
+ # never ReportScreen. Every check accumulates into `errors` rather than
18
+ # short-circuiting on the first one (fold: "the owner fixes one thing per
19
+ # run").
20
+ module WorkGraphValidator
21
+ module_function
22
+
23
+ def validate(intent_dir)
24
+ missing = []
25
+ errors = []
26
+
27
+ graph_path = File.join(intent_dir, "graph.md")
28
+ parsed = GraphFile.parse(graph_path)
29
+ errors.concat(parsed[:errors])
30
+
31
+ graph = parsed[:graph]
32
+ if graph.nil?
33
+ # G9 (intent 342, D6/D7/D12): a legacy intent that never got a
34
+ # graph.md still reads as a valid work graph, structurally, through
35
+ # the backward shim - but only when graph.md is genuinely absent.
36
+ # A graph.md that exists but is malformed (no "## Graph" section)
37
+ # must never fall through to the synthetic chain and hide its own
38
+ # error (D12's whole point). The fresh error list here is
39
+ # deliberate: `errors` above already holds the not-found error from
40
+ # `parsed[:errors]`, and reusing it would return ok: false for every
41
+ # legacy intent in the store.
42
+ if !File.exist?(graph_path) && ActionGraphShim.shape(intent_dir) == :actions
43
+ return validate_actions_shape(intent_dir)
44
+ end
45
+
46
+ missing << "graph.md ## Graph section"
47
+ return { ok: false, missing: missing, errors: errors }
48
+ end
49
+
50
+ nodes = graph[:nodes]
51
+ edges = graph[:edges]
52
+ declared_ids = edges.keys
53
+
54
+ # Intent-scope dangling check: every node must be declared with its own
55
+ # needs-line here (unlike a roadmap, which may have undeclared roots -
56
+ # that reconciliation is G4's, not this validator's).
57
+ (nodes - declared_ids).each { |id| errors << "needs target #{id.inspect} names no declared node" }
58
+
59
+ cyc = GraphEdges.cycle(edges)
60
+ errors << "cyclic graph, cannot validate: #{cyc.join(' > ')}" if cyc
61
+
62
+ node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
63
+ parsed_nodes = {}
64
+ paths_by_id = Hash.new { |h, k| h[k] = [] }
65
+
66
+ node_paths.each do |path|
67
+ nf = NodeFile.parse(path)
68
+ nf[:errors].each { |e| errors << "#{File.basename(path)}: #{e}" }
69
+ id = nf[:node]
70
+ next unless id
71
+
72
+ paths_by_id[id] << path
73
+ parsed_nodes[id] ||= nf
74
+ end
75
+
76
+ paths_by_id.each do |id, paths|
77
+ next unless paths.length > 1
78
+
79
+ errors << "node id #{id.inspect} claimed by more than one file: #{paths.map { |p| File.basename(p) }.join(', ')}"
80
+ end
81
+
82
+ declared_ids.each do |id|
83
+ missing << "nodes/ file for #{id}" unless parsed_nodes.key?(id)
84
+ errors << "declared node #{id.inspect} has no node file under nodes/" unless parsed_nodes.key?(id)
85
+ end
86
+ parsed_nodes.each_key do |id|
87
+ errors << "node file for #{id.inspect} exists but ## Graph declares no such node" unless declared_ids.include?(id)
88
+ end
89
+
90
+ work_nodes = []
91
+ verify_nodes = []
92
+
93
+ declared_ids.each do |id|
94
+ nf = parsed_nodes[id]
95
+ next unless nf
96
+
97
+ case nf[:kind]
98
+ when "verify"
99
+ verify_nodes << id
100
+ errors << "verify node #{id.inspect} has no ## Criteria" unless has_section?(nf[:body], "## Criteria")
101
+ when "decision"
102
+ errors << "decision node #{id.inspect} has no ## Question with a question" unless has_nonblank_section?(nf[:body], "## Question")
103
+ when "research"
104
+ errors << "research node #{id.inspect} has no ## Deposit" unless has_section?(nf[:body], "## Deposit")
105
+ when "work"
106
+ work_nodes << id
107
+ errors << "work node #{id.inspect} has no ## Steps" unless has_section?(nf[:body], "## Steps")
108
+ errors << "work node #{id.inspect} has no ## Proven by" unless has_section?(nf[:body], "## Proven by")
109
+ end
110
+ end
111
+
112
+ above_trivial_bar = work_nodes.length >= 2
113
+ if above_trivial_bar
114
+ verify_directive = parsed[:verify]
115
+ directive_ok = verify_directive && !verify_directive[:reason].to_s.empty?
116
+
117
+ unless directive_ok
118
+ attached = verify_nodes.select { |vid| node_touches_graph?(vid, edges) }
119
+ if attached.empty?
120
+ errors << "two or more work nodes require a verify node attached to the graph, or a verify: none reason=<text> directive"
121
+ end
122
+ end
123
+
124
+ work_nodes.each do |id|
125
+ nf = parsed_nodes[id]
126
+ next unless nf
127
+
128
+ errors << "work node #{id.inspect} has no failure-mode matrix under a heading carrying its id" unless has_valid_matrix?(id, nf[:body])
129
+ end
130
+ end
131
+
132
+ verify_nodes.each do |id|
133
+ next if node_touches_graph?(id, edges)
134
+
135
+ errors << "verify node #{id.inspect} exists but no edge reaches it (attach it or drop it)"
136
+ end
137
+
138
+ { ok: errors.empty?, missing: missing, errors: errors }
139
+ end
140
+
141
+ # A node "touches" the graph when some edge involves it on either side:
142
+ # it targets something, or something targets it. A verify node declared
143
+ # with "needs nothing" and targeted by nothing is fully isolated (fold
144
+ # A13).
145
+ def node_touches_graph?(id, edges)
146
+ (edges[id] || []).any? || edges.values.any? { |targets| targets.include?(id) }
147
+ end
148
+
149
+ def has_section?(body, heading_text)
150
+ NodeFile.split_by_headings(body.to_s).any? { |heading, _| heading.strip == heading_text }
151
+ end
152
+
153
+ def has_nonblank_section?(body, heading_text)
154
+ NodeFile.split_by_headings(body.to_s).any? { |heading, section| heading.strip == heading_text && !section.strip.empty? }
155
+ end
156
+
157
+ def heading_tokens(heading)
158
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
159
+ end
160
+
161
+ # The matrix table must sit under a heading whose tokens include the
162
+ # node's own id, and that heading must own at least one data row (fold
163
+ # A1 - the same table-owning rule report_screen's resolver uses, so a
164
+ # node's own Proven-by cell is never "not recorded" the moment it ships).
165
+ def has_valid_matrix?(id, body)
166
+ NodeFile.split_by_headings(body.to_s).any? do |heading, section|
167
+ heading_tokens(heading).include?(id) && NodeFile.table_rows(section).any?
168
+ end
169
+ end
170
+
171
+ # The structural check the synthetic (actions/-only) shape gets, and
172
+ # nothing more (D6): at least one node, unique ids, every needs target
173
+ # names a declared node, acyclic. Never the kind-section rules, the
174
+ # failure-mode matrix bar, or the verify-attachment bar - a legacy action
175
+ # file labels its headings "S1", or nothing at all, and was never asked
176
+ # to meet a bar written for a graph authored under 327.
177
+ def validate_actions_shape(intent_dir)
178
+ errors = []
179
+ graph = ActionGraphShim.view(intent_dir)[:graph] || { nodes: [], edges: {}, errors: [] }
180
+ nodes = graph[:nodes]
181
+ edges = graph[:edges]
182
+
183
+ errors << "actions/ yields no nodes" if nodes.empty?
184
+ # D16: uniqueness is checked over the node array, not edges.keys - a
185
+ # Hash key set is unique by construction, so checking edges.keys can
186
+ # never fire.
187
+ errors << "duplicate node ids in synthetic chain" if nodes.uniq.length != nodes.length
188
+
189
+ # D16: every needs target is checked against the declared node list,
190
+ # not the node list against its own key set (nodes - edges.keys, which
191
+ # can never differ since the builder mints edges.keys from nodes).
192
+ (edges.values.flatten.uniq - nodes).each do |id|
193
+ errors << "needs target #{id.inspect} names no declared node"
194
+ end
195
+
196
+ cyc = GraphEdges.cycle(edges)
197
+ errors << "cyclic graph, cannot validate: #{cyc.join(' > ')}" if cyc
198
+
199
+ { ok: errors.empty?, missing: [], errors: errors }
200
+ end
201
+ end
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # meter-watch (intent 355, n5, D6): reads the rate-limit cache under --home
6
+ # and writes ~/.plastic/.cache/meter-state.json with the state (ok, reduce,
7
+ # stop, resume, stale, unavailable), the two raw percentages, the reset
8
+ # time, and when it was checked. A session watches that one file instead of
9
+ # every session parsing the cache and re-deriving the thresholds.
10
+ #
11
+ # Usage:
12
+ # meter-watch [--home DIR]
13
+ # meter-watch --install-timer [--home DIR]
14
+ #
15
+ # --install-timer writes a LaunchAgent plist under --home (default
16
+ # ~/.plastic when --home is not given) that runs this tick every 20
17
+ # minutes. It never calls launchctl; load it yourself with the command it
18
+ # prints. The Plastic installer never calls --install-timer on its own.
19
+ #
20
+ # Exit codes: 0 reported (unavailable included); 2 usage error.
21
+
22
+ require "json"
23
+ require_relative "lib/meter_watch"
24
+
25
+ def usage_abort(message)
26
+ warn "meter-watch: #{message}"
27
+ warn "Usage: meter-watch [--home DIR] [--install-timer]"
28
+ exit 2
29
+ end
30
+
31
+ home = File.join(Dir.home, ".plastic")
32
+ install_timer = false
33
+
34
+ argv = ARGV.dup
35
+ until argv.empty?
36
+ case (token = argv.shift)
37
+ when "--home"
38
+ usage_abort("--home requires a value") if argv.empty?
39
+ home = argv.shift
40
+ when "--install-timer"
41
+ install_timer = true
42
+ else
43
+ usage_abort("unknown argument #{token.inspect}")
44
+ end
45
+ end
46
+
47
+ home = File.expand_path(home)
48
+
49
+ if install_timer
50
+ plist_path = MeterWatch.install_timer(home: home, script_path: File.expand_path(__FILE__))
51
+ puts "Installed LaunchAgent at #{plist_path}"
52
+ puts "Run `launchctl load #{plist_path}` to activate it."
53
+ exit 0
54
+ end
55
+
56
+ state = MeterWatch.new(home: home).tick
57
+ puts JSON.generate(state)
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require_relative "lib/node_packet"
6
+ require_relative "lib/savepoint"
7
+
8
+ # node-packet - the CLI over NodePacket (intent 338, G5). Builds one node's
9
+ # whole input from disk and writes it to packets/<node>--a<N>.packet (or
10
+ # --out), printing a parsable summary and the exact node-transition running
11
+ # command intent 340's runner should record.
12
+ #
13
+ # Usage:
14
+ # node-packet <intent_dir> --node <id> [--budget N] [--hop-tokens N]
15
+ # [--holder H] [--expires E] [--model M] [--attempt N]
16
+ # [--out PATH] [--force]
17
+ #
18
+ # Exit codes (spec D17, shared with node-transition's family):
19
+ # 0 - the packet was written (or the identical bytes already existed)
20
+ # 2 - usage: not an intent directory, missing --node, or an unknown node
21
+ # 3 - an unreadable, cyclic or unparsable graph, node file, or record
22
+ # 4 - overflow past the third cut; nothing was written
23
+ # 5 - an existing attempt file whose bytes differ; nothing was written
24
+ module NodePacketCLI
25
+ module_function
26
+
27
+ def usage
28
+ warn "Usage: node-packet <intent_dir> --node <id> [--budget N] [--hop-tokens N] " \
29
+ "[--holder H] [--expires E] [--model M] [--attempt N] [--out PATH] [--force]"
30
+ end
31
+
32
+ def intent_directory?(dir)
33
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
34
+ end
35
+
36
+ def opt(args, name)
37
+ (i = args.index(name)) && args[i + 1]
38
+ end
39
+
40
+ def main(argv)
41
+ args = argv.dup
42
+ intent_dir_arg = args.shift
43
+
44
+ unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
45
+ warn "node-packet: #{intent_dir_arg.inspect} is not an intent directory"
46
+ usage
47
+ exit 2
48
+ end
49
+ intent_dir = File.expand_path(intent_dir_arg)
50
+
51
+ node = opt(args, "--node")
52
+ if node.to_s.strip.empty?
53
+ warn "node-packet: --node is required"
54
+ usage
55
+ exit 2
56
+ end
57
+
58
+ budget = opt(args, "--budget")
59
+ hop_tokens = opt(args, "--hop-tokens")
60
+ attempt = opt(args, "--attempt")
61
+ out = opt(args, "--out")
62
+ holder = opt(args, "--holder")
63
+ expires = opt(args, "--expires")
64
+ model = opt(args, "--model")
65
+ force = args.include?("--force")
66
+
67
+ result = NodePacket.build(
68
+ intent_dir: intent_dir,
69
+ node: node,
70
+ budget_tokens: budget ? budget.to_i : NodePacket::DEFAULT_BUDGET_TOKENS,
71
+ hop_tokens: hop_tokens ? hop_tokens.to_i : NodePacket::DEFAULT_HOP_TOKENS,
72
+ holder: holder,
73
+ expires: expires,
74
+ model: model,
75
+ attempt: attempt ? attempt.to_i : nil,
76
+ out: out,
77
+ force: force,
78
+ )
79
+
80
+ if result[:ok]
81
+ puts NodePacket.summary_line(result)
82
+ puts result[:running_command]
83
+ exit 0
84
+ end
85
+
86
+ Array(result[:errors]).each { |e| warn "node-packet: #{e}" }
87
+ puts result[:needs_decision_command] if result[:needs_decision_command]
88
+ exit result[:exit_code]
89
+ end
90
+ end
91
+
92
+ NodePacketCLI.main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -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__