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

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 (45) hide show
  1. package/PLASTIC.md +20 -18
  2. package/bin/test +24 -4
  3. package/hooks/call-budget +4 -0
  4. package/hooks/hooks.json +12 -0
  5. package/package.json +1 -1
  6. package/scripts/doctor.rb +79 -4
  7. package/scripts/hook-call-budget +222 -0
  8. package/scripts/hook-session-start +307 -319
  9. package/scripts/insight-append +18 -4
  10. package/scripts/lib/compact_instructions.rb +5 -5
  11. package/scripts/lib/doctor_core.rb +2 -1
  12. package/scripts/lib/graph_edges.rb +16 -0
  13. package/scripts/lib/hook_registry.rb +14 -2
  14. package/scripts/lib/installer_core.rb +13 -5
  15. package/scripts/lib/meter_watch.rb +179 -0
  16. package/scripts/lib/node_packet.rb +27 -5
  17. package/scripts/lib/runner_dispatch.rb +29 -5
  18. package/scripts/lib/runner_policy.rb +31 -0
  19. package/scripts/lib/runner_proposals.rb +21 -0
  20. package/scripts/lib/session_usage.rb +190 -0
  21. package/scripts/meter-watch +57 -0
  22. package/scripts/read-config +3 -3
  23. package/scripts/runner +5 -0
  24. package/scripts/session-usage +56 -0
  25. package/scripts/skill-lint +115 -6
  26. package/skills/auto/SKILL.md +61 -63
  27. package/skills/auto/references/agent-architecture.md +10 -8
  28. package/skills/auto/references/human-report-contract.md +1 -1
  29. package/skills/conventions/references/completion-and-done.md +7 -7
  30. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  31. package/skills/conventions/references/maintenance-and-revisions.md +1 -1
  32. package/skills/doctor/report.md +1 -1
  33. package/skills/intent-continuing/references/boarding-matrix.md +2 -2
  34. package/skills/intent-creating/SKILL.md +58 -133
  35. package/skills/intent-ending/SKILL.md +48 -56
  36. package/skills/intent-ending/evals/evals.json +1 -1
  37. package/skills/intent-executing/SKILL.md +39 -134
  38. package/skills/intent-speccing/SKILL.md +3 -0
  39. package/skills/releasing/SKILL.md +1 -1
  40. package/skills/releasing/references/release-lines.md +1 -1
  41. package/skills/tutorial/SKILL.md +2 -1
  42. package/skills/tutorial/references/track-1-guided.md +21 -40
  43. package/skills/tutorial/references/track-2-auto.md +2 -2
  44. package/templates/agents.md +2 -2
  45. package/templates/config.yml +3 -3
package/PLASTIC.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **This file is maintained by Plastic.** It is overwritten on update; do not edit it. Project
4
4
  > rules go in `AGENTS.md`. Deeper doctrine lives in the six `plastic-conventions` chapters
5
5
  > (`references/<chapter>.md`: knowledge-graph, lifecycle-and-savepoints, locks-and-worktrees,
6
- > completion-and-done, maintenance-and-revisions, roadmaps), read on demand.
6
+ > completion-and-done, maintenance-and-revisions, roadmaps).
7
7
 
8
8
  ## Two Modes, Plus Auto
9
9
 
@@ -43,20 +43,20 @@ IDs follow Luhmann's alternating convention (`1`, `1a`, `1a1`, `1a1a`); siblings
43
43
 
44
44
  ## The Record: Stages as its Shape
45
45
 
46
- | Stage | Section | Deliverable | Skill |
47
- |---|---|---|---|
48
- | **What** | `## Intent` | `{ID}--{slug}.md` | `plastic-intent-creating` |
49
- | **Why** | `## Context` + Decisions | `spec.md` | `plastic-intent-speccing` (thinking mode) |
50
- | **How** | `checklist.md` + `actions/` | `plan.md` | `plastic-intent-speccing` writes the action files |
51
- | **Exec** | `## Outcome` | `outcome.md` | `plastic-intent-executing`; `plastic-intent-ending` closes |
46
+ | Stage | Section | Skill |
47
+ |---|---|---|
48
+ | **What** | `## Intent` | `plastic-intent-creating` |
49
+ | **Why** | `## Context` + Decisions | `plastic-intent-speccing` (optional) |
50
+ | **How** | `graph.md` + `nodes/` | `plastic-intent-speccing` |
51
+ | **Exec** | `## Outcome` | `plastic-intent-executing` drives the runner loop; `plastic-intent-ending` closes |
52
52
 
