@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
@@ -66,6 +66,8 @@
66
66
  # refused before removal; pass --discard-worktree-changes to override
67
67
  # deliberately.
68
68
  # 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
69
+ # 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
70
+ # exception to report-and-proceed; --allow-hollow-report overrides.
69
71
 
70
72
  require "fileutils"
71
73
  require "date"
@@ -77,7 +79,9 @@ require_relative "lib/savepoint"
77
79
  require_relative "lib/lock"
78
80
  require_relative "lib/intent_validator"
79
81
  require_relative "lib/outcome_guard"
82
+ require_relative "lib/report_screen"
80
83
  require_relative "lib/backfill_intent"
84
+ require_relative "lib/outcome_report"
81
85
 
82
86
  DISPOSITIONS = %w[delivered abandoned].freeze
83
87
 
@@ -99,6 +103,7 @@ def parse_args(argv)
99
103
  opts = {
100
104
  store: nil, id: nil, disposition: nil, index: nil,
101
105
  outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
106
+ allow_hollow_report: false,
102
107
  session: nil, discard_worktree_changes: false,
103
108
  }
104
109
  i = 0
@@ -115,6 +120,7 @@ def parse_args(argv)
115
120
  when "--discard-worktree-changes" then opts[:discard_worktree_changes] = true
116
121
  when "--no-commit" then opts[:no_commit] = true
117
122
  when "--dry-run" then opts[:dry_run] = true
123
+ when "--allow-hollow-report" then opts[:allow_hollow_report] = true
118
124
  else
119
125
  usage_abort("unknown argument #{arg.inspect}")
120
126
  end
@@ -171,6 +177,64 @@ def resolve_plastic_home_and_scope(store)
171
177
  end
172
178
  end
173
179
 
180
+ # --- outcome generation, before the backfill (intent 339, G6, spec D10) -----
181
+ #
182
+ # Generates outcome.md from graph.md, nodes/, and the ledger, BEFORE the
183
+ # backfill step runs, and only into a file that is missing or still the
184
+ # scaffold placeholder (`Savepoint.stage_file_present?` false) - a real,
185
+ # hand-written outcome.md is never touched (row 7.2). Only when a graph.md
186
+ # exists at all (row 7.3): every intent without one closes exactly as it did
187
+ # before this intent. The model is parsed AFTER the `stage_file_present?`
188
+ # guard, not before: a real hand-written outcome.md returns at that guard
189
+ # regardless of graph.md's own health, so a close that was never going to
190
+ # generate anything must never print a parse warning about it. Refuses a
191
+ # report model that reports itself broken (row v1f.2, B2): a malformed
192
+ # `## Graph` fails `model[:ok]` and the close falls straight through to the
193
+ # backfill, rather than adopting parse damage as delivery fact - agreeing
194
+ # with the shipped `outcome-report` CLI, which exits 3 on the same
195
+ # directory. The generated text is then checked against
196
+ # BOTH close gates - `OutcomeGuard` and the hollow-report gate below - before
197
+ # being adopted (row 7.4); when either would refuse it, the write is
198
+ # reverted and the close falls through to intent 308's unchanged backfill
199
+ # (row 7.5), so no close gains an exit 7 it did not have before this intent.
200
+ # Fails open on any exception (row 7.6), the same report-and-proceed shape
201
+ # every other close step in this file uses. `generator:` is the injectable
202
+ # seam (mirrors run_structure_check's `gate:`).
203
+ def run_generate_outcome(intent_dir, disposition:, generator: OutcomeReport.method(:write))
204
+ graph_path = File.join(intent_dir, "graph.md")
205
+ return unless File.exist?(graph_path)
206
+
207
+ outcome_path = File.join(intent_dir, "outcome.md")
208
+ return if Savepoint.stage_file_present?(outcome_path)
209
+
210
+ report_model = OutcomeReport.model(intent_dir)
211
+ unless report_model[:ok]
212
+ warn "end-intent: graph.md failed to parse (#{report_model[:errors].join('; ')}); falling back to the backfill"
213
+ return
214
+ end
215
+
216
+ original_exists = File.exist?(outcome_path)
217
+ original_content = original_exists ? File.read(outcome_path) : nil
218
+
219
+ generator.call(intent_dir, disposition: disposition)
220
+
221
+ guard_reason = OutcomeGuard.reason(intent_dir, disposition)
222
+ hollow = hollow_report_reason(intent_dir, disposition)
223
+ if guard_reason || hollow
224
+ if original_exists
225
+ File.write(outcome_path, original_content)
226
+ elsif File.exist?(outcome_path)
227
+ File.delete(outcome_path)
228
+ end
229
+ warn "end-intent: generated outcome.md would be refused (#{[guard_reason, hollow].compact.join('; ')}); " \
230
+ "falling back to the backfill"
231
+ else
232
+ warn "end-intent: outcome.md generated from graph.md and the ledger"
233
+ end
234
+ rescue StandardError => e
235
+ warn "end-intent: outcome generation crashed (#{e.message}); proceeding without it"
236
+ end
237
+
174
238
  # Backfill (intent 308): write the judgment documents that are missing or still the
