@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,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
@@ -135,13 +135,21 @@ unless skip_day_ledger
135
135
  end
136
136
  end
137
137
 
138
- # --- (d) heartbeat, always -------------------------------------------------
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.ensure_tmp_root(store)
143
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
144
- File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
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 = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
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
@@ -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)