53
- Stages are the shape of the record, not checkpoints: nothing blocks a write. Only "how to
54
- execute" (a checklist item plus its action) must exist before work; the rest is backfilled at
55
- intent end from what was recorded while working.
53
+ Stages are the shape of the record, not checkpoints: nothing blocks a write. Work runs on
54
+ `graph.md` and `nodes/`, driven by `runner step` (dispatch), `runner status` (read the
55
+ ledger), and `runner answer` (close a `needs_decision` node). `spec.md` stays optional;
56
+ `plan.md` and `checklist.md` exist only for a legacy intent with no `graph.md`.
56
57
 
57
58
  Invoke a skill for your harness: Claude Code uses the slash form (`/plastic-intent-creating`);
58
- Codex CLI uses a dollar prefix instead (`$plastic-intent-creating`), and may also select a skill
59
- implicitly by matching its description.
59
+ Codex CLI uses a dollar prefix instead (`$plastic-intent-creating`), and may also select a skill implicitly by matching its description.
60
60
 
61
61
  `## Insights` is the append-only log of durable discoveries from every stage, newest at the
62
62
  bottom, never prepended, each entry prefixed `{utc-iso8601} · {stage} · {author}`. Write
@@ -71,8 +71,8 @@ An auto team is `plastic-enforcer` (the lead) plus `plastic-executor`; models li
71
71
  Twenty skills, each `plastic-<name>`, listed by your harness. `plastic-doctor` checks
72
72
  installation health (core, store, and full scopes). `plastic-feedback` turns a Plastic quirk,
73
73
  bug, or feature idea into a redacted local report and a prefilled GitHub issue URL; only the
74
- user submits it. If the user hits one, offer to invoke the plastic-feedback skill yourself
75
- instead of waiting to be asked. A release is a collection of intents; `plastic-releasing`
74
+ user submits it. If the user hits one, offer to invoke the plastic-feedback skill yourself.
75
+ A release is a collection of intents; `plastic-releasing`
76
76
  runs the flow. A roadmap is an ordered, delivery-side collection; `plastic-roadmap` owns it.
77
77
 
78
78
  ## State System
@@ -108,8 +108,10 @@ the store root, so the id allocator and every 1.x store walker skip them.
108
108
  2. Artifacts go in the intent directory, never in `docs/plans/` or `docs/specs/`: code goes in
109
109
  the project, everything else in the intent. Capture observations in `## Insights`; at the
110
110
  end write `outcome.md` and `## Outcome`, and update INDEX.md.
111
- 3. State is derived from what exists: `outcome.md` present means done, so never write it
112
- before the checklist is fully checked. Status lives on checklist items, not intents.
111
+ 3. State is derived from what exists: `outcome.md` present means the intent is closed, so
112
+ never write it before every node in `graph.md` reaches a terminal status (checklist.md
113
+ fully checked, for a legacy intent). Status lives on graph nodes or checklist items,
114
+ never on intents.
113
115
  4. The global store is never pushed (`~/.plastic/` holds sensitive data); agent-created repos
114
116
  are private by default (`gh repo create --private`).
115
117
  5. Intents are created only via `plastic-intent-creating`, never hand-authored. It
@@ -137,5 +139,5 @@ treating work as new. Enola, or Serena when Enola is absent: prefer its symbol r
137
139
  Locks and worktrees exist only for auto teams. One team develops an intent at a time: a
138
140
  session-keyed `delivery.lock` in the intent directory, alive while its mtime lease is fresh.
139
141
  Code edits happen in the intent's worktree (`<repo>/.claude/worktrees/{id}--{slug}`, branch
140
- `plastic/{id}--{slug}`). "Done" is three signals that agree: INDEX `## Completed` or
141
- `## Abandoned`, `outcome.md`, and the savepoint `Done` line; INDEX wins on conflict.
142
+ `plastic/{id}--{slug}`). A terminal intent is three signals that agree: INDEX
143
+ `## Completed`/`## Abandoned`, `outcome.md`, and the terminal savepoint line; INDEX wins.
package/bin/test CHANGED
@@ -2,7 +2,10 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # Runs the FULL Minitest suite in a single process.
5
+ # Runs the Minitest suite in a single process, either the whole suite or a
6
+ # named subset via --only (intent 355, n4, D5). Every run, named or full,
7
+ # goes through FailuresReporter, so a green run costs one line and a red run
8
+ # prints only the failures plus that line, never a dot per test.
6
9
  #
7
10
  # `release.verify` used to be `ruby -Itest test/*_test.rb`, but Ruby runs only
8
11
  # the first glob-expanded file as the program — the rest land in ARGV and are
@@ -12,17 +15,34 @@
12
15
  # loaded.
13
16
 
14
17
  root = File.expand_path("..", __dir__)
15
-
16
- files = Dir.glob(File.join(root, "test", "**", "*_test.rb")).sort
17
- abort "No test files found under #{File.join(root, "test")}" if files.empty?
18
+ discover = -> { Dir.glob(File.join(root, "test", "**", "*_test.rb")).sort }
18
19
 
