@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/scripts/insight-append
CHANGED
|
@@ -11,8 +11,16 @@
|
|
|
11
11
|
# `now:` seam is the test seam; the CLI uses the default Time.now, which is fine
|
|
12
12
|
# because determinism is covered at the library level (test/insights_test.rb).
|
|
13
13
|
#
|
|
14
|
+
# --rule (intent 341, G8, C37): tags the entry as a rule, not just an
|
|
15
|
+
# observation, by prepending the literal "rule: " onto the text before it
|
|
16
|
+
# ever reaches Insights.append_insight; the library itself stays untouched,
|
|
17
|
+
# since a tag is a text-level convention, not a new field on the ledger.
|
|
18
|
+
# Doctor's unpromoted_rules check (scripts/doctor.rb) later lists any tagged
|
|
19
|
+
# entry whose exact text no skills/conventions/references/*.md chapter
|
|
20
|
+
# carries yet.
|
|
21
|
+
#
|
|
14
22
|
# Usage:
|
|
15
|
-
# insight-append <intent_dir> <text> --stage S --author A
|
|
23
|
+
# insight-append <intent_dir> <text> --stage S --author A [--rule]
|
|
16
24
|
#
|
|
17
25
|
# Exit codes: 0 (entry appended), 2 (usage).
|
|
18
26
|
|
|
@@ -21,6 +29,7 @@ require_relative "lib/insights"
|
|
|
21
29
|
def parse_args(argv)
|
|
22
30
|
stage = nil
|
|
23
31
|
author = nil
|
|
32
|
+
rule = false
|
|
24
33
|
positional = []
|
|
25
34
|
i = 0
|
|
26
35
|
while i < argv.length
|
|
@@ -31,21 +40,26 @@ def parse_args(argv)
|
|
|
31
40
|
when "--author"
|
|
32
41
|
author = argv[i + 1]
|
|
33
42
|
i += 2
|
|
43
|
+
when "--rule"
|
|
44
|
+
rule = true
|
|
45
|
+
i += 1
|
|
34
46
|
else
|
|
35
47
|
positional << argv[i]
|
|
36
48
|
i += 1
|
|
37
49
|
end
|
|
38
50
|
end
|
|
39
|
-
[positional[0], positional[1], stage, author]
|
|
51
|
+
[positional[0], positional[1], stage, author, rule]
|
|
40
52
|
end
|
|
41
53
|
|
|
42
|
-
intent_dir, text, stage, author = parse_args(ARGV)
|
|
54
|
+
intent_dir, text, stage, author, rule = parse_args(ARGV)
|
|
43
55
|
|
|
44
56
|
if [intent_dir, text, stage, author].any? { |v| v.nil? || v.to_s.empty? }
|
|
45
|
-
warn "usage: insight-append <intent_dir> <text> --stage S --author A"
|
|
57
|
+
warn "usage: insight-append <intent_dir> <text> --stage S --author A [--rule]"
|
|
46
58
|
exit 2
|
|
47
59
|
end
|
|
48
60
|
|
|
61
|
+
text = "rule: #{text}" if rule
|
|
62
|
+
|
|
49
63
|
entry = Insights.append_insight(File.expand_path(intent_dir), text,
|
|
50
64
|
stage: stage, author: author)
|
|
51
65
|
puts "appended: #{entry}"
|
|
@@ -19,9 +19,9 @@ require "digest"
|
|
|
19
19
|
#
|
|
20
20
|
# Library only: no CLI, no ENV, no I/O.
|
|
21
21
|
module CompactInstructions
|
|
22
|
-
#
|
|
23
|
-
OFFER_TOKENS =
|
|
24
|
-
INSIST_TOKENS =
|
|
22
|
+
# 15 and 25 percent of a 1M window (intent 355, n5, D7).
|
|
23
|
+
OFFER_TOKENS = 150_000
|
|
24
|
+
INSIST_TOKENS = 250_000
|
|
25
25
|
|
|
26
26
|
# Static on purpose. A body rendered from the user's config would change its hash
|
|
27
27
|
# every time they edited config.yml, and doctor would then report a correct install
|
|
@@ -32,10 +32,10 @@ module CompactInstructions
|
|
|
32
32
|
Plastic watches this session's context. When the harness reports how much of the
|
|
33
33
|
window is used:
|
|
34
34
|
|
|
35
|
-
- At
|
|
35
|
+
- At 150,000 tokens, offer to compact. Say that the hand-off in today's day ledger
|
|
36
36
|
is written and current, and take no for an answer: a task that is nearly done
|
|
37
37
|
does not need the interruption.
|
|
38
|
-
- At
|
|
38
|
+
- At 250,000 tokens, insist. Take no new work, write the hand-off in today's day
|
|
39
39
|
ledger, and compact before continuing.
|
|
40
40
|
- After a compaction, say continue. The day summary at boot and the hand-off carry
|
|
41
41
|
the state; do not rebuild it by re-reading files.
|
|
@@ -30,7 +30,8 @@ class Doctor
|
|
|
30
30
|
# The Claude events hooks_registered expects in settings.json: the six-event map of
|
|
31
31
|
# cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
|
|
32
32
|
# intent 316a added MessageDisplay, registered for message-display, Claude only).
|
|
33
|
-
CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd
|
|
33
|
+
CLAUDE_HOOK_EVENTS = %w[SessionStart PreToolUse PreCompact PostToolUse UserPromptSubmit SessionEnd
|
|
34
|
+
MessageDisplay].freeze
|
|
34
35
|
|
|
35
36
|
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
36
37
|
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|
|
@@ -118,4 +118,20 @@ module GraphEdges
|
|
|
118
118
|
visited[node] = true
|
|
119
119
|
nil
|
|
120
120
|
end
|
|
121
|
+
|
|
122
|
+
# The declared work ids whose `needs` reach a verify node without passing
|
|
123
|
+
# through another verify node (343 D7), in declaration order. `kinds` maps
|
|
124
|
+
# an id to its node kind, so this module stays blind to where a kind lives.
|
|
125
|
+
def review_fixes(edges, kinds)
|
|
126
|
+
edges.keys.select { |id| kinds[id] == "work" && reaches_verify?(id, edges, kinds, {}) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def reaches_verify?(node, edges, kinds, seen)
|
|
130
|
+
(edges[node] || []).any? do |target|
|
|
131
|
+
next false if seen[target]
|
|
132
|
+
|
|
133
|
+
seen[target] = true
|
|
134
|
+
kinds[target] == "verify" || reaches_verify?(target, edges, kinds, seen)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
121
137
|
end
|
|
@@ -34,6 +34,17 @@ module HookRegistry
|
|
|
34
34
|
{ "name" => "check-update", "status" => "" },
|
|
35
35
|
] },
|
|
36
36
|
],
|
|
37
|
+
# Intent 355, n2: the call budget guard. Not one of the edit-path
|
|
38
|
+
# gates intent 302 removed (those denied a write on content; this
|
|
39
|
+
# denies a call on a per-attempt COUNT, read from the session's own
|
|
40
|
+
# transcript, never from the tool's arguments) - see scripts/hook-
|
|
41
|
+
# call-budget. Claude only: CODEX_LIVE_STATE_EVENTS below does not
|
|
42
|
+
# carry PreToolUse, so codex_hooks_json never projects it.
|
|
43
|
+
"PreToolUse" => [
|
|
44
|
+
{ "matcher" => "", "hooks" => [
|
|
45
|
+
{ "name" => "call-budget", "status" => "" },
|
|
46
|
+
] },
|
|
47
|
+
],
|
|
37
48
|
"PreCompact" => [
|
|
38
49
|
{ "matcher" => "", "hooks" => [
|
|
39
50
|
{ "name" => "savepoint", "status" => "Saving Plastic intent state..." },
|
|
@@ -75,8 +86,9 @@ module HookRegistry
|
|
|
75
86
|
# the PostToolUse record hook collapses from Claude's multi-tool matcher onto
|
|
76
87
|
# Codex's single apply_patch tool (181 F4: apply_patch is Codex's sole
|
|
77
88
|
# file-mutation tool; tool_name always reports apply_patch), and the live-state
|
|
78
|
-
# events project through whole.
|
|
79
|
-
#
|
|
89
|
+
# events project through whole. PreToolUse (intent 355, n2's call-budget guard,
|
|
90
|
+
# Claude only) is not one of CODEX_LIVE_STATE_EVENTS below, so Codex still
|
|
91
|
+
# carries no PreToolUse group of its own. Command invokes the
|
|
80
92
|
# codex-hook dispatcher with the hook name. Guide-settled shape [guide Part 3]:
|
|
81
93
|
# top-level {"hooks":{<Event>: [{"matcher","hooks":[{"type":"command","command",
|
|
82
94
|
# "statusMessage"}]}]}}, identical to Claude's shape, string command. Single
|
|
@@ -49,9 +49,9 @@ class InstallerCore
|
|
|
49
49
|
# hand-curated pointer rather than embedding the core wholesale, and it never drifts
|
|
50
50
|
# because it only ever points, never duplicates.
|
|
51
51
|
CODEX_AGENTS_MD_BODY = <<~MD.freeze
|
|
52
|
-
Plastic is installed for this agent. Plastic is intent-driven state management:
|
|
53
|
-
|
|
54
|
-
straight to code.
|
|
52
|
+
Plastic is installed for this agent. Plastic is intent-driven state management: work runs
|
|
53
|
+
in one of three modes, direct, thinking, or auto (a team drives the runner loop:
|
|
54
|
+
`runner step`, `status`, `answer`). Do not jump straight to code.
|
|
55
55
|
|
|
56
56
|
Standing rules:
|
|
57
57
|
- Core conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly. For
|
|
@@ -438,6 +438,8 @@ class InstallerCore
|
|
|
438
438
|
"scripts/verify-intent" => "scripts/verify-intent",
|
|
439
439
|
"scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
|
|
440
440
|
"scripts/exec-worktree" => "scripts/exec-worktree",
|
|
441
|
+
"scripts/lib/session_usage.rb" => "scripts/lib/session_usage.rb",
|
|
442
|
+
"scripts/session-usage" => "scripts/session-usage",
|
|
441
443
|
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
442
444
|
"scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
|
|
443
445
|
"scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
|
|
@@ -543,6 +545,12 @@ class InstallerCore
|
|
|
543
545
|
# by scripts/runner's `step`.
|
|
544
546
|
"scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
|
|
545
547
|
"scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
|
|
548
|
+
# Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
|
|
549
|
+
# is its cap table, above); its launcher (hooks/call-budget) ships via
|
|
550
|
+
# hook_files' own glob, so only the hook script itself needs an entry.
|
|
551
|
+
"scripts/hook-call-budget" => "scripts/hook-call-budget",
|
|
552
|
+
"scripts/meter-watch" => "scripts/meter-watch",
|
|
553
|
+
"scripts/lib/meter_watch.rb" => "scripts/lib/meter_watch.rb",
|
|
546
554
|
# Intent 340 (G7, n6): answer (closes a decision node or unparks a
|
|
547
555
|
# work node parked at needs_decision), proposals (mints ids for what
|
|
548
556
|
# an executor proposed), and rewind (resets the intent branch to a
|
|
@@ -564,8 +572,8 @@ class InstallerCore
|
|
|
564
572
|
version: 3
|
|
565
573
|
execution_mode: subagent-driven
|
|
566
574
|
stale_threshold_days: 3
|
|
567
|
-
context_offer_tokens:
|
|
568
|
-
context_insist_tokens:
|
|
575
|
+
context_offer_tokens: 150000
|
|
576
|
+
context_insist_tokens: 250000
|
|
569
577
|
hash_length: 6
|
|
570
578
|
hash_algorithm: sha256-base36
|
|
571
579
|
max_slug_words: 5
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "time"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "rbconfig"
|
|
9
|
+
require_relative "atomic_write"
|
|
10
|
+
|
|
11
|
+
# MeterWatch (intent 355, n5, D6): reads the owner's rate-limit cache on a
|
|
12
|
+
# timer and writes one state file a session watches, instead of every session
|
|
13
|
+
# parsing the cache and re-deriving the thresholds for itself. No model call
|
|
14
|
+
# is spent on a tick that leaves the state unchanged.
|
|
15
|
+
#
|
|
16
|
+
# Everything is injected: home (holds config.yml and .cache/), the clock, the
|
|
17
|
+
# cache path, and the renamer AtomicWrite uses. Nothing reads ENV, nothing
|
|
18
|
+
# calls launchctl, and --install-timer (scripts/meter-watch) only ever writes
|
|
19
|
+
# under the injected home.
|
|
20
|
+
class MeterWatch
|
|
21
|
+
DEFAULT_REDUCE_AT = 55
|
|
22
|
+
DEFAULT_STOP_AT = 85
|
|
23
|
+
DEFAULT_WEEKLY_STOP_AT = 97
|
|
24
|
+
TICK_SECONDS = 20 * 60
|
|
25
|
+
STALE_AFTER_SECONDS = TICK_SECONDS * 2
|
|
26
|
+
|
|
27
|
+
def initialize(home:, cache_path: nil, config_path: nil, now: Time.now, renamer: File.method(:rename))
|
|
28
|
+
@home = home
|
|
29
|
+
@cache_path = cache_path || File.join(home, ".cache", "rate-limits.json")
|
|
30
|
+
@config_path = config_path || File.join(home, "config.yml")
|
|
31
|
+
@state_path = File.join(home, ".cache", "meter-state.json")
|
|
32
|
+
@now = now
|
|
33
|
+
@renamer = renamer
|
|
34
|
+
@reduce_at, @stop_at, @weekly_stop_at = load_thresholds
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :state_path
|
|
38
|
+
|
|
39
|
+
def tick
|
|
40
|
+
previous = read_state
|
|
41
|
+
state = compute_state(previous)
|
|
42
|
+
write_if_changed(previous, state)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def compute_state(previous)
|
|
48
|
+
return base_state("unavailable") unless File.file?(@cache_path)
|
|
49
|
+
return stopped?(previous) ? previous : base_state("stale") if stale?
|
|
50
|
+
|
|
51
|
+
cache = JSON.parse(File.read(@cache_path))
|
|
52
|
+
five_hour = cache["five_hour"]
|
|
53
|
+
seven_day = cache["seven_day"]
|
|
54
|
+
resets_at = cache["resets_at"]
|
|
55
|
+
|
|
56
|
+
# Resume compares `now` against the STOP's OWN resets_at (carried
|
|
57
|
+
# forward on `previous`, from the tick that first wrote "stop"), never
|
|
58
|
+
# the cache's current resets_at: the cache moves resets_at on to the
|
|
59
|
+
# NEXT window before five_hour/seven_day themselves drop, so comparing
|
|
60
|
+
# against the live value would never report resume (B4).
|
|
61
|
+
label = if stopped?(previous) && reset_passed?(previous["resets_at"])
|
|
62
|
+
"resume"
|
|
63
|
+
else
|
|
64
|
+
classify(five_hour, seven_day)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
base_state(label, five_hour: five_hour, seven_day: seven_day, resets_at: resets_at)
|
|
68
|
+
rescue JSON::ParserError
|
|
69
|
+
base_state("unavailable")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stopped?(previous)
|
|
73
|
+
previous && previous["state"] == "stop"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def classify(five_hour, seven_day)
|
|
77
|
+
return "stop" if five_hour.to_f >= @stop_at
|
|
78
|
+
return "stop" if seven_day.to_f >= @weekly_stop_at
|
|
79
|
+
return "reduce" if five_hour.to_f >= @reduce_at
|
|
80
|
+
|
|
81
|
+
"ok"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def stale?
|
|
85
|
+
File.mtime(@cache_path) < (@now - STALE_AFTER_SECONDS)
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def reset_passed?(resets_at)
|
|
91
|
+
at = parse_time(resets_at)
|
|
92
|
+
at && @now >= at
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse_time(value)
|
|
96
|
+
return nil if value.nil? || value.to_s.empty?
|
|
97
|
+
|
|
98
|
+
text = value.to_s
|
|
99
|
+
text.match?(/\A\d+\z/) ? Time.at(text.to_i).utc : Time.iso8601(text)
|
|
100
|
+
rescue ArgumentError, TypeError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def base_state(label, five_hour: nil, seven_day: nil, resets_at: nil)
|
|
105
|
+
{
|
|
106
|
+
"state" => label,
|
|
107
|
+
"five_hour" => five_hour,
|
|
108
|
+
"seven_day" => seven_day,
|
|
109
|
+
"resets_at" => resets_at,
|
|
110
|
+
"checked_at" => @now.getutc.iso8601,
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def write_if_changed(previous, state)
|
|
115
|
+
return state if previous && previous["state"] == state["state"]
|
|
116
|
+
|
|
117
|
+
FileUtils.mkdir_p(File.dirname(@state_path))
|
|
118
|
+
AtomicWrite.write(@state_path, JSON.generate(state), renamer: @renamer)
|
|
119
|
+
state
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def read_state
|
|
123
|
+
return nil unless File.file?(@state_path)
|
|
124
|
+
|
|
125
|
+
JSON.parse(File.read(@state_path))
|
|
126
|
+
rescue JSON::ParserError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def load_thresholds
|
|
131
|
+
config = File.file?(@config_path) ? (YAML.safe_load(File.read(@config_path)) || {}) : {}
|
|
132
|
+
meter = config["meter"].is_a?(Hash) ? config["meter"] : {}
|
|
133
|
+
[
|
|
134
|
+
meter.fetch("reduce_at", DEFAULT_REDUCE_AT),
|
|
135
|
+
meter.fetch("stop_at", DEFAULT_STOP_AT),
|
|
136
|
+
meter.fetch("weekly_stop_at", DEFAULT_WEEKLY_STOP_AT),
|
|
137
|
+
]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
class << self
|
|
141
|
+
# Writes the LaunchAgent plist under an injectable home; never loads it
|
|
142
|
+
# with launchctl (scripts/meter-watch --install-timer calls this and
|
|
143
|
+
# nothing else). RunAtLoad primes the first tick; StartInterval repeats
|
|
144
|
+
# it every 20 minutes.
|
|
145
|
+
def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS)
|
|
146
|
+
agents_dir = File.join(home, "Library", "LaunchAgents")
|
|
147
|
+
FileUtils.mkdir_p(agents_dir)
|
|
148
|
+
plist_path = File.join(agents_dir, "com.plastic.meter-watch.plist")
|
|
149
|
+
File.write(plist_path, plist(script_path, home, ruby, interval))
|
|
150
|
+
plist_path
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def plist(script_path, home, ruby, interval)
|
|
156
|
+
<<~XML
|
|
157
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
158
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
159
|
+
<plist version="1.0">
|
|
160
|
+
<dict>
|
|
161
|
+
<key>Label</key>
|
|
162
|
+
<string>com.plastic.meter-watch</string>
|
|
163
|
+
<key>ProgramArguments</key>
|
|
164
|
+
<array>
|
|
165
|
+
<string>#{ruby}</string>
|
|
166
|
+
<string>#{script_path}</string>
|
|
167
|
+
<string>--home</string>
|
|
168
|
+
<string>#{home}</string>
|
|
169
|
+
</array>
|
|
170
|
+
<key>StartInterval</key>
|
|
171
|
+
<integer>#{interval}</integer>
|
|
172
|
+
<key>RunAtLoad</key>
|
|
173
|
+
<true/>
|
|
174
|
+
</dict>
|
|
175
|
+
</plist>
|
|
176
|
+
XML
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -425,7 +425,15 @@ module NodePacket
|
|
|
425
425
|
verify.split("\n").map(&:strip).reject(&:empty?).join("; ")
|
|
426
426
|
end
|
|
427
427
|
|
|
428
|
-
|
|
428
|
+
# `files` (intent 355, n4, D5): a node's own declared `*_test.rb` files
|
|
429
|
+
# name the only test command the executor needs - `bin/test --only <those
|
|
430
|
+
# files>` - so it never has to invent one or fall back to the project's
|
|
431
|
+
# generic `release.verify`. A node that declares no test files (a docs-only
|
|
432
|
+
# node, say) still falls back to `project_reader` exactly as before.
|
|
433
|
+
def test_command_block(intent_dir:, files: [], project_reader: method(:default_project_reader))
|
|
434
|
+
named = Array(files).select { |f| f.to_s.end_with?("_test.rb") }
|
|
435
|
+
return "test command: ruby bin/test --only #{named.join(' ')}" if named.any?
|
|
436
|
+
|
|
429
437
|
cmd = project_reader.call(intent_dir)
|
|
430
438
|
cmd ? "test command: #{cmd}" : "test command: none recorded in the project record"
|
|
431
439
|
end
|
|
@@ -435,14 +443,26 @@ module NodePacket
|
|
|
435
443
|
# carries no lease. `worktree_block` already renders its own copy when the
|
|
436
444
|
# worktree is unprovisioned; the two conditions often fire together, so a
|
|
437
445
|
# directive already present is never repeated.
|
|
446
|
+
#
|
|
447
|
+
# `call_cap` (intent 355, n2, D2): one sentence naming this attempt's tool
|
|
448
|
+
# call cap and the return it hits at, so the executor learns the number
|
|
449
|
+
# from the packet it starts with, never from a denied call mid-edit
|
|
450
|
+
# (matrix 2.4). nil (a caller that names no cap) renders nothing here.
|
|
438
451
|
def where_to_work_block(intent_dir:, worktree_reader: Arm.method(:worktree_block),
|
|
439
|
-
project_reader: method(:default_project_reader), lease_missing: false
|
|
452
|
+
project_reader: method(:default_project_reader), lease_missing: false, call_cap: nil,
|
|
453
|
+
files: [])
|
|
440
454
|
wt = worktree_block(intent_dir: intent_dir, worktree_reader: worktree_reader)
|
|
441
|
-
parts = [wt, test_command_block(intent_dir: intent_dir, project_reader: project_reader)]
|
|
455
|
+
parts = [wt, test_command_block(intent_dir: intent_dir, files: files, project_reader: project_reader)]
|
|
442
456
|
parts << STOP_DIRECTIVE if lease_missing && !wt.include?(STOP_DIRECTIVE)
|
|
457
|
+
parts << call_cap_sentence(call_cap) if call_cap
|
|
443
458
|
parts.join("\n")
|
|
444
459
|
end
|
|
445
460
|
|
|
461
|
+
def call_cap_sentence(call_cap)
|
|
462
|
+
"call budget: this attempt may make at most #{call_cap} tool calls; past that a hook denies the " \
|
|
463
|
+
"next one, so commit what is green and return failed_verification reason=call_budget."
|
|
464
|
+
end
|
|
465
|
+
|
|
446
466
|
# --- section and list parsing (shared) -------------------------------------
|
|
447
467
|
|
|
448
468
|
# The body of the FIRST heading, at exactly `level` `#` characters, whose
|
|
@@ -786,7 +806,8 @@ module NodePacket
|
|
|
786
806
|
def build(intent_dir:, node:, budget_tokens: nil, hop_tokens: DEFAULT_HOP_TOKENS,
|
|
787
807
|
holder: nil, expires: nil, model: nil, attempt: nil, out: nil, force: false,
|
|
788
808
|
renamer: File.method(:rename), git_runner: DEFAULT_GIT_RUNNER,
|
|
789
|
-
worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader)
|
|
809
|
+
worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader),
|
|
810
|
+
call_cap: nil)
|
|
790
811
|
intent_dir = File.expand_path(intent_dir)
|
|
791
812
|
|
|
792
813
|
nb = node_block(intent_dir: intent_dir, node: node)
|
|
@@ -826,7 +847,8 @@ module NodePacket
|
|
|
826
847
|
# ledger data (spec D3's self-cancellation risk).
|
|
827
848
|
missing_lease = lease_missing?(node: node, holder: holder, expires: expires, model: model, entries: entries)
|
|
828
849
|
where_text = where_to_work_block(intent_dir: intent_dir, worktree_reader: worktree_reader,
|
|
829
|
-
project_reader: project_reader, lease_missing: missing_lease
|
|
850
|
+
project_reader: project_reader, lease_missing: missing_lease,
|
|
851
|
+
call_cap: call_cap, files: nb[:files])
|
|
830
852
|
|
|
831
853
|
state = {
|
|
832
854
|
node_text: nb[:text], ledger_text: ledger_text, intent_text: record[:intent],
|
|
@@ -45,6 +45,22 @@ module RunnerDispatch
|
|
|
45
45
|
|
|
46
46
|
HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
|
|
47
47
|
|
|
48
|
+
# D8 (355, n6): the agent every dispatched, non-decision node names - a
|
|
49
|
+
# role, never a harness (matrix 6.5), and never `plastic-advisor`, which
|
|
50
|
+
# stays a deliberate, never-auto-dispatched consultation agent.
|
|
51
|
+
SPAWN_AGENT = "plastic-executor"
|
|
52
|
+
|
|
53
|
+
# matrix 6.1/6.2: one spawn block per dispatched node - agent, the model
|
|
54
|
+
# RunnerPolicy.model_for resolved, the packet path, the one test command
|
|
55
|
+
# (NodePacket.test_command_block, n4), and the call cap (n2) - fenced so a
|
|
56
|
+
# session pastes it straight into the Agent tool (327 D42: the runner
|
|
57
|
+
# itself never spawns).
|
|
58
|
+
def spawn_block(model:, packet:, test_command:, call_cap:, agent: SPAWN_AGENT)
|
|
59
|
+
lines = ["agent: #{agent}", "model: #{model}", "packet: #{packet}", test_command,
|
|
60
|
+
NodePacket.call_cap_sentence(call_cap)]
|
|
61
|
+
(["```"] + lines + ["```"]).join("\n")
|
|
62
|
+
end
|
|
63
|
+
|
|
48
64
|
# dispatch(context, limit:) -> a result hash. Always carries :ok, :reason,
|
|
49
65
|
# :errors, :rearm_command, :dispatched, :stop, :parked, :status, :blockers,
|
|
50
66
|
# :plan - fields that do not apply to a given outcome stay nil/empty rather
|
|
@@ -195,6 +211,7 @@ module RunnerDispatch
|
|
|
195
211
|
holder = context.session
|
|
196
212
|
model = RunnerPolicy.model_for(kind, config: config)
|
|
197
213
|
expires = RunnerPolicy.lease_expires(kind, now: now)
|
|
214
|
+
calls_cap = RunnerPolicy.call_cap(kind, config: config)
|
|
198
215
|
|
|
199
216
|
# Row 10.16/M13: recorded BEFORE provisioning - a worktree this dispatch
|
|
200
217
|
# finds already on disk (kept there by a prior failed_verification
|
|
@@ -223,7 +240,7 @@ module RunnerDispatch
|
|
|
223
240
|
# default (row 10.9).
|
|
224
241
|
build_result = packet_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
|
|
225
242
|
model: model, force: true, worktree_reader: node_reader,
|
|
226
|
-
budget_tokens: node_declared_budget(intent_dir, node))
|
|
243
|
+
budget_tokens: node_declared_budget(intent_dir, node), call_cap: calls_cap)
|
|
227
244
|
unless build_result[:ok]
|
|
228
245
|
# M6: a failed packet build never leaves an orphan worktree behind, and
|
|
229
246
|
# its errors travel back up so the step's report can name the node and
|
|
@@ -236,7 +253,7 @@ module RunnerDispatch
|
|
|
236
253
|
precondition = lambda do |c|
|
|
237
254
|
ReadySet.ready?(content: c, subject: node, graph: { edges: edges }, nodes: nodes_decl, caps: caps)[:ready]
|
|
238
255
|
end
|
|
239
|
-
fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model }
|
|
256
|
+
fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model, calls: calls_cap }
|
|
240
257
|
|
|
241
258
|
result = begin
|
|
242
259
|
ledger.append_transition(savepoint_path, subject: node, state: "running", fields: fields, now: now,
|
|
@@ -254,10 +271,13 @@ module RunnerDispatch
|
|
|
254
271
|
return { ok: false }
|
|
255
272
|
end
|
|
256
273
|
|
|
274
|
+
test_command = NodePacket.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
|
|
275
|
+
spawn = spawn_block(model: model, packet: build_result[:path], test_command: test_command, call_cap: calls_cap)
|
|
276
|
+
|
|
257
277
|
{
|
|
258
278
|
ok: true,
|
|
259
279
|
entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, worktree: provisioned[:path],
|
|
260
|
-
packet: build_result[:path] },
|
|
280
|
+
packet: build_result[:path], spawn: spawn },
|
|
261
281
|
}
|
|
262
282
|
end
|
|
263
283
|
|
|
@@ -461,7 +481,10 @@ module RunnerDispatch
|
|
|
461
481
|
# Row 5.22/5.23/5.24: one machine-readable (YAML) document naming, per
|
|
462
482
|
# dispatched node, the packet path, the model, the worktree, the kind and
|
|
463
483
|
# the role, plus the return contract ONCE at the top level - never inside
|
|
464
|
-
# any one node's packet.
|
|
484
|
+
# any one node's packet. Row 6.4: "spawn" carries the same, already fully
|
|
485
|
+
# rendered spawn block for each dispatched node in order, so any reader of
|
|
486
|
+
# this data (YAML today, JSON if it is ever re-serialized) finds it under
|
|
487
|
+
# `spawn` rather than re-deriving it from the other fields.
|
|
465
488
|
def render_plan(dispatched)
|
|
466
489
|
return nil if dispatched.empty?
|
|
467
490
|
|
|
@@ -470,7 +493,8 @@ module RunnerDispatch
|
|
|
470
493
|
"dispatch" => dispatched.map do |d|
|
|
471
494
|
{ "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
|
|
472
495
|
"worktree" => d[:worktree], "packet" => d[:packet] }
|
|
473
|
-
end
|
|
496
|
+
end,
|
|
497
|
+
"spawn" => dispatched.map { |d| d[:spawn] }
|
|
474
498
|
)
|
|
475
499
|
end
|
|
476
500
|
private_class_method :render_plan
|
|
@@ -139,4 +139,35 @@ module RunnerPolicy
|
|
|
139
139
|
def lease_expires(kind, now: Time.now)
|
|
140
140
|
(now + (lease_minutes(kind) * 60)).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
141
141
|
end
|
|
142
|
+
|
|
143
|
+
# --- call budget (intent 355, n2) -------------------------------------------
|
|
144
|
+
#
|
|
145
|
+
# D2: a cap on tool calls per attempt, enforced by a PreToolUse hook that
|
|
146
|
+
# counts tool calls in the session transcript. Shipped per kind (matrix
|
|
147
|
+
# 2.1); `decision` carries one too even though it is never dispatched
|
|
148
|
+
# (327 D12 leaves it out of every lease table for the same reason), so
|
|
149
|
+
# `call_cap` never has to special-case an unknown kind here any more than
|
|
150
|
+
# `retry_cap` does.
|
|
151
|
+
CALL_CAP_TABLE = { "work" => 60, "verify" => 40, "research" => 40, "decision" => 10 }.freeze
|
|
152
|
+
|
|
153
|
+
# matrix 5.17's own fallback rule, one call: an unknown or nil kind reads
|
|
154
|
+
# `work`'s cap, never a fourth, undeclared number.
|
|
155
|
+
def call_cap(kind, config: {})
|
|
156
|
+
override = call_caps_section(config)[kind.to_s]
|
|
157
|
+
present?(override) ? override.to_i : CALL_CAP_TABLE.fetch(kind.to_s, CALL_CAP_TABLE["work"])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# matrix 2.2: `runner.call_caps.<kind>` in the project config overrides the
|
|
161
|
+
# shipped cap, the same nested-Hash shape AgentModels.models_section reads
|
|
162
|
+
# `agents.models` from - one more caller of the pattern, not a new one.
|
|
163
|
+
def call_caps_section(config)
|
|
164
|
+
return {} unless config.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
runner = config["runner"]
|
|
167
|
+
return {} unless runner.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
section = runner["call_caps"]
|
|
170
|
+
section.is_a?(Hash) ? section : {}
|
|
171
|
+
end
|
|
172
|
+
private_class_method :call_caps_section
|
|
142
173
|
end
|
|
@@ -46,6 +46,8 @@ module RunnerProposals
|
|
|
46
46
|
"research" => "node-research.md",
|
|
47
47
|
}.freeze
|
|
48
48
|
|
|
49
|
+
REVIEW_FIX_CAP = 2
|
|
50
|
+
|
|
49
51
|
# accept(context, proposer:, proposed_nodes:, proposed_edges:, now:,
|
|
50
52
|
# validator:, templates_dir:, renamer:) -> {ok:, minted:, validator:,
|
|
51
53
|
# errors:}. `proposer` names the node whose return carried these proposals
|
|
@@ -129,6 +131,14 @@ module RunnerProposals
|
|
|
129
131
|
edge_specs << { from: from, to: to }
|
|
130
132
|
end
|
|
131
133
|
|
|
134
|
+
trial_nodes = loaded[:nodes].merge(node_specs.to_h { |s| [s[:id], { kind: s[:kind] }] })
|
|
135
|
+
existing_fixes = review_fix_count(loaded[:edges], loaded[:nodes])
|
|
136
|
+
trial_fixes = review_fix_count(trial_edges, trial_nodes)
|
|
137
|
+
if trial_fixes > [existing_fixes, REVIEW_FIX_CAP].max
|
|
138
|
+
return refuse(intent_dir, proposer,
|
|
139
|
+
"proposal refused (review_fix_cap): would make #{trial_fixes} review fixes, the cap is #{REVIEW_FIX_CAP}", now)
|
|
140
|
+
end
|
|
141
|
+
|
|
132
142
|
node_specs.each { |s| scaffold_node_file(intent_dir, s) }
|
|
133
143
|
if node_specs.any? || edge_specs.any?
|
|
134
144
|
append_to_graph(graph_path, node_specs: node_specs, edge_specs: edge_specs, renamer: renamer)
|
|
@@ -141,6 +151,17 @@ module RunnerProposals
|
|
|
141
151
|
{ ok: true, minted: node_specs.map { |s| s[:id] }, validator: safe_validate(validator, intent_dir), errors: [] }
|
|
142
152
|
end
|
|
143
153
|
|
|
154
|
+
# --- review fixes (355 D4) -------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# review_fix_count(edges, nodes) -> how many work nodes GraphEdges.review_fixes
|
|
157
|
+
# finds, a node's kind read from its declaration and, when that is missing,
|
|
158
|
+
# from its id's NodeFile::KIND_PREFIX (343 D6).
|
|
159
|
+
def review_fix_count(edges, nodes)
|
|
160
|
+
ids = (edges.keys + edges.values.flatten).uniq
|
|
161
|
+
kinds = ids.to_h { |id| [id, (nodes[id] || {})[:kind] || NodeFile::KIND_PREFIX.key(id.to_s[/\A[a-z]+/])] }
|
|
162
|
+
GraphEdges.review_fixes(edges, kinds).length
|
|
163
|
+
end
|
|
164
|
+
|
|
144
165
|
# --- node scaffolding --------------------------------------------------
|
|
145
166
|
|
|
146
167
|
# A template's own placeholder id ("n1", "v1", ...) is replaced only as a
|