@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.
- package/PLASTIC.md +20 -18
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +79 -4
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-session-start +307 -319
- package/scripts/insight-append +18 -4
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/doctor_core.rb +2 -1
- package/scripts/lib/graph_edges.rb +16 -0
- package/scripts/lib/hook_registry.rb +14 -2
- package/scripts/lib/installer_core.rb +13 -5
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_packet.rb +27 -5
- package/scripts/lib/runner_dispatch.rb +29 -5
- package/scripts/lib/runner_policy.rb +31 -0
- package/scripts/lib/runner_proposals.rb +21 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/meter-watch +57 -0
- package/scripts/read-config +3 -3
- package/scripts/runner +5 -0
- package/scripts/session-usage +56 -0
- package/scripts/skill-lint +115 -6
- package/skills/auto/SKILL.md +61 -63
- package/skills/auto/references/agent-architecture.md +10 -8
- package/skills/auto/references/human-report-contract.md +1 -1
- package/skills/conventions/references/completion-and-done.md +7 -7
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/maintenance-and-revisions.md +1 -1
- package/skills/doctor/report.md +1 -1
- package/skills/intent-continuing/references/boarding-matrix.md +2 -2
- package/skills/intent-creating/SKILL.md +58 -133
- package/skills/intent-ending/SKILL.md +48 -56
- package/skills/intent-ending/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +39 -134
- package/skills/intent-speccing/SKILL.md +3 -0
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/SKILL.md +2 -1
- package/skills/tutorial/references/track-1-guided.md +21 -40
- package/skills/tutorial/references/track-2-auto.md +2 -2
- package/templates/agents.md +2 -2
- 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)
|
|
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 |
|
|
47
|
-
|
|
48
|
-
| **What** | `## Intent` | `
|
|
49
|
-
| **Why** | `## Context` + Decisions | `
|
|
50
|
-
| **How** | `
|
|
51
|
-
| **Exec** | `## Outcome` | `
|
|
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.
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
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}`).
|
|
141
|
-
`## Abandoned`, `outcome.md`, and the savepoint
|
|
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
|
|
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 }
|
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
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|