19
20
  # `bin/test --list` prints the discovered files without running them, so the
20
21
  # discovery logic can be tested cheaply (no full-suite execution).
21
22
  if ARGV.delete("--list")
23
+ files = discover.call
24
+ abort "No test files found under #{File.join(root, "test")}" if files.empty?
22
25
  puts files.map { |f| f.sub("#{root}/", "") }
23
26
  exit 0
24
27
  end
25
28
 
29
+ only_index = ARGV.index("--only")
30
+ if only_index
31
+ named = ARGV.slice!(only_index..-1)
32
+ named.shift
33
+ abort "--only requires at least one test file" if named.empty?
34
+ files = named.map { |f| File.expand_path(f, root) }
35
+ missing = files.reject { |f| File.exist?(f) }
36
+ unless missing.empty?
37
+ warn "no such test file: #{missing.map { |f| f.sub("#{root}/", "") }.join(', ')}"
38
+ exit 2
39
+ end
40
+ else
41
+ files = discover.call
42
+ abort "No test files found under #{File.join(root, "test")}" if files.empty?
43
+ end
44
+
26
45
  $LOAD_PATH.unshift File.join(root, "test")
46
+ require_relative "../test/lib/failures_reporter"
27
47
  require "minitest/autorun"
28
48
  files.each { |f| require f }
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ echo "$INPUT" | env -u RUBYOPT ruby --disable-gems "$SCRIPT_DIR/../scripts/hook-call-budget"
package/hooks/hooks.json CHANGED
@@ -17,6 +17,18 @@
17
17
  ]
18
18
  }
19
19
  ],
