@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,81 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# hook-message-display (intent 316a, O4): the MessageDisplay hook CLI. Reads
|
|
6
|
+
# the harness's per-chunk JSON payload from stdin, hands it to MessageDisplay
|
|
7
|
+
# (the pure handler class), and prints the hookSpecificOutput envelope when a
|
|
8
|
+
# String comes back. Always exits 0, whatever happens — a raised exception
|
|
9
|
+
# here must never surface as a non-zero exit or stray stderr on an ordinary
|
|
10
|
+
# chunk of an ordinary message (matrix 31).
|
|
11
|
+
#
|
|
12
|
+
# This script is the one place in the O4 stack allowed to read ENV, the
|
|
13
|
+
# clock, or Dir.tmpdir: MessageDisplay itself takes tmp_root/plastic_home/
|
|
14
|
+
# color/now as constructor arguments and touches none of them directly.
|
|
15
|
+
#
|
|
16
|
+
# Claude adapter: Claude Code only; the core is harness-agnostic.
|
|
17
|
+
|
|
18
|
+
# 317a (B1): the requires sit inside their own rescue - a missing or broken
|
|
19
|
+
# lib file must never surface as a non-zero exit or stderr on a chunk of an
|
|
20
|
+
# ordinary message (LoadError is a ScriptError, outside StandardError).
|
|
21
|
+
begin
|
|
22
|
+
require "json"
|
|
23
|
+
require "time"
|
|
24
|
+
require "tmpdir"
|
|
25
|
+
require "yaml"
|
|
26
|
+
require_relative "lib/message_display"
|
|
27
|
+
rescue ScriptError, StandardError
|
|
28
|
+
exit 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def color_enabled?(plastic_home)
|
|
32
|
+
return false unless ENV["NO_COLOR"].to_s.empty?
|
|
33
|
+
|
|
34
|
+
cfg_path = File.join(plastic_home, "config.yml")
|
|
35
|
+
return true unless File.exist?(cfg_path)
|
|
36
|
+
|
|
37
|
+
cfg = YAML.safe_load(File.read(cfg_path))
|
|
38
|
+
display = cfg.is_a?(Hash) ? cfg["display"] : nil
|
|
39
|
+
return true unless display.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
display.fetch("ansi_screen", true) != false
|
|
42
|
+
rescue StandardError
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
raw = $stdin.tty? ? "" : $stdin.read
|
|
48
|
+
payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
|
|
49
|
+
|
|
50
|
+
if payload.is_a?(Hash)
|
|
51
|
+
tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
|
|
52
|
+
plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
53
|
+
|
|
54
|
+
# PLASTIC_HOOK_TRACE=<file> (331a1): opt-in, off by default, one JSON
|
|
55
|
+
# object appended per chunk. This script is the only place allowed to
|
|
56
|
+
# read the environment; MessageDisplay takes the sink as an argument.
|
|
57
|
+
trace_path = ENV["PLASTIC_HOOK_TRACE"].to_s
|
|
58
|
+
trace = trace_path.empty? ? nil : MessageDisplay.file_trace(trace_path)
|
|
59
|
+
|
|
60
|
+
handler = MessageDisplay.new(
|
|
61
|
+
tmp_root: tmp_root,
|
|
62
|
+
plastic_home: plastic_home,
|
|
63
|
+
color: color_enabled?(plastic_home),
|
|
64
|
+
now: Time.now,
|
|
65
|
+
trace: trace,
|
|
66
|
+
)
|
|
67
|
+
result = handler.handle(payload)
|
|
68
|
+
|
|
69
|
+
if result.is_a?(String)
|
|
70
|
+
puts JSON.generate(
|
|
71
|
+
"hookSpecificOutput" => {
|
|
72
|
+
"hookEventName" => "MessageDisplay",
|
|
73
|
+
"displayContent" => result,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
exit 0
|
package/scripts/hook-record
CHANGED
|
@@ -135,13 +135,21 @@ unless skip_day_ledger
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
# --- (d) heartbeat,
|
|
138
|
+
# --- (d) heartbeat, guarded on the pointer (spec D9) -----------------------
|
|
139
|
+
# Session start writes the `current` pointer; with no pointer at all, session
|
|
140
|
+
# start never ran for this session (a `-p` print session, a resumed
|
|
141
|
+
# background job, an unregistered SessionStart hook), so neither the
|
|
142
|
+
# per-session dir nor its heartbeat is created here -- guarding only the
|
|
143
|
+
# write would still leave an empty directory that heartbeat_age's mtime
|
|
144
|
+
# fallback reports as an orphan just the same.
|
|
139
145
|
begin
|
|
140
146
|
store ||= File.join(plastic_home, "store")
|
|
141
147
|
sid ||= SessionLedger.short_session_id(nil, session_id)
|
|
142
|
-
SessionLedger.
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
if File.exist?(SessionLedger.pointer_path(store, sid))
|
|
149
|
+
SessionLedger.ensure_tmp_root(store)
|
|
150
|
+
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
151
|
+
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
152
|
+
end
|
|
145
153
|
rescue StandardError
|
|
146
154
|
nil
|
|
147
155
|
end
|
|
@@ -18,6 +18,40 @@ require_relative "lib/day_summary"
|
|
|
18
18
|
index_path, plastic_home, mode, plugin_root = ARGV
|
|
19
19
|
exit 0 unless index_path && plastic_home && mode
|
|
20
20
|
|
|
21
|
+
# --- session id: the stdin payload's session_id, then the env var, then the
|
|
22
|
+
# pid (spec 298 D1, row G, spec D4). Guarded on $stdin.tty? so a human running
|
|
23
|
+
# this hook by hand at a real terminal never blocks on a read that never gets
|
|
24
|
+
# an EOF; every real harness invocation pipes the SessionStart JSON payload,
|
|
25
|
+
# never attaches a tty. Malformed or empty stdin (or no payload id) falls
|
|
26
|
+
# through to the same env-var-then-pid chain this always had.
|
|
27
|
+
stdin_payload = begin
|
|
28
|
+
if $stdin.tty?
|
|
29
|
+
nil
|
|
30
|
+
else
|
|
31
|
+
raw = $stdin.read
|
|
32
|
+
raw && !raw.strip.empty? ? JSON.parse(raw) : nil
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
payload_session_id = stdin_payload.is_a?(Hash) ? stdin_payload["session_id"].to_s : ""
|
|
38
|
+
|
|
39
|
+
# --- subagent marker (intent 355 spec D9, node n7; review fix n8, B6): the
|
|
40
|
+
# stdin payload carries agent_id only when this SessionStart call runs
|
|
41
|
+
# inside a spawned agent. agent_type alone is not enough: a live
|
|
42
|
+
# `claude --agent` session carries agent_type on every turn but never
|
|
43
|
+
# agent_id, so keying on agent_type would boot a live agent session with the
|
|
44
|
+
# core banner only. Read from that payload only, never from an environment
|
|
45
|
+
# variable, so a helper needs the core banner alone. An absent agent_id (the
|
|
46
|
+
# common case) is a live session. Any exception here still boots the banner,
|
|
47
|
+
# never nothing: the rescue falls back to a live session, whose own content
|
|
48
|
+
# already comes from paths this file already guards independently.
|
|
49
|
+
subagent_session = begin
|
|
50
|
+
stdin_payload.is_a?(Hash) && !!stdin_payload["agent_id"]
|
|
51
|
+
rescue StandardError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
21
55
|
# Plastic home and the store are two different paths (intent 231). The shim passes
|
|
22
56
|
# home (~/.plastic) as argument 2; the store lives one level below it. Compose the
|
|
23
57
|
# store exactly once here, so no later line re-derives it and no path can gain a
|
|
@@ -236,9 +270,9 @@ begin
|
|
|
236
270
|
end
|
|
237
271
|
rescue Exception
|
|
238
272
|
# Any failure (timeout, missing binary, parse error) — stay silent, never crash.
|
|
239
|
-
end
|
|
273
|
+
end unless subagent_session
|
|
240
274
|
|
|
241
|
-
if plastic_md
|
|
275
|
+
if plastic_md && !subagent_session
|
|
242
276
|
# Conventions always loaded first
|
|
243
277
|
parts << plastic_md
|
|
244
278
|
parts << "\n---\n"
|
|
@@ -274,7 +308,7 @@ if plastic_md
|
|
|
274
308
|
end
|
|
275
309
|
end
|
|
276
310
|
|
|
277
|
-
if active_deprecations.any?
|
|
311
|
+
if active_deprecations.any? && !subagent_session
|
|
278
312
|
parts << ""
|
|
279
313
|
active_deprecations.each do |dep|
|
|
280
314
|
severity = dep["severity"] || "info"
|
|
@@ -301,7 +335,7 @@ if active_deprecations.any?
|
|
|
301
335
|
end
|
|
302
336
|
end
|
|
303
337
|
|
|
304
|
-
if update_notice
|
|
338
|
+
if update_notice && !subagent_session
|
|
305
339
|
parts.unshift("! #{update_notice}\n")
|
|
306
340
|
end
|
|
307
341
|
|
|
@@ -349,7 +383,7 @@ begin
|
|
|
349
383
|
end
|
|
350
384
|
rescue StandardError
|
|
351
385
|
nil
|
|
352
|
-
end
|
|
386
|
+
end unless subagent_session
|
|
353
387
|
|
|
354
388
|
# --- Session day ledger: open or join today, write the per-session pointer
|
|
355
389
|
# and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
|
|
@@ -361,7 +395,11 @@ begin
|
|
|
361
395
|
author = "session" if author.empty?
|
|
362
396
|
SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
|
|
363
397
|
|
|
364
|
-
session =
|
|
398
|
+
session = if !payload_session_id.empty?
|
|
399
|
+
payload_session_id
|
|
400
|
+
else
|
|
401
|
+
ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
|
|
402
|
+
end
|
|
365
403
|
sid = SessionLedger.short_session_id(nil, session)
|
|
366
404
|
SessionLedger.ensure_tmp_root(store_dir)
|
|
367
405
|
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
|
|
@@ -394,7 +432,7 @@ begin
|
|
|
394
432
|
end
|
|
395
433
|
rescue StandardError
|
|
396
434
|
nil
|
|
397
|
-
end
|
|
435
|
+
end unless subagent_session
|
|
398
436
|
|
|
399
437
|
# Emit nothing when there is genuinely nothing to surface (no conventions,
|
|
400
438
|
# no deprecations, no update notice).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# index-projection - the CLI over IndexProjection (intent 337, G4): prints
|
|
6
|
+
# the drift between INDEX.md and the intent ledgers for one store root
|
|
7
|
+
# (INDEX.md plus a store/ directory), exits non-zero when any drift exists.
|
|
8
|
+
# --write renders INDEX.md's four status sections from the projection
|
|
9
|
+
# through AtomicWrite; every other byte, including ## Clusters and
|
|
10
|
+
# ## Relocated, is untouched.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# index-projection <store_root> [--write]
|
|
14
|
+
#
|
|
15
|
+
# Exit codes:
|
|
16
|
+
# 0 - no drift (or, with --write, the write landed cleanly)
|
|
17
|
+
# 1 - drift exists (a real terminal-ledger conflict, an INDEX entry with
|
|
18
|
+
# no directory, or a directory INDEX does not list)
|
|
19
|
+
# 2 - usage: no argument, or the path is not a store root
|
|
20
|
+
|
|
21
|
+
require_relative "lib/index_projection"
|
|
22
|
+
|
|
23
|
+
module IndexProjectionCli
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def usage
|
|
27
|
+
warn "Usage: index-projection <store_root> [--write]"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def main(argv)
|
|
31
|
+
args = argv.dup
|
|
32
|
+
write = !!args.delete("--write")
|
|
33
|
+
|
|
34
|
+
root = args.shift
|
|
35
|
+
unless root && Dir.exist?(root)
|
|
36
|
+
warn "index-projection: #{root.inspect} is not a store root"
|
|
37
|
+
usage
|
|
38
|
+
return 2
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
index_path = File.join(root, "INDEX.md")
|
|
42
|
+
store_dir = File.join(root, "store")
|
|
43
|
+
unless File.exist?(index_path)
|
|
44
|
+
warn "index-projection: no INDEX.md at #{index_path}"
|
|
45
|
+
return 2
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if write
|
|
49
|
+
result = IndexProjection.write(store_dir, index_path: index_path)
|
|
50
|
+
unless result[:ok]
|
|
51
|
+
warn "index-projection: #{result[:error]}"
|
|
52
|
+
return 2
|
|
53
|
+
end
|
|
54
|
+
puts "index-projection: wrote #{index_path} (#{result[:moved].length} moved)"
|
|
55
|
+
return 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
analysis = IndexProjection.analyze(store_dir, index_path: index_path)
|
|
59
|
+
print_report(analysis)
|
|
60
|
+
|
|
61
|
+
(analysis[:drift].empty? && analysis[:index_only].empty? && analysis[:directory_only].empty?) ? 0 : 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def print_report(analysis)
|
|
65
|
+
puts "Drift (#{analysis[:drift].length}):"
|
|
66
|
+
analysis[:drift].each { |r| puts " #{r[:id]}: INDEX=#{r[:index_status]} ledger=#{r[:ledger_status]}" }
|
|
67
|
+
puts "INDEX-only (#{analysis[:index_only].length}):"
|
|
68
|
+
analysis[:index_only].each { |r| puts " #{r[:id]} (#{r[:index_status]})" }
|
|
69
|
+
puts "Directory-only (#{analysis[:directory_only].length}):"
|
|
70
|
+
analysis[:directory_only].each { |r| puts " #{r[:id]}" }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
exit(IndexProjectionCli.main(ARGV)) if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
# intent-screen (intent 316) - prints the intent screen for one intent directory:
|
|
5
|
+
# the title, the field table (Store, Status, Stage, Savepoint, Progress, Next,
|
|
6
|
+
# Insight, each with a note), and the Steps table, filled from the record by
|
|
7
|
+
# scripts/lib/intent_screen.rb. The session adds the What-this-means bullets and
|
|
8
|
+
# the close; it never edits the numbers.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# intent-screen <intent_dir> [--template <path>] [--ansi]
|
|
12
|
+
#
|
|
13
|
+
# The store root is the directory two levels above the intent (<root>/store/<id--slug>);
|
|
14
|
+
# the template defaults to templates/intent-screen.md next to this script's dir,
|
|
15
|
+
# in-repo (<repo>/scripts -> <repo>/templates) and installed (~/.plastic/scripts ->
|
|
16
|
+
# ~/.plastic/templates) alike.
|
|
17
|
+
#
|
|
18
|
+
# --ansi (intent 316a, O3): emits scripts/lib/intent_screen_ansi.rb's styled
|
|
19
|
+
# truecolor block instead of the plain Markdown screen. Plain stays the
|
|
20
|
+
# default with no flag. Two things force plain even WITH --ansi (D18): NO_COLOR
|
|
21
|
+
# present in the environment (any value counts), or a non-TTY stdout — the
|
|
22
|
+
# true default form of D2, not IntentScreenAnsi's own uncoloured layout. The
|
|
23
|
+
# library (scripts/lib/intent_screen_ansi.rb) is pure and never reads either;
|
|
24
|
+
# this script is the one place allowed to.
|
|
25
|
+
#
|
|
26
|
+
# Exit codes:
|
|
27
|
+
# 0 - the screen is on stdout
|
|
28
|
+
# 2 - usage error, or the path is not an intent directory (one line on stderr)
|
|
29
|
+
#
|
|
30
|
+
# Harness-agnostic core: no harness assumption lives here. This script holds
|
|
31
|
+
# the plain/ANSI selection (intent 316a1, D3) and belongs to the same
|
|
32
|
+
# harness-agnostic core as lib/intent_screen.rb and lib/intent_screen_ansi.rb.
|
|
33
|
+
|
|
34
|
+
require_relative "lib/intent_screen"
|
|
35
|
+
require_relative "lib/intent_screen_ansi"
|
|
36
|
+
|
|
37
|
+
def usage_abort(message)
|
|
38
|
+
warn "intent-screen: #{message}"
|
|
39
|
+
exit 2
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
args = ARGV.dup
|
|
43
|
+
template_path = nil
|
|
44
|
+
ansi = false
|
|
45
|
+
positional = []
|
|
46
|
+
while (arg = args.shift)
|
|
47
|
+
case arg
|
|
48
|
+
when "--template"
|
|
49
|
+
template_path = args.shift or usage_abort("--template needs a path")
|
|
50
|
+
when "--ansi"
|
|
51
|
+
ansi = true
|
|
52
|
+
else
|
|
53
|
+
usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
|
|
54
|
+
positional << arg
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
usage_abort("usage: intent-screen <intent_dir> [--template <path>] [--ansi]") unless positional.length == 1
|
|
59
|
+
intent_dir = File.expand_path(positional.first)
|
|
60
|
+
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
61
|
+
|
|
62
|
+
store_root = File.expand_path("../..", intent_dir)
|
|
63
|
+
template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
|
|
64
|
+
usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
|
|
65
|
+
|
|
66
|
+
plain = -> { IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path)) }
|
|
67
|
+
|
|
68
|
+
degrade_to_plain = ENV.key?("NO_COLOR") || !$stdout.tty?
|
|
69
|
+
|
|
70
|
+
$stdout.write(
|
|
71
|
+
if ansi && !degrade_to_plain
|
|
72
|
+
IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
|
|
73
|
+
else
|
|
74
|
+
plain.call
|
|
75
|
+
end
|
|
76
|
+
)
|
|
77
|
+
exit 0
|
|
@@ -0,0 +1,277 @@
|
|
|
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 "savepoint"
|
|
8
|
+
require_relative "work_graph_validator"
|
|
9
|
+
|
|
10
|
+
# ActionGraphShim (intent 342, G9): a read-time backward shim that presents
|
|
11
|
+
# any intent directory's actions/*.md as a node graph, in the record shape
|
|
12
|
+
# NodeFile.parse returns, so 336, 338 and 339 read a legacy intent through
|
|
13
|
+
# the same call they already use for an authored graph.md (spec.md D1-D18).
|
|
14
|
+
#
|
|
15
|
+
# Writes nothing, anywhere, ever (D1). An authored graph.md always wins over
|
|
16
|
+
# the synthetic chain (D3) - the reverse of 334's forward shim, which
|
|
17
|
+
# resolves content and prefers the older, richer source. This one resolves
|
|
18
|
+
# structure, where an authored graph is the only real graph.
|
|
19
|
+
module ActionGraphShim
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# D10's heading test: unchanged, four tokens, verified correct on the live
|
|
23
|
+
# store. Governs only whether a "## Files ..." heading itself is negated
|
|
24
|
+
# (e.g. "## Files you must NOT change"), never the section body.
|
|
25
|
+
FILES_NEGATION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't/i.freeze
|
|
26
|
+
|
|
27
|
+
# D19's body-truncation vocabulary: the four heading tokens plus the
|
|
28
|
+
# exclusion phrases the live corpus actually uses ("Out of bounds, owned
|
|
29
|
+
# by leads running in parallel right now:" matched none of the original
|
|
30
|
+
# four). Governs whether a body LINE truncates the files harvest -
|
|
31
|
+
# never the heading test above, which stays on FILES_NEGATION_RE.
|
|
32
|
+
FILES_EXCLUSION_RE = /\bnot\b|\bnever\b|\bavoid\b|don't|\bout of bounds\b|\bout of scope\b|\bhands off\b|\bleave alone\b|\bexcluded\b/i.freeze
|
|
33
|
+
|
|
34
|
+
BACKTICK_RE = /`([^`]+)`/.freeze
|
|
35
|
+
PROVEN_BY_LABEL_RE = /\AS\d+\z/.freeze
|
|
36
|
+
|
|
37
|
+
# :authored when graph.md exists, :actions when it does not but the intent
|
|
38
|
+
# holds at least one real action file (D7's exact realness test, reused
|
|
39
|
+
# rather than reimplemented so the shim can never disagree with
|
|
40
|
+
# Savepoint.has_real_action? about whether an intent has work in it), :none
|
|
41
|
+
# otherwise. A directory that does not exist returns :none and never
|
|
42
|
+
# raises.
|
|
43
|
+
def shape(intent_dir)
|
|
44
|
+
return :none unless intent_dir && File.directory?(intent_dir.to_s)
|
|
45
|
+
return :authored if File.exist?(File.join(intent_dir, "graph.md"))
|
|
46
|
+
return :actions if Savepoint.has_real_files_in?("actions", intent_dir)
|
|
47
|
+
|
|
48
|
+
:none
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The seven-key hash GraphFile.parse returns (ok:, goal:, decisions:,
|
|
52
|
+
# graph:, status:, verify:, errors:), for whichever shape the directory
|
|
53
|
+
# is in. On :authored this is literally GraphFile.parse, unmodified (D3).
|
|
54
|
+
# On :actions the same hash is built from the action files, with goal:,
|
|
55
|
+
# decisions: and status: nil (a 2024 action file was never asked for
|
|
56
|
+
# them) and verify: a fixed reason so a trivial-bar check the caller may
|
|
57
|
+
# run stays self-consistent. On :none it is GraphFile.parse's own
|
|
58
|
+
# not-found failure, produced by delegating to it rather than
|
|
59
|
+
# reformatting the message ourselves.
|
|
60
|
+
def view(intent_dir)
|
|
61
|
+
case shape(intent_dir)
|
|
62
|
+
when :authored
|
|
63
|
+
GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
64
|
+
when :actions
|
|
65
|
+
synth = synthetic_graph(intent_dir)
|
|
66
|
+
{
|
|
67
|
+
ok: synth[:errors].empty?,
|
|
68
|
+
goal: nil,
|
|
69
|
+
decisions: nil,
|
|
70
|
+
graph: synth,
|
|
71
|
+
status: nil,
|
|
72
|
+
verify: { reason: "backward shim: legacy actions/ carry no verify node" },
|
|
73
|
+
errors: synth[:errors],
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
# 342 post-execution review, should-fix 5: a nil intent_dir must never
|
|
77
|
+
# raise. shape(nil) is already nil-guarded, so `view` and `needs`
|
|
78
|
+
# honor the same contract rather than raising a File.join TypeError.
|
|
79
|
+
return GraphFile.failure(["graph file not found: #{intent_dir.inspect}"]) unless intent_dir
|
|
80
|
+
|
|
81
|
+
GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Node records for either shape (D8), always carrying NodeFile.parse's own
|
|
86
|
+
# key set (ok:, node:, kind:, files:, budget:, body:, errors:) plus
|
|
87
|
+
# needs:, path: and proven_by:. [] on :none, never a raise on a real
|
|
88
|
+
# directory.
|
|
89
|
+
def nodes(intent_dir)
|
|
90
|
+
case shape(intent_dir)
|
|
91
|
+
when :authored
|
|
92
|
+
authored_nodes(intent_dir)
|
|
93
|
+
when :actions
|
|
94
|
+
synthetic_nodes(intent_dir)
|
|
95
|
+
else
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The needs targets for one node id, under either shape; [] for an id the
|
|
101
|
+
# graph does not declare, and [] for a directory with no graph at all.
|
|
102
|
+
def needs(intent_dir, node_id)
|
|
103
|
+
graph = view(intent_dir)[:graph]
|
|
104
|
+
return [] unless graph
|
|
105
|
+
|
|
106
|
+
(graph[:edges] || {})[node_id] || []
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- authored shape -------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def authored_nodes(intent_dir)
|
|
112
|
+
parsed = GraphFile.parse(File.join(intent_dir, "graph.md"))
|
|
113
|
+
edges = parsed.dig(:graph, :edges) || {}
|
|
114
|
+
|
|
115
|
+
# 342 post-execution review, nit 8: sort_key numerically over the
|
|
116
|
+
# basename, the same key the synthetic chain uses, so an authored
|
|
117
|
+
# intent with ten or more nodes orders n10 after n9 rather than
|
|
118
|
+
# lexically between n1 and n2.
|
|
119
|
+
Dir.glob(File.join(intent_dir, "nodes", "*.md"))
|
|
120
|
+
.sort_by { |path| sort_key(File.basename(path)) }
|
|
121
|
+
.map do |path|
|
|
122
|
+
nf = NodeFile.parse(path)
|
|
123
|
+
nf.merge(
|
|
124
|
+
needs: edges[nf[:node]] || [],
|
|
125
|
+
path: path,
|
|
126
|
+
proven_by: proven_by_labels(nf[:body].to_s)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- actions shape ----------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# {nodes:, edges:, errors:} built directly over the minted chain, in the
|
|
134
|
+
# same shape GraphEdges.parse returns, rather than rendering a "## Graph"
|
|
135
|
+
# section and reparsing it.
|
|
136
|
+
def synthetic_graph(intent_dir)
|
|
137
|
+
ids = real_action_files(intent_dir).each_index.map { |i| "n#{i + 1}" }
|
|
138
|
+
edges = {}
|
|
139
|
+
ids.each_with_index { |id, i| edges[id] = i.zero? ? [] : [ids[i - 1]] }
|
|
140
|
+
{ nodes: ids, edges: edges, errors: [] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def synthetic_nodes(intent_dir)
|
|
144
|
+
files = real_action_files(intent_dir)
|
|
145
|
+
files.each_with_index.map do |path, i|
|
|
146
|
+
id = "n#{i + 1}"
|
|
147
|
+
needs_targets = i.zero? ? [] : ["n#{i}"]
|
|
148
|
+
begin
|
|
149
|
+
text = File.read(path)
|
|
150
|
+
{
|
|
151
|
+
ok: true,
|
|
152
|
+
node: id,
|
|
153
|
+
kind: "work",
|
|
154
|
+
files: files_section_paths(text),
|
|
155
|
+
budget: nil,
|
|
156
|
+
body: text,
|
|
157
|
+
errors: [],
|
|
158
|
+
needs: needs_targets,
|
|
159
|
+
path: path,
|
|
160
|
+
proven_by: proven_by_labels(text),
|
|
161
|
+
}
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
{
|
|
164
|
+
ok: false,
|
|
165
|
+
node: id,
|
|
166
|
+
kind: "work",
|
|
167
|
+
files: [],
|
|
168
|
+
budget: nil,
|
|
169
|
+
body: nil,
|
|
170
|
+
errors: ["could not read #{path}: #{e.message}"],
|
|
171
|
+
needs: needs_targets,
|
|
172
|
+
path: path,
|
|
173
|
+
proven_by: [],
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Real action files (File.file?, non-zero size, Savepoint.stage_file_present?
|
|
180
|
+
# - D7's own three-part predicate, applied per file since has_real_files_in?
|
|
181
|
+
# only answers true/false), ordered by the integer trailing the basename,
|
|
182
|
+
# falling back to the basename itself so the key is a total order over
|
|
183
|
+
# every basename shape the live store holds (D4).
|
|
184
|
+
def real_action_files(intent_dir)
|
|
185
|
+
Dir.glob(File.join(intent_dir.to_s, "actions", "*.md"))
|
|
186
|
+
.select { |f| File.file?(f) && File.size(f) > 0 && Savepoint.stage_file_present?(f) }
|
|
187
|
+
.sort_by { |f| sort_key(File.basename(f)) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def sort_key(basename)
|
|
191
|
+
m = basename.match(/\d+/)
|
|
192
|
+
m ? [0, m[0].to_i, basename] : [1, 0, basename]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The first heading whose leading token is "Files" (case-insensitively),
|
|
196
|
+
# unless the heading text carries a negation, in which case it is not a
|
|
197
|
+
# files section and the search continues (D10). Paths come out of that
|
|
198
|
+
# section's BODY through extract_files_paths, which is itself
|
|
199
|
+
# negation-aware and fence-aware (D17, D18). No qualifying heading gives
|
|
200
|
+
# [] (D9).
|
|
201
|
+
def files_section_paths(text)
|
|
202
|
+
NodeFile.split_by_headings(text).each do |heading, section|
|
|
203
|
+
next unless files_heading?(heading)
|
|
204
|
+
|
|
205
|
+
return extract_files_paths(section.to_s)
|
|
206
|
+
end
|
|
207
|
+
[]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Fence-aware (D18) and negation-aware over the section body, not only
|
|
211
|
+
# its heading (D17). D10 already stops a heading like "## Files you must
|
|
212
|
+
# NOT change" from being read as a files section at all; this handles
|
|
213
|
+
# the equally common case where a legitimate "## Files to touch" heading
|
|
214
|
+
# is followed, inside the same section, by an out-of-bounds paragraph -
|
|
215
|
+
# live on this intent's own dogfood fixture. A fenced block is dropped
|
|
216
|
+
# before anything else runs, reusing NodeFile.each_fence_line rather than
|
|
217
|
+
# a bare backtick scan, which is exactly what that helper exists to
|
|
218
|
+
# prevent.
|
|
219
|
+
#
|
|
220
|
+
# Lines are scanned in document order; harvesting stops at the first line
|
|
221
|
+
# an exclusion GOVERNS (D19), not merely mentions - a negation is a false
|
|
222
|
+
# positive as often as a real exclusion clause, since an action file
|
|
223
|
+
# routinely annotates an in-bounds path with a "do NOT rename it" aside.
|
|
224
|
+
# governs_exclusion? draws the line: the match governs when it begins
|
|
225
|
+
# before the line's first backtick (an exclusion clause introducing
|
|
226
|
+
# paths) or when the line carries no backtick at all (a bare warning
|
|
227
|
+
# sentence); it annotates, and the line is kept, when the match falls
|
|
228
|
+
# after the first backtick (a parenthetical about a path already named).
|
|
229
|
+
# Any span that still holds a newline is rejected as a backstop: a path
|
|
230
|
+
# is never more than one line.
|
|
231
|
+
def extract_files_paths(section)
|
|
232
|
+
kept = +""
|
|
233
|
+
NodeFile.each_fence_line(section) do |line, fenced|
|
|
234
|
+
next if fenced
|
|
235
|
+
break if governs_exclusion?(line)
|
|
236
|
+
|
|
237
|
+
kept << line
|
|
238
|
+
end
|
|
239
|
+
kept.scan(BACKTICK_RE).flatten.reject { |s| s.include?("\n") }.uniq
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# True when a FILES_EXCLUSION_RE match on this line governs the paths on
|
|
243
|
+
# it rather than merely annotating one (D19).
|
|
244
|
+
def governs_exclusion?(line)
|
|
245
|
+
match = line.match(FILES_EXCLUSION_RE)
|
|
246
|
+
return false unless match
|
|
247
|
+
|
|
248
|
+
first_backtick = line.index("`")
|
|
249
|
+
first_backtick.nil? || match.begin(0) < first_backtick
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def files_heading?(heading)
|
|
253
|
+
stripped = heading.to_s.sub(/\A#+\s*/, "")
|
|
254
|
+
first_token = stripped.split(/\s+/, 2).first.to_s.sub(/[^A-Za-z]+\z/, "")
|
|
255
|
+
return false unless first_token.casecmp?("Files")
|
|
256
|
+
|
|
257
|
+
!heading.to_s.match?(FILES_NEGATION_RE)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The S\d+ tokens of a file's headings, in heading order, taking a
|
|
261
|
+
# heading only when it owns at least one table data row (D5) - the same
|
|
262
|
+
# table-owning rule WorkGraphValidator.has_valid_matrix? and ReportScreen
|
|
263
|
+
# already apply, reused unmodified rather than adapted, so a prose
|
|
264
|
+
# mention of an S-label never manufactures a Proven-by source (334's
|
|
265
|
+
# post-execution review defect, in the other direction).
|
|
266
|
+
def proven_by_labels(text)
|
|
267
|
+
labels = []
|
|
268
|
+
NodeFile.split_by_headings(text).each do |heading, section|
|
|
269
|
+
next unless NodeFile.table_rows(section).any?
|
|
270
|
+
|
|
271
|
+
WorkGraphValidator.heading_tokens(heading).each do |token|
|
|
272
|
+
labels << token if token.match?(PROVEN_BY_LABEL_RE)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
labels
|
|
276
|
+
end
|
|
277
|
+
end
|
package/scripts/lib/arm.rb
CHANGED
|
@@ -94,8 +94,21 @@ module Arm
|
|
|
94
94
|
}
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# Owner rule 2026-08-31: does this session already have a live top-level
|
|
98
|
+
# pointer pointing SOMEWHERE ELSE (the day ledger or another intent)? A
|
|
99
|
+
# pointer already on this intent is the owner re-arming mid-delivery and
|
|
100
|
+
# stays idempotent. True means a
|
|
101
|
+
# conversation session. Reads only; rescues to false (fail open).
|
|
102
|
+
def preexisting_pointer?(session, home:)
|
|
103
|
+
path = pointer_path(session, home: home)
|
|
104
|
+
File.exist?(path) && !File.read(path).to_s.strip.empty?
|
|
105
|
+
rescue StandardError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
97
109
|
# --- the pointer -------------------------------------------------------------
|
|
98
110
|
|
|
111
|
+
|
|
99
112
|
def pointer_path(session, home:)
|
|
100
113
|
store = global_store(home)
|
|
101
114
|
SessionLedger.pointer_path(store, SessionLedger.short_session_id(nil, session))
|
|
@@ -125,12 +138,24 @@ module Arm
|
|
|
125
138
|
# lock data read and touches nothing.
|
|
126
139
|
def arm(intent_dir:, session:, mode: "auto", home: Dir.home, harness: nil,
|
|
127
140
|
agent: nil, model: nil, thread: nil, now: Time.now, runner: Worktree::ShellRunner.new,
|
|
128
|
-
host: Socket.gethostname)
|
|
141
|
+
host: Socket.gethostname, allow_inline: false)
|
|
129
142
|
raise ArgumentError, "mode must be auto or guided" unless %w[auto guided].include?(mode.to_s)
|
|
130
143
|
dir = File.expand_path(intent_dir)
|
|
131
144
|
key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
|
|
132
145
|
h = home_for(dir, home: home)
|
|
133
146
|
|
|
147
|
+
# Owner rule 2026-08-31: the main session never delivers an intent inline.
|
|
148
|
+
# A session that already carries a top-level session pointer is a
|
|
149
|
+
# conversation session (SessionStart wrote it at boot); arming there is
|
|
150
|
+
# inline delivery and is refused BEFORE any lock is taken. A dispatched or
|
|
151
|
+
# headless session has no pre-existing pointer and arms freely.
|
|
152
|
+
# --allow-inline is the explicit owner override. Fail open on read errors:
|
|
153
|
+
# a broken pointer file must never block a legitimate delivery.
|
|
154
|
+
if !allow_inline && preexisting_pointer?(key, home: h) &&
|
|
155
|
+
read_pointer(key, home: h).to_s.strip != intent_id_for(dir)
|
|
156
|
+
return { status: :inline_refused, lock: nil, worktree: nil, session: key, pointer: nil }
|
|
157
|
+
end
|
|
158
|
+
|
|
134
159
|
status, lock = Lock.acquire(dir, session: key, host: host, now: now,
|
|
135
160
|
harness: harness, agent: agent, model: model,
|
|
136
161
|
thread: thread, run_mode: mode.to_s)
|