175
239
  # placeholder from the record (BackfillIntent). Fail-open: a crash here warns and the
176
240
  # close proceeds, the same contract as the structure check below.
@@ -215,7 +279,137 @@ end
215
279
  # Replace the body of the intent file's `## Outcome` section with `summary`,
216
280
  # leaving every other section and the frontmatter untouched. No-op (returns
217
281
  # content unchanged) when summary is blank or the section is not found.
282
+ # 317a S9 (D7): the self-render gate. Renders the delivered screen's sources on
283
+ # the intent's own record and names what would come out hollow. This is intent
284
+ # 308's ONE deliberate exception to report-and-proceed: a hollow DELIVERED
285
+ # close is refused with exit 7 (--allow-hollow-report overrides), because 317
286
+ # closed exactly this way and the owner read a screen full of "not recorded".
287
+ # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
288
+ # exempt: the reader cannot expect labels the writer never had.
289
+ #
290
+ # Intent 334 (G1, fold A3; post-execution review, blocking 2): the legacy
291
+ # "S3" label grammar applies only to actions/*.md headings, and the node id
292
+ # grammar (kind prefix n/v/d/r plus digits, D1r) applies only to nodes/*.md
293
+ # headings - never both over both directories. This repo's own house words
294
+ # for plan review and post-execution review are "v1" and "v2"; widening the
295
+ # node-id alternative onto actions/ headings turned that prose into a
296
+ # manufactured label and refused legitimate legacy closes at exit 7.
297
+ ACTION_ID_LABEL_RE = /\AS\d+\z/.freeze
298
+ NODE_ID_LABEL_RE = /\A[nvdr][1-9]\d*\z/.freeze
299
+
300
+ # The id-shaped tokens found in `paths`' headings, matched against
301
+ # `id_label_re` only - the caller picks the grammar for the directory.
302
+ def heading_id_labels(paths, id_label_re)
303
+ paths.flat_map do |path|
304
+ ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
305
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).select { |tok| tok.match?(id_label_re) }
306
+ end
307
+ end
308
+ end
309
+
310
+ # 339 S9 (D18): a node id counts as a label in an actions/*.md heading too,
311
+ # but only when it stands alone as one of the heading's " - "-delimited
312
+ # segments - this repo's own convention, "### S1 - n1 - Title" (this action
313
+ # file's own headings, post-fold) or the bare "### n1 - Title". Never as a
314
+ # free word inside ordinary prose: the comment above ACTION_ID_LABEL_RE
315
+ # records exactly why intent 334 restricted the node-id grammar to
316
+ # nodes/*.md headings in the first place, and that reasoning does not change
317
+ # here - a word-scan widened onto actions/ headings would let this repo's own
318
+ # house words ("v1", "v2" for plan review and post-execution review)
319
+ # manufacture a label out of ordinary prose and refuse a legitimate legacy
320
+ # close. Segment position is what a word-scan cannot see: "v2" embedded
321
+ # mid-sentence is never its own " - "-delimited segment, but a heading built
322
+ # to this convention always puts the node id in one.
323
+ def heading_node_id_segments(paths, id_label_re)
324
+ paths.flat_map do |path|
325
+ ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
326
+ heading.to_s.sub(/\A#+\s*/, "").split(/\s+-\s+/).map(&:strip).select { |seg| seg.match?(id_label_re) }
327
+ end
328
+ end
329
+ end
330
+
331
+ def hollow_report_reason(intent_dir, disposition)
332
+ return nil unless disposition == "delivered"
333
+
334
+ outcome = File.join(intent_dir, "outcome.md")
335
+ return nil unless File.exist?(outcome)
336
+ text = File.read(outcome)
337
+ return nil if text.include?("<!-- backfilled from the record by end-intent on ")
338
+
339
+ # The gate's evidence bar (A7/B7): it judges only records that CLAIM a
340
+ # labeled convention - at least one actions/*.md heading carrying an
341
+ # S-label or a node-id segment, or at least one nodes/*.md heading carrying
342
+ # a node id ("n1", "v2", "d3", "r4"). A terse legacy close with no labeled
343
+ # matrix stays report-and-proceed (intent 334, G1, D10r).
344
+ #
345
+ # Every matching token per heading is kept, not just the first (fold A3):
346
+ # a legacy actions/ heading can carry BOTH an S-label and prose that reads
347
+ # like a node id ("### The v2 rewrite (S3)"). Restricting the word-scan
348
+ # node-id grammar to nodes/*.md headings (post-execution review, blocking
349
+ # 2) means "v2" in that heading is never even tested against it, so the
350
+ # heading's real "S3" label is never at risk.
351
+ #
352
+ # 339 S9 (D18): a graph-era action file headed by node id alone ("### n1 -
353
+ # Title", no S-label) used to yield an empty label set here and the gate
354
+ # returned before judging anything - every such record closed unjudged by
355
+ # silent exemption. `heading_node_id_segments` widens the actions/*.md side
356
+ # to see that shape without reopening the word-scan hole above: it matches
357
+ # a node id only when the heading's own " - "-delimited segments carry one,
358
+ # never a free word in prose. The node_paths call stays exactly as it was;
359
+ # nodes/*.md headings already resolve node ids correctly (test_node_id_
360
+ # labels_are_recognized) and their heading grammar is controlled, so a
361
+ # word-scan there carries none of the prose risk.
362
+ action_paths = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort
363
+ node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
364
+ heading_labels = (heading_id_labels(action_paths, ACTION_ID_LABEL_RE) +
365
+ heading_node_id_segments(action_paths, NODE_ID_LABEL_RE) +
366
+ heading_id_labels(node_paths, NODE_ID_LABEL_RE)).uniq
367
+ return nil if heading_labels.empty?
368
+
369
+ rows = ReportScreen.delivered_rows(intent_dir)
370
+ return "outcome.md ## Delivered renders no rows although a labeled action matrix exists" if rows.empty?
371
+
372
+ needs = ReportScreen.section_of(text, "## Needs you").gsub(/<!--.*?-->/m, "").strip
373
+ if !needs.empty? && needs != "None" && ReportScreen.needs_you_rows(intent_dir).empty?
374
+ return "## Needs you has content that would render as None"
375
+ end
376
+
377
+ labels = rows.map { |r| r[:label] }
378
+ if (labels & heading_labels).empty?
379
+ return "delivered row labels (#{labels.first(3).join(', ')}...) match no action-file heading (#{heading_labels.first(3).join(', ')}...)"
380
+ end
381
+ if rows.all? { |r| ReportScreen.proven_by(intent_dir, r[:label]) == ReportScreen::NOT_RECORDED }
382
+ return "every Proven-by cell renders not recorded although labeled action headings exist"
383
+ end
384
+
385
+ nil
386
+ rescue StandardError => e
387
+ warn "end-intent: hollow-report gate crashed (#{e.message}); proceeding without it"
388
+ nil
389
+ end
390
+
391
+ # 317a S7 (D5, A8): stamp the live lock's run_mode into outcome.md frontmatter
392
+ # INSIDE the close step - after the gate, before the INDEX move and the store
393
+ # commit - so the stamped file rides the close commit and mode survives disarm.
394
+ def stamp_outcome_mode(intent_dir)
395
+ data = Lock.read(intent_dir)
396
+ run_mode = data && data["run_mode"].to_s
397
+ return if run_mode.nil? || run_mode.empty?
398
+
399
+ path = File.join(intent_dir, "outcome.md")
400
+ return unless File.exist?(path)
401
+ text = File.read(path)
402
+ return unless text.start_with?("---")
403
+ head, body = text[3..].split("---", 2)
404
+ return if body.nil? || head.match?(/^mode:/)
405
+
406
+ File.write(path, "---#{head}mode: #{run_mode}\n---#{body}")
407
+ rescue StandardError => e
408
+ warn "end-intent: mode stamp failed (#{e.message}); proceeding without it"
409
+ end
410
+
218
411
  def stamp_outcome_summary(content, summary)
412
+
219
413
  return content if summary.nil? || summary.to_s.strip.empty?
220
414
 
221
415
  lines = content.lines
@@ -645,6 +839,11 @@ def main(argv)
645
839
  end
646
840
  end
647
841
 
842
+ # 0a. Generate outcome.md from graph.md and the ledger, when there is one
843
+ # (intent 339, G6, spec D10) - before the backfill, so a real graph never
844
+ # falls through to the terse backfill it exists to replace.
845
+ run_generate_outcome(intent_dir, disposition: disposition)
846
+
648
847
  # 1. Backfill from the record (intent 308), then the structure self-check and the
649
848
  # outcome guard, both report-only since 308.
650
849
  run_backfill(intent_dir, store: store, id: id, disposition: disposition,
@@ -654,6 +853,26 @@ def main(argv)
654
853
  reason = OutcomeGuard.reason(intent_dir, disposition)
655
854
  warn "end-intent: outcome.md: #{reason} (proceeding; doctor --intent #{id} reports it)" if reason
656
855
 
856
+ # 1a2. The self-render gate (317a S9, D7): refuse a hollow delivered close
857
+ # BEFORE any terminal mutation - INDEX, savepoint, and store stay untouched
858
+ # on exit 7 (A9).
859
+ hollow = hollow_report_reason(intent_dir, disposition)
860
+ if hollow
861
+ if opts[:allow_hollow_report]
862
+ warn "end-intent: hollow delivered report overridden (--allow-hollow-report): #{hollow}"
863
+ else
864
+ warn "end-intent: refusing a hollow delivered close: #{hollow}"
865
+ warn "end-intent: write outcome.md's ## Delivered as the labeled | Row | What | table " \
866
+ "matching the action-file headings (templates/outcome.md shows the shape), " \
867
+ "or pass --allow-hollow-report to close anyway"
868
+ exit 7
869
+ end
870
+ end
871
+
872
+ # 1a3. Mode stamp (317a S7, D5): run_mode into outcome frontmatter while the
873
+ # lock still exists, so the stamped file rides the close commit.
874
+ stamp_outcome_mode(intent_dir)
875
+
657
876
  # 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
658
877
  if opts[:outcome_summary]
659
878
  intent_file = Savepoint.intent_file(intent_dir)
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-call-budget (reads the PreToolUse stdin JSON payload)
6
+ #
7
+ # Intent 355, n2 (review fix n8). A node gets a call budget by kind
8
+ # (RunnerPolicy.call_cap): a cap on tool calls per attempt. RunnerDispatch
9
+ # writes the cap onto the running line as `calls=` and states it as one
10
+ # sentence in the node's own input, so the executor learns the number before
11
+ # it ever gets denied.
12
+ #
13
+ # Inside a subagent the hook input's session_id and transcript_path are the
14
+ # MAIN session's, never the subagent's own (v1 finding). So this hook caps
15
+ # ONLY a subagent call: agent_id present in the payload. A call with no
16
+ # agent_id (the main thread, a plain conversation, a live --agent session's
17
+ # own turns) is never capped and the decision returns before any file is
18
+ # touched.
19
+ #
20
+ # Which node a subagent is working is read from the subagent's OWN
21
+ # transcript, at <dirname(transcript_path)>/<session_id>/subagents/
22
+ # agent-<agent_id>.jsonl (the main transcript_path plus the main session_id
23
+ # plus the subagent's own agent_id): its first user record carries the
24
+ # dispatch prompt, and the lead/spawn block always pastes the node's packet
25
+ # path into that prompt (PACKET_PATH_RE). The packet path names the intent
26
+ # directory directly (its grandparent), so this hook never scans the store to
27
+ # find it. No packet path in that first prompt means allow.
28
+ #
29
+ # The cap applies only when that node's LATEST transition line in
30
+ # <intent>/savepoint.md is `running` and carries `calls=`; a later `done` or
31
+ # `failed_verification` line (or a sibling node's own running line) means no
32
+ # cap. Past the cap, a Bash call that runs only a safe git verb (add, commit,
33
+ # status, rev-parse, log) is still allowed, so the denial the hook itself
34
+ # orders (commit what is green) is never the thing the hook blocks.
35
+ #
36
+ # Pure and dependency-injected down to the paths it reads; the trailing block
37
+ # guarded by `$PROGRAM_NAME == __FILE__` is this file's only I/O (stdin,
38
+ # stdout), so a test can `load` this file and call HookCallBudget's module
39
+ # functions directly without spawning a process or touching stdin.
40
+
41
+ require "json"
42
+ require_relative "lib/node_ledger"
43
+ require_relative "lib/savepoint"
44
+
45
+ module HookCallBudget
46
+ module_function
47
+
48
+ RETURN_INSTRUCTION = "commit what is green and return failed_verification reason=call_budget"
49
+
50
+ # A node id, then "--a", then an attempt number, inside a /packets/
51
+ # directory: the exact shape RunnerDispatch's spawn block pastes into a
52
+ # subagent's own first prompt. Captures [full_path, node].
53
+ PACKET_PATH_RE = %r{(\S+/packets/([a-z]{1,2}\d+)--a\d+\.packet)}.freeze
54
+
55
+ FIRST_RECORD_SCAN_LIMIT = 20
56
+
57
+ GIT_ALLOWED_VERBS = %w[add commit status rev-parse log].freeze
58
+ GIT_UNSAFE_RE = /[;&|\n]|\$\(|`/.freeze
59
+
60
+ # The one decision: nil (allow, no output) or a deny payload Hash. Never
61
+ # raises across its own boundary.
62
+ def decide(payload)
63
+ return nil unless payload.is_a?(Hash)
64
+
65
+ agent_id = payload["agent_id"]
66
+ return nil if agent_id.to_s.strip.empty?
67
+
68
+ session_id = payload["session_id"].to_s
69
+ transcript_path = payload["transcript_path"].to_s
70
+ return nil if session_id.empty? || transcript_path.empty?
71
+
72
+ sub_path = subagent_transcript_path(transcript_path, session_id, agent_id)
73
+
74
+ prompt = begin
75
+ first_user_prompt_text(sub_path)
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ return nil unless prompt
80
+
81
+ match = PACKET_PATH_RE.match(prompt)
82
+ return nil unless match
83
+
84
+ node = match[2]
85
+ intent_dir = File.dirname(File.dirname(match[1]))
86
+
87
+ cap = node_cap(intent_dir, node)
88
+ return nil unless cap
89
+
90
+ count = begin
91
+ count_tool_use_blocks(sub_path)
92
+ rescue StandardError => e
93
+ note_unreadable(intent_dir, session_id, e.message)
94
+ return nil
95
+ end
96
+
97
+ return nil if count <= cap
98
+ return nil if git_only_bash_call?(payload)
99
+
100
+ deny_payload("#{node} is past its call budget (#{count}/#{cap} tool calls); #{RETURN_INSTRUCTION}")
101
+ end
102
+
103
+ def subagent_transcript_path(main_transcript_path, session_id, agent_id)
104
+ File.join(File.dirname(main_transcript_path), session_id, "subagents", "agent-#{agent_id}.jsonl")
105
+ end
106
+
107
+ # The first `"type":"user"` record in `path`'s own transcript, rendered to
108
+ # plain text (message.content as a string, or the joined text blocks of a
109
+ # content array). Scans at most FIRST_RECORD_SCAN_LIMIT lines: the dispatch
110
+ # prompt is always the transcript's opening record, so this never touches
111
+ # the bulk of a long-running subagent's history. Raises on a genuinely
112
+ # unreadable path (a directory, a permission error); the caller decides
113
+ # what that means.
114
+ def first_user_prompt_text(path)
115
+ File.open(path) do |f|
116
+ f.each_line.first(FIRST_RECORD_SCAN_LIMIT).each do |raw|
117
+ record = begin
118
+ JSON.parse(raw)
119
+ rescue JSON::ParserError
120
+ next
121
+ end
122
+ next unless record.is_a?(Hash) && record["type"] == "user"
123
+
124
+ return message_text(record)
125
+ end
126
+ end
127
+ nil
128
+ end
129
+
130
+ def message_text(record)
131
+ content = record.dig("message", "content")
132
+ case content
133
+ when String
134
+ content
135
+ when Array
136
+ content.select { |b| b.is_a?(Hash) && b["type"] == "text" }.map { |b| b["text"].to_s }.join("\n")
137
+ else
138
+ ""
139
+ end
140
+ end
141
+
142
+ # The cap named on `node`'s LATEST (file-order) non-torn transition line in
143
+ # `intent_dir`/savepoint.md, or nil when that line is not `running`, carries
144
+ # no `calls=`, or does not exist at all (matrix 2.6/2.6b: a later done/
145
+ # failed_verification line, or a sibling node's own running line, never
146
+ # caps this node).
147
+ def node_cap(intent_dir, node)
148
+ path = File.join(intent_dir, "savepoint.md")
149
+ return nil unless File.exist?(path)
150
+
151
+ latest = NodeLedger.entries(path).select { |e| !e[:torn] && e[:subject] == node }.last
152
+ return nil unless latest && latest[:state] == "running"
153
+
154
+ cap = (latest[:fields] || {})["calls"]
155
+ cap ? cap.to_i : nil
156
+ end
157
+
158
+ # Streams `path` a line at a time, counting `"type":"tool_use"` content
159
+ # blocks with a plain regex rather than a full JSON parse per line (matrix
160
+ # 2.5/2.8): a 3 MB transcript counts in well under 50 ms this way.
161
+ def count_tool_use_blocks(path)
162
+ count = 0
163
+ File.foreach(path) { |ln| count += ln.scan(/"type"\s*:\s*"tool_use"/).length }
164
+ count
165
+ end
166
+
167
+ # Past the cap, a Bash call is still allowed when its command runs only one
168
+ # safe git verb (matrix 2.7/D3): the git add/commit the denial itself
169
+ # orders must never be the call the hook blocks. Any chaining token
170
+ # (`;`, `&`, `|`, a newline, a subshell or a backtick) refuses the whole
171
+ # command, so a compound command can never smuggle an unrelated call past
172
+ # the cap riding on a leading `git`.
173
+ def git_only_bash_call?(payload)
174
+ return false unless payload["tool_name"].to_s == "Bash"
175
+
176
+ command = payload.dig("tool_input", "command").to_s.strip
177
+ return false if command.empty? || GIT_UNSAFE_RE.match?(command)
178
+
179
+ tokens = command.split(/\s+/)
180
+ return false unless tokens.shift == "git"
181
+
182
+ tokens.shift(2) if tokens.first == "-C"
183
+ GIT_ALLOWED_VERBS.include?(tokens.first)
184
+ end
185
+
186
+ def deny_payload(reason)
187
+ { "hookSpecificOutput" => { "hookEventName" => "PreToolUse", "permissionDecision" => "deny",
188
+ "permissionDecisionReason" => reason } }
189
+ end
190
+
191
+ # Best-effort note that the transcript could not be counted (matrix 2.9): a
192
+ # plain milestone line, the same shared primitive Commit/Review/Report
193
+ # lines already use, dedup'd on (stage, milestone) so a repeat failure in
194
+ # the same session with the same message is not written twice.
195
+ def note_unreadable(intent_dir, session_id, message, now: Time.now)
196
+ Savepoint.append_savepoint_line(intent_dir, "CallBudget", "transcript unreadable for #{session_id}: #{message}",
197
+ now)
198
+ rescue StandardError
199
+ nil
200
+ end
201
+ end
202
+
203
+ if $PROGRAM_NAME == __FILE__
204
+ raw = begin
205
+ $stdin.read
206
+ rescue StandardError
207
+ nil
208
+ end
209
+
210
+ if raw && !raw.strip.empty?
211
+ payload = begin
212
+ JSON.parse(raw)
213
+ rescue StandardError
214
+ nil
215
+ end
216
+
217
+ result = HookCallBudget.decide(payload)
218
+ puts JSON.generate(result) if result
219
+ end
220
+
221
+ exit 0
222
+ end
@@ -5,12 +5,11 @@
5
5
  # Usage: hook-capture (reads the UserPromptSubmit stdin JSON payload)
6
6
  # Intent 298. One UserPromptSubmit process replacing hook-continue,
7
7
  # hook-future-intent-check, and hook-auto-arm: it appends a pending line to
8
- # the session day ledger, detects "continue" and "auto" prompts, and hints at
9
- # matching Future intents. Every job runs in its own rescue and the hook
10
- # always exits 0 (spec D2).
8
+ # the session day ledger and detects "continue" and "auto" prompts. Every job
9
+ # runs in its own rescue and the hook always exits 0 (spec D2). Intent 345
10
+ # (D7, 323) removed the per-prompt Future-intent hint step entirely.
11
11
 
12
12
  require "json"
13
- require "yaml"
14
13
  require "open3"
15
14
  require "fileutils"
16
15
  require_relative "lib/session_ledger"
@@ -31,7 +30,8 @@ end
31
30
  exit 0 unless payload.is_a?(Hash)
32
31
 
33
32
  session_id = payload["session_id"].to_s
34
- prompt = payload["user_prompt"].to_s
33
+ prompt = payload["prompt"].to_s
34
+ prompt = payload["user_prompt"].to_s if prompt.empty?
35
35
  cwd = payload["cwd"].to_s
36
36
  cwd = Dir.pwd if cwd.empty?
37
37
 
@@ -41,98 +41,26 @@ templates = File.expand_path("../templates", __dir__)
41
41
  sid = SessionLedger.short_session_id(nil, session_id)
42
42
  today = SessionLedger.day_id
43
43
 
44
- # --- Extracted matching logic, shared by both the global and project store
45
- # passes of job (f) below (mirrors hook-future-intent-check verbatim). ---
46
- def future_intent_matches(store_root, message)
47
- index_path = File.join(store_root, "INDEX.md")
48
- return [] unless File.exist?(index_path)
49
-
50
- lines = File.readlines(index_path)
51
- future_dirs = []
52
- section = nil
53
-
54
- lines.each do |line|
55
- section = :future if line.start_with?("## Future")
56
- section = nil if line.start_with?("## ") && !line.start_with?("## Future")
57
- next unless section == :future && line.strip.start_with?("- [")
58
-
59
- future_dirs << $1 if line =~ /store\/([\w-]+)\//
60
- end
61
- return [] if future_dirs.empty?
62
-
63
- matches = []
64
- message_words = message.split(/\W+/).reject { |w| w.length < 4 }
65
-
66
- future_dirs.each do |dir|
67
- intent_path = File.join(store_root, "store", dir, "#{dir}.md")
68
- next unless File.exist?(intent_path)
69
-
70
- content = File.read(intent_path)
71
-
72
- tags = []
73
- tags = $1.split(",").map(&:strip).map(&:downcase) if content =~ /^tags:\s*\[([^\]]+)\]/
74
-
75
- intent_name = ""
76
- intent_name = $1.downcase if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
77
-
78
- keywords = (tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }).map(&:downcase).uniq
79
-
80
- matched_keywords = keywords.select { |kw| message.include?(kw) }
81
- name_matches = message_words.select { |w| intent_name.include?(w) }
82
- matched_keywords = (matched_keywords + name_matches).uniq
83
-
84
- next if matched_keywords.empty?
85
-
86
- index_line = lines.find { |l| l.include?(dir) }&.strip || "#{dir} - #{intent_name}"
87
- matches << { "index_line" => index_line, "keywords" => matched_keywords }
88
- end
89
- matches
90
- end
91
-
92
- # The store_root that carries the current project's own store, resolved from
93
- # cwd through projects.yml, mirroring SessionLedger.project_slug's matching
94
- # but returning nil (not "global") when nothing matches, since there is then
95
- # no distinct second store to hint against.
96
- def resolve_project_store_root(cwd, plastic_home)
97
- projects_path = File.join(plastic_home, "projects.yml")
98
- return nil unless File.exist?(projects_path)
99
-
100
- data = begin
101
- YAML.safe_load(File.read(projects_path))
102
- rescue StandardError
103
- nil
104
- end
105
- data = {} unless data.is_a?(Hash)
106
- projects = data["projects"].is_a?(Hash) ? data["projects"] : {}
107
- expanded_cwd = File.expand_path(cwd)
108
-
109
- matches = projects.filter_map do |slug, info|
110
- next unless info.is_a?(Hash) && slug.is_a?(String)
111
-
112
- path = info["path"]
113
- next unless path
114
-
115
- root = File.expand_path(path)
116
- next unless expanded_cwd == root || expanded_cwd.start_with?("#{root}#{File::SEPARATOR}")
117
-
118
- [root.length, slug]
119
- end
120
-
121
- best = matches.max_by { |(length, _slug)| length }
122
- best ? File.join(plastic_home, "projects", best[1]) : nil
123
- end
124
-
125
44
  def truncate(text, max)
126
45
  return text if text.length <= max
127
46
 
128
47
  "#{text[0, max - 3]}..."
129
48
  end
130
49
 
131
- # --- (a) tmp root + heartbeat, unconditional -------------------------------
50
+ # --- (a) tmp root + heartbeat, guarded on the pointer (spec D9) ------------
51
+ # Session start writes the `current` pointer; a capture with no pointer yet
52
+ # means session start never ran for this session at all (a `-p` print
53
+ # session, a resumed background job, an unregistered SessionStart hook), so
54
+ # neither the per-session dir nor its heartbeat is created. UserPromptSubmit
55
+ # fires before PostToolUse, so capture is the FIRST creator of every orphan
56
+ # `.tmp/<sid>/` this guard prevents; hook-record carries the same guard for
57
+ # its own (later) heartbeat write.
132
58
  begin
133
- SessionLedger.ensure_tmp_root(store)
134
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
135
- File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
59
+ if File.exist?(SessionLedger.pointer_path(store, sid))
60
+ SessionLedger.ensure_tmp_root(store)
61
+ FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
62
+ File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
63
+ end
136
64
  rescue StandardError
137
65
  nil
138
66
  end
@@ -150,11 +78,14 @@ rescue StandardError
150
78
  end
151
79
 
152
80
  # --- (c) pending checklist line -------------------------------------------
81
+ # Gated on SessionLedger.capture_worthy?(prompt) (spec D2), a pure predicate
82
+ # over the RAW prompt so it sees the true first character and multi-line
83
+ # shape; the line text itself is still the sanitized, truncated copy.
153
84
  unless skip_day_ledger
154
85
  begin
155
- project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
156
- clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
157
- if clean.length >= 10
86
+ if SessionLedger.capture_worthy?(prompt)
87
+ project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
88
+ clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
158
89
  SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
159
90
  line = SessionLedger.checklist_line(:pending, sid, project, clean)
160
91
  SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
@@ -170,7 +101,7 @@ system_message = nil
170
101
 
171
102
  # --- (d) "continue" cockpit -------------------------------------------------
172
103
  begin
173
- if prompt.match?(/\bcontinue\b/i)
104
+ if prompt.to_s.strip.downcase == "continue"
174
105
  dashboard = File.expand_path("dashboard.rb", __dir__)
175
106
  if File.exist?(dashboard)
176
107
  cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
@@ -211,25 +142,6 @@ rescue StandardError
211
142
  nil
212
143
  end
213
144
 
214
- # --- (f) Future-intent hint, global store then the project's own store -----
215
- begin
216
- message = prompt.to_s.downcase
217
- if message.strip.length >= 10 && message.strip != "continue"
218
- matches = future_intent_matches(plastic_home, message)
219
- project_root = resolve_project_store_root(cwd, plastic_home)
220
- matches += future_intent_matches(project_root, message) if project_root
221
-
222
- if matches.any?
223
- parts = ["PLASTIC - Future intents related to this message:\n"]
224
- matches.each { |m| parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})" }
225
- parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
226
- context_parts << parts.join("\n")
227
- end
228
- end
229
- rescue StandardError
230
- nil
231
- end
232
-
233
145
  exit 0 if context_parts.empty?
234
146
 
235
147
  payload_out = {