20
+ "PreToolUse": [
21
+ {
22
+ "matcher": "",
23
+ "hooks": [
24
+ {
25
+ "type": "command",
26
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" call-budget",
27
+ "statusMessage": ""
28
+ }
29
+ ]
30
+ }
31
+ ],
20
32
  "PreCompact": [
21
33
  {
22
34
  "matcher": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.20",
3
+ "version": "2.0.0-alpha.22",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -631,6 +631,11 @@ class Doctor
631
631
  # ReadySet's own require chain is 65,648 bytes, far past that budget.
632
632
  checks.concat(node_graph_checks(intent_dirs))
633
633
 
634
+ # unpromoted_rules (intent 341, G8, C37): an Insights entry tagged
635
+ # `rule:` (via `insight-append --rule`) is a promise the rule will make
636
+ # it into project doctrine. Advisory only.
637
+ checks.concat(unpromoted_rules_checks(intent_dirs, home: Dir.home))
638
+
634
639
  # cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
635
640
  # `store:id` ref against the FULL store family via the relocation map
636
641
  # (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
@@ -795,7 +800,11 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
795
800
  # written from the record by `scaffold-intent backfill`, so this is repairable and
796
801
  # reported as a fixable warn (backfilled_complete). Its exclusions go to their OWN
797
802
  # bucket so savepoint_operational's consumed/dead-row bookkeeping above never sees them.
798
- backfill_gaps = %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
803
+ # A graph intent (real graph.md on disk) never carries spec.md or plan.md by design
804
+ # (D1, no ceremonies, intent 341): the graph IS the spec and the plan, so a missing one
805
+ # is never a gap here.
806
+ is_graph_intent = File.exist?(File.join(dir, "graph.md"))
807
+ backfill_gaps = is_graph_intent ? [] : %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
799
808
  # Name whichever directory the intent actually used (post-execution
800
809
  # review, non-blocking 8), consistent with Savepoint.missing_for_stage:
801
810
  # a nodes/ directory on disk means the intent chose the node-graph
@@ -853,7 +862,7 @@ def check_done_signals(scopes: nil)
853
862
  projection = IndexProjection.analyze(store[:store_dir], index_path: store[:index])
854
863
  projection[:drift].each do |row|
855
864
  index_drift << "#{store[:scope]}: #{row[:id]} - INDEX says #{row[:index_status]}, " \
856
- "the ledger's last Done line says #{row[:ledger_status]}"
865
+ "the ledger's last line says #{row[:ledger_status]}"
857
866
  end
858
867
  end
859
868
 
@@ -1097,7 +1106,7 @@ def check_done_signals(scopes: nil)
1097
1106
  fix_hint: "For a live (Active) intent, rebuild the ledger via " \
1098
1107
  "Savepoint.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
1099
1108
  "a phantom there stays advisory unless an explicit human grant authorizes the " \
1100
- "124a manual Done-bookend repair."
1109
+ "124a manual terminal-bookend repair."
1101
1110
  )
1102
1111
  end
1103
1112
 
@@ -1294,9 +1303,13 @@ end
1294
1303
  end
1295
1304
 
1296
1305
  INTENT_END_LIFECYCLE_FILES = %w[spec.md plan.md checklist.md outcome.md].freeze
1306
+ # A graph intent (D1, no ceremonies, intent 341) never carries spec.md, plan.md, or
1307
+ # checklist.md by design; the graph IS the spec and the plan. outcome.md stays mandatory.
1308
+ GRAPH_INTENT_LIFECYCLE_FILES = %w[outcome.md].freeze
1297
1309
 
1298
1310
  def intent_lifecycle_artifacts_check(intent_dir, disposition)
1299
- missing = INTENT_END_LIFECYCLE_FILES.select { |f| !Savepoint.stage_file_present?(File.join(intent_dir, f)) }
1311
+ files = File.exist?(File.join(intent_dir, "graph.md")) ? GRAPH_INTENT_LIFECYCLE_FILES : INTENT_END_LIFECYCLE_FILES
1312
+ missing = files.select { |f| !Savepoint.stage_file_present?(File.join(intent_dir, f)) }
1300
1313
  unless Savepoint.stage_file_present?(Savepoint.intent_file(intent_dir))
1301
1314
  missing = [File.basename(Savepoint.intent_file(intent_dir))] + missing
1302
1315
  end
@@ -1774,6 +1787,68 @@ end
1774
1787
  end
1775
1788
  end
1776
1789
 
1790
+ # --- Check: unpromoted rule: findings (intent 341, G8, C37) ---------------
1791
+ #
1792
+ # `insight-append --rule` tags an entry "... - rule: <text>". A tag is a
1793
+ # promise the rule will make it into project doctrine; until the exact
1794
+ # rule text shows up in some skills/conventions/references/*.md chapter,
1795
+ # it is only visible to a session that happens to read this one intent
1796
+ # file, and the next session repeats the mistake the rule names. Advisory
1797
+ # only (warn, never fail): a freshly tagged rule is not yet promoted by
1798
+ # design, and nothing here can auto-promote it (that is an editorial call,
1799
+ # not a mechanical one).
1800
+ RULE_ENTRY_RE = /—\s*rule:\s*(.+?)\s*\z/.freeze
1801
+
1802
+ def unpromoted_rules_checks(intent_dirs, package_root: PACKAGE_ROOT, home: nil)
1803
+ # The installed doctor runs from ~/.plastic/scripts, so PACKAGE_ROOT
1804
+ # (~/.plastic) has no skills/ directory: the conventions chapters install
1805
+ # to the agent home layout instead. `home` is caller-injected (never an
1806
+ # ENV read here) so this stays hermetic in tests; the real call site
1807
+ # passes the process's actual home directory.
1808
+ chapter_dirs = []
1809
+ if home
1810
+ chapter_dirs << File.join(home, ".claude", "skills", "plastic-conventions", "references")
1811
+ chapter_dirs << File.join(home, ".agents", "skills", "plastic-conventions", "references")
1812
+ end
1813
+ chapter_dirs << File.join(package_root, "skills", "conventions", "references")
1814
+
1815
+ chapters_text = chapter_dirs.select { |d| Dir.exist?(d) }
1816
+ .flat_map { |d| Dir.glob(File.join(d, "*.md")) }
1817
+ .map { |f| File.read(f) }
1818
+ .join("\n\n")
1819
+
1820
+ unpromoted = []
1821
+ intent_dirs.each do |d|
1822
+ md_path = File.join(d[:path], "#{d[:name]}.md")
1823
+ next unless File.exist?(md_path)
1824
+
1825
+ File.readlines(md_path).each do |line|
1826
+ m = line.chomp.match(RULE_ENTRY_RE)
1827
+ next unless m
1828
+
1829
+ rule_text = m[1].strip
1830
+ next if rule_text.empty?
1831
+
1832
+ unpromoted << "#{tilde(d[:path])}: #{rule_text}" unless chapters_text.include?(rule_text)
1833
+ end
1834
+ end
1835
+
1836
+ if unpromoted.empty?
1837
+ [check(
1838
+ category: "conventions", name: "unpromoted_rules", status: "pass",
1839
+ message: "Every tagged rule: finding is carried by a conventions chapter"
1840
+ )]
1841
+ else
1842
+ [check(
1843
+ category: "conventions", name: "unpromoted_rules", status: "warn",
1844
+ message: "#{unpromoted.size} tagged rule(s) not yet carried by any conventions chapter",
1845
+ details: unpromoted, fixable: false,
1846
+ fix_hint: "Promote the rule into the right skills/conventions/references/*.md chapter, " \
1847
+ "or drop the tag if the finding does not belong in doctrine"
1848
+ )]
1849
+ end
1850
+ end
1851
+
1777
1852
  # --- Check category 3: Agent registration ---
1778
1853
 
1779
1854
 
@@ -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