@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- 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__
|