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

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 (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -0
@@ -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)
@@ -0,0 +1,31 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # AtomicWrite (intent 334, n2, D19r): a sibling-temp-plus-rename write, the
5
+ # same shape as Lock#write (scripts/lib/lock.rb) - a content write is never
6
+ # an in-place truncate, so a crash mid-write can never leave an empty or
7
+ # half-written target on disk. The temp file is a SIBLING in the same
8
+ # directory as the target, never a system tmpdir, because File.rename can
9
+ # raise EXDEV when the temp and the target live on different filesystems.
10
+ # The temp name ends in .lock (post-execution review, non-blocking 5), the
11
+ # same property Lock#write_temp_path documents: an orphan left by a crash
12
+ # between the write and the rename is covered by the store's existing
13
+ # *.lock gitignore rule rather than swept into its git add -A auto-commit.
14
+ #
15
+ # The renamer is injectable so the interrupted-write case (a rename that
16
+ # raises) is testable with dependency injection, never eval or a global
17
+ # (D19r).
18
+ module AtomicWrite
19
+ module_function
20
+
21
+ def write(path, content, renamer: File.method(:rename))
22
+ dir = File.dirname(path)
23
+ temp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{Time.now.to_f}.#{rand(0xFFFFFF)}.lock")
24
+ File.write(temp, content)
25
+ renamer.call(temp, path)
26
+ true
27
+ rescue StandardError
28
+ File.delete(temp) if temp && File.exist?(temp)
29
+ raise
30
+ end
31
+ end
@@ -0,0 +1,71 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "digest"
6
+
7
+ # CoreIntegrity (intent 340, G7, n1): re-hashes the files an installed
8
+ # ~/.plastic/manifest.json lists, so the runner's own "am I running trusted
9
+ # code" check has one implementation. scripts/doctor.rb's
10
+ # Doctor#check_install_integrity does this same arithmetic inline today,
11
+ # per-agent (Codex/Hermes/Claude), against each agent's own manifest; this
12
+ # module is not a refactor of that (out of scope for this node), it exists
13
+ # so the runner - and a later node that points doctor at it - never have to
14
+ # reimplement the same hashing loop a third time.
15
+ #
16
+ # Pure and side-effect-free: `check` takes plastic_home: and never raises
17
+ # across its boundary. A missing, unreadable, or non-JSON manifest.json is a
18
+ # named refusal (`reason:`), never an exception. A manifest-listed file that
19
+ # no longer exists is reported under `missing`, distinct from `drifted` (a
20
+ # file that exists but no longer hashes to the manifest's recorded value).
21
+ module CoreIntegrity
22
+ module_function
23
+
24
+ def check(plastic_home:)
25
+ manifest_path = File.join(plastic_home.to_s, "manifest.json")
26
+ return refusal("manifest.json not found at #{manifest_path}") unless File.exist?(manifest_path)
27
+
28
+ raw = begin
29
+ File.read(manifest_path)
30
+ rescue StandardError => e
31
+ return refusal("manifest.json is unreadable: #{e.message}")
32
+ end
33
+
34
+ data = begin
35
+ JSON.parse(raw)
36
+ rescue JSON::ParserError => e
37
+ return refusal("manifest.json is not valid JSON: #{e.message}")
38
+ end
39
+
40
+ files = data.is_a?(Hash) ? data["files"] : nil
41
+ return refusal("manifest.json's files: is not a mapping") unless files.is_a?(Hash)
42
+
43
+ drifted = []
44
+ missing = []
45
+ files.each do |path, expected_hash|
46
+ unless File.exist?(path)
47
+ missing << path
48
+ next
49
+ end
50
+
51
+ # v1 minor 6/row 11.16: a tracked file that exists but cannot be read
52
+ # (permissions changed under this process) must never raise out of a
53
+ # module whose whole contract is "never raises across its boundary" -
54
+ # unreadable is reported the same way a hash mismatch is: this
55
+ # process cannot confirm the file matches what the manifest expects,
56
+ # which is exactly what `drifted` already means.
57
+ actual_hash = begin
58
+ Digest::SHA256.file(path).hexdigest
59
+ rescue SystemCallError
60
+ nil
61
+ end
62
+ drifted << path if actual_hash.nil? || actual_hash != expected_hash
63
+ end
64
+
65
+ { ok: drifted.empty? && missing.empty?, drifted: drifted, missing: missing, reason: nil }
66
+ end
67
+
68
+ def refusal(reason)
69
+ { ok: false, drifted: [], missing: [], reason: reason }
70
+ end
71
+ end
@@ -0,0 +1,40 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "report_screen"
5
+
6
+ # DashboardScreen (intent 331d) - the presentation half of the dashboard
7
+ # screen. scripts/dashboard.rb's screen_fields sources every fact (Active, In
8
+ # delivery, Delivered, Roadmap, Sessions, Changed, the two capped row lists)
9
+ # through the same helpers report-screen and DaySummary already use, turning
10
+ # a missing source into "not recorded" or "none" before it ever reaches
11
+ # here; this module carries no data-sourcing logic of its own, only layout,
12
+ # exactly like ReportScreen.render_state and IntentScreen.render do for
13
+ # their own screens.
14
+ module DashboardScreen
15
+ module_function
16
+
17
+ TEMPLATE_PATH = File.expand_path("../../templates/dashboard-screen.md", __dir__)
18
+
19
+ def render(fields, template: nil)
20
+ out = (template || File.read(TEMPLATE_PATH)).dup
21
+ out = out.gsub("{{scope}}", fields.fetch(:scope).to_s)
22
+ out = out.gsub("{{active}}", fields.fetch(:active).to_s)
23
+ out = out.gsub("{{in_delivery}}", fields.fetch(:in_delivery).to_s)
24
+ out = out.gsub("{{delivered}}", fields.fetch(:delivered).to_s)
25
+ out = out.gsub("{{roadmap}}", fields.fetch(:roadmap).to_s)
26
+ out = out.gsub("{{sessions}}", fields.fetch(:sessions).to_s)
27
+ out = out.gsub("{{changed}}", fields.fetch(:changed).to_s)
28
+ out = out.gsub("{{where_we_are.rows}}", where_we_are_rows(fields.fetch(:where_we_are, [])))
29
+ out = out.gsub("{{where_we_go_next.rows}}", where_we_go_next_rows(fields.fetch(:where_we_go_next, [])))
30
+ ReportScreen.fit_screen(out.gsub(/\n{3,}/, "\n\n"))
31
+ end
32
+
33
+ def where_we_are_rows(rows)
34
+ rows.map { |r| "| #{r[:graph_id]} | #{r[:intent]} | #{r[:stage]} | #{r[:progress]} | #{r[:lead]} |" }.join("\n")
35
+ end
36
+
37
+ def where_we_go_next_rows(rows)
38
+ rows.map { |r| "| #{r[:rank]} | #{r[:graph_id]} | #{r[:intent]} | #{r[:reason]} |" }.join("\n")
39
+ end
40
+ end
@@ -12,6 +12,7 @@ require "time"
12
12
  require_relative "session_ledger"
13
13
  require_relative "handoff"
14
14
  require_relative "lock"
15
+ require_relative "report_screen"
15
16
 
16
17
  module DaySummary
17
18
  module_function
@@ -113,8 +114,12 @@ module DaySummary
113
114
  dir = File.join(store_dir, dirname)
114
115
  next unless File.directory?(dir) && Lock.fresh?(dir, now: now)
115
116
  # A guided session's lock is live but not autonomous; a lock with no
116
- # run_mode (a 1.14 auto team) counts as auto.
117
- next if (Lock.read(dir) || {})["run_mode"].to_s == "guided"
117
+ # run_mode (a 1.14 auto team) counts as auto. 317a (B9): when the lock
118
+ # carries no run_mode, the outcome frontmatter stamp (D5) is asked
119
+ # before defaulting, so a guided close never reads as auto.
120
+ run_mode = (Lock.read(dir) || {})["run_mode"].to_s
121
+ run_mode = ReportScreen.outcome_frontmatter(dir)["mode"].to_s if run_mode.empty?
122
+ next if run_mode == "guided"
118
123
 
119
124
  id, slug = dirname.split("--", 2)
120
125
  "- #{id} #{slug}: #{last_savepoint_line(dir)}"