@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24
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 +6 -5
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +22 -19
- package/scripts/exec-worktree +5 -5
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +16 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +26 -3
- package/scripts/hook-session-start +49 -10
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_entry.rb +53 -0
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +228 -9
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +11 -2
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +2 -2
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +36 -10
- package/scripts/lib/runner_dispatch.rb +66 -51
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +9 -10
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +22 -16
- package/scripts/lib/session_ledger.rb +44 -4
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/new-intent +1 -1
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +224 -0
- package/scripts/plastic-lock +33 -32
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +8 -8
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +4 -4
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +7 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require "date"
|
|
8
|
+
require_relative "lib/savepoint"
|
|
9
|
+
|
|
10
|
+
# graph-measure - the one read-only executable over the node ledger (intent
|
|
11
|
+
# 343, G10, n2). A subcommand table over one library, following
|
|
12
|
+
# scripts/runner's shape (327 spec): the public verbs are named up front even
|
|
13
|
+
# before every module lands, a flag outside a verb's own vocabulary is
|
|
14
|
+
# refused by name, and each verb's module is required LAZILY inside its own
|
|
15
|
+
# branch so a verb whose module has not landed yet reports plainly instead of
|
|
16
|
+
# taking `intent`, the verb an operator runs constantly, down with it.
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# graph-measure <intent|budget|cohorts> <dir> [--format text|json]
|
|
20
|
+
#
|
|
21
|
+
# `intent` (n2) and `budget` (n4) take an INTENT directory. `cohorts` (n5,
|
|
22
|
+
# its model section; n6 adds the rates, the latency and the bar to the same
|
|
23
|
+
# verb) takes a STORE directory instead - the directory holding `id--slug/`
|
|
24
|
+
# intent folders for one project - and is validated against that shape, not
|
|
25
|
+
# `intent_directory?`.
|
|
26
|
+
#
|
|
27
|
+
# Exit codes:
|
|
28
|
+
# 0 - a report rendered (anomalies never change this: a ledger with torn
|
|
29
|
+
# lines still renders, lists them, and exits 0)
|
|
30
|
+
# 1 - the directory has no savepoint.md to measure at all, named
|
|
31
|
+
# 2 - usage: no args, an unknown subcommand, an unknown flag, or the path
|
|
32
|
+
# is not the right shape for its verb (an intent directory for
|
|
33
|
+
# `intent`/`budget`, a store directory for `cohorts`)
|
|
34
|
+
# 3 - the verb's module has not been delivered yet
|
|
35
|
+
module GraphMeasureCLI
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
VERBS = %w[intent budget cohorts].freeze
|
|
39
|
+
SAVEPOINT_FILE = "savepoint.md"
|
|
40
|
+
|
|
41
|
+
# verb -> [[ModuleName, lib_file], ...], required lazily inside the verb's
|
|
42
|
+
# own branch (row 2.6). "budget" and "cohorts" name libraries n4 and n5
|
|
43
|
+
# ship; requiring them today raises LoadError, caught below.
|
|
44
|
+
LAZY_MODULES = {
|
|
45
|
+
"intent" => [["GraphMeasure", "graph_measure"], ["GraphMeasureReport", "graph_measure_report"]],
|
|
46
|
+
"budget" => [["GraphMeasureBudget", "graph_measure_budget"]],
|
|
47
|
+
"cohorts" => [["GraphMeasureModels", "graph_measure_models"], ["GraphMeasureCohorts", "graph_measure_cohorts"]],
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
KNOWN_FLAGS = {
|
|
51
|
+
"intent" => %w[--format],
|
|
52
|
+
"budget" => %w[--format],
|
|
53
|
+
"cohorts" => %w[--format],
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# "intent" and "budget" take one intent directory; "cohorts" takes the
|
|
57
|
+
# store directory that holds many of them (row 5.15).
|
|
58
|
+
DIR_KIND = { "intent" => :intent, "budget" => :intent, "cohorts" => :store }.freeze
|
|
59
|
+
|
|
60
|
+
def usage
|
|
61
|
+
warn "Usage: graph-measure <#{VERBS.join('|')}> <dir> [--format text|json]"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def intent_directory?(dir)
|
|
65
|
+
dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def store_directory?(dir)
|
|
69
|
+
dir && File.directory?(dir)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def valid_dir?(verb, dir)
|
|
73
|
+
DIR_KIND.fetch(verb, :intent) == :store ? store_directory?(dir) : intent_directory?(dir)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def opt(args, name)
|
|
77
|
+
(i = args.index(name)) && args[i + 1]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unrecognized_flag(verb, args)
|
|
81
|
+
allowed = KNOWN_FLAGS.fetch(verb, [])
|
|
82
|
+
args.find { |a| a.is_a?(String) && a.start_with?("--") && !allowed.include?(a) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def main(argv)
|
|
86
|
+
args = argv.dup
|
|
87
|
+
verb = args.shift
|
|
88
|
+
|
|
89
|
+
unless verb
|
|
90
|
+
usage
|
|
91
|
+
return 2
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
unless VERBS.include?(verb)
|
|
95
|
+
warn "graph-measure: unknown subcommand #{verb.inspect}"
|
|
96
|
+
usage
|
|
97
|
+
return 2
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
dir_arg = args.shift
|
|
101
|
+
unless valid_dir?(verb, dir_arg && File.expand_path(dir_arg))
|
|
102
|
+
label = DIR_KIND.fetch(verb, :intent) == :store ? "not a store directory" : "not an intent directory"
|
|
103
|
+
warn "graph-measure: #{dir_arg.inspect} is #{label}"
|
|
104
|
+
usage
|
|
105
|
+
return 2
|
|
106
|
+
end
|
|
107
|
+
dir = File.expand_path(dir_arg)
|
|
108
|
+
|
|
109
|
+
bad_flag = unrecognized_flag(verb, args)
|
|
110
|
+
if bad_flag
|
|
111
|
+
warn "graph-measure: unknown flag #{bad_flag.inspect} for #{verb}"
|
|
112
|
+
usage
|
|
113
|
+
return 2
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
format = opt(args, "--format") || "text"
|
|
117
|
+
unless %w[text json].include?(format)
|
|
118
|
+
warn "graph-measure: unknown --format #{format.inspect} (use text or json)"
|
|
119
|
+
usage
|
|
120
|
+
return 2
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
run_lazy(verb, dir, format)
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
# Row 2.22: a malformed graph.md, a torn ledger, or invalid UTF-8 never
|
|
126
|
+
# raises out of GraphMeasure or GraphMeasureReport (both are built to
|
|
127
|
+
# resolve broken data to an anomaly, never an exception) - this is
|
|
128
|
+
# defence in depth so a defect in either never reaches the owner as a
|
|
129
|
+
# bare Ruby backtrace.
|
|
130
|
+
warn "graph-measure: internal error: #{e.message}"
|
|
131
|
+
1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def run_lazy(verb, dir, format)
|
|
135
|
+
LAZY_MODULES.fetch(verb, []).each { |(_name, file)| require_relative(File.join("lib", file)) }
|
|
136
|
+
rescue LoadError
|
|
137
|
+
warn "graph-measure: #{verb} is not yet delivered (its module has not landed)"
|
|
138
|
+
3
|
|
139
|
+
else
|
|
140
|
+
dispatch_lazy(verb, dir, format)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def dispatch_lazy(verb, dir, format)
|
|
144
|
+
case verb
|
|
145
|
+
when "intent"
|
|
146
|
+
run_intent(dir, format)
|
|
147
|
+
when "budget"
|
|
148
|
+
run_budget(dir, format)
|
|
149
|
+
when "cohorts"
|
|
150
|
+
run_cohorts(dir, format)
|
|
151
|
+
else
|
|
152
|
+
warn "graph-measure: #{verb}'s module loaded but no dispatcher is wired up yet"
|
|
153
|
+
3
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# M1: the boundary's own config resolution, never GraphMeasureModels' or
|
|
158
|
+
# GraphMeasureCohorts' (spec D2, both modules' own DI convention). Mirrors
|
|
159
|
+
# scripts/doctor.rb's own `PLASTIC_HOME` fallback and `load_yaml_safe`
|
|
160
|
+
# shape exactly, so a missing or malformed config.yml/project.yml resolves
|
|
161
|
+
# to `{}` (the shipped-default chain), never an exception.
|
|
162
|
+
def plastic_home
|
|
163
|
+
ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def load_yaml_safe(path)
|
|
167
|
+
return {} unless path && File.exist?(path)
|
|
168
|
+
|
|
169
|
+
YAML.safe_load(File.read(path), permitted_classes: [Date, Time]) || {}
|
|
170
|
+
rescue StandardError
|
|
171
|
+
{}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def missing_savepoint(dir)
|
|
175
|
+
savepoint_path = File.join(dir, SAVEPOINT_FILE)
|
|
176
|
+
return nil if File.exist?(savepoint_path)
|
|
177
|
+
|
|
178
|
+
warn "graph-measure: #{savepoint_path} does not exist, nothing to measure"
|
|
179
|
+
savepoint_path
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def run_intent(dir, format)
|
|
183
|
+
# Row 2.5: checked directly, ahead of GraphMeasure.read, which resolves a
|
|
184
|
+
# missing file to an empty (not absent) record by design (D3) - the CLI
|
|
185
|
+
# boundary is the only place that can still tell "nothing to measure"
|
|
186
|
+
# apart from "measured, and it is empty".
|
|
187
|
+
return 1 if missing_savepoint(dir)
|
|
188
|
+
|
|
189
|
+
record = GraphMeasure.read(dir)
|
|
190
|
+
puts format == "json" ? GraphMeasureReport.render_json(record) : GraphMeasureReport.render_text(record)
|
|
191
|
+
0
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def run_budget(dir, format)
|
|
195
|
+
return 1 if missing_savepoint(dir)
|
|
196
|
+
|
|
197
|
+
record = GraphMeasureBudget.read(dir)
|
|
198
|
+
puts format == "json" ? GraphMeasureBudget.render_json(record) : GraphMeasureBudget.render_text(record)
|
|
199
|
+
0
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Row 2.5's "missing savepoint.md" check does not apply here: `dir` is a
|
|
203
|
+
# STORE directory (spec D1's "the directory holding id--slug/ intent
|
|
204
|
+
# folders"), which never carries a savepoint.md of its own.
|
|
205
|
+
#
|
|
206
|
+
# n6 adds the rates, the latency and the bar to this same verb, sourced
|
|
207
|
+
# from `GraphMeasureCohorts`. Its bar section reuses the one
|
|
208
|
+
# `GraphMeasureModels` walk's `qualified` counts (passed in here) rather
|
|
209
|
+
# than triggering a second store walk for the same numbers.
|
|
210
|
+
#
|
|
211
|
+
# M1 (v1 review): this call used to pass neither `project_config:` nor
|
|
212
|
+
# `global_config:`, so the model comparison always resolved against
|
|
213
|
+
# RunnerPolicy's shipped defaults only, never the real project-then-
|
|
214
|
+
# global-then-default chain the CHANGELOG describes and
|
|
215
|
+
# `scripts/doctor.rb`'s own `model_requalification_checks` actually reads.
|
|
216
|
+
# Reproduced: with a `plastic-executor:` override present in global
|
|
217
|
+
# config, `GraphMeasureModels.read` reports real drift rows while this
|
|
218
|
+
# verb (unpatched) always reported zero. `GraphMeasure`'s own modules stay
|
|
219
|
+
# config-free by design (D2's DI convention: they never read disk or ENV
|
|
220
|
+
# themselves) - this executable is the boundary that is allowed to, the
|
|
221
|
+
# same role `scripts/doctor.rb` already plays for the same two config
|
|
222
|
+
# files.
|
|
223
|
+
#
|
|
224
|
+
# NEW-4 (v2 review, D22): the project-scope half named `<store>/../project.yml`,
|
|
225
|
+
# a file whose template carries no `agents:` key at all, so a real
|
|
226
|
+
# operator override at the file that DOES exist in the wild - the store's
|
|
227
|
+
# sibling `config.yml` - was silently ignored. The path now comes from
|
|
228
|
+
# `GraphMeasureModels.project_config_path`, the one function
|
|
229
|
+
# `scripts/doctor.rb`'s own rule calls too, so the two commands cannot
|
|
230
|
+
# resolve a different file for the same store.
|
|
231
|
+
def run_cohorts(dir, format)
|
|
232
|
+
global_config = load_yaml_safe(File.join(plastic_home, "config.yml"))
|
|
233
|
+
project_config = load_yaml_safe(GraphMeasureModels.project_config_path(dir))
|
|
234
|
+
|
|
235
|
+
models_record = GraphMeasureModels.read(dir, project_config: project_config, global_config: global_config)
|
|
236
|
+
cohorts_record = GraphMeasureCohorts.read(dir, qualified: models_record[:qualified],
|
|
237
|
+
project_config: project_config, global_config: global_config)
|
|
238
|
+
if format == "json"
|
|
239
|
+
combined = GraphMeasureModels.model(models_record).merge(GraphMeasureCohorts.model(cohorts_record))
|
|
240
|
+
puts JSON.generate(combined)
|
|
241
|
+
else
|
|
242
|
+
puts GraphMeasureModels.render_text(models_record)
|
|
243
|
+
puts GraphMeasureCohorts.render_text(cohorts_record)
|
|
244
|
+
end
|
|
245
|
+
0
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
exit GraphMeasureCLI.main(ARGV) if $PROGRAM_NAME == __FILE__
|
package/scripts/hook-call-budget
CHANGED
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
# transcript, at <dirname(transcript_path)>/<session_id>/subagents/
|
|
22
22
|
# agent-<agent_id>.jsonl (the main transcript_path plus the main session_id
|
|
23
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
|
|
25
|
-
# path into that prompt (
|
|
24
|
+
# dispatch prompt, and the lead/spawn block always pastes the node's input
|
|
25
|
+
# path into that prompt (INPUT_PATH_RE). The node input path names the intent
|
|
26
26
|
# directory directly (its grandparent), so this hook never scans the store to
|
|
27
|
-
# find it. No
|
|
27
|
+
# find it. No node input path in that first prompt means allow.
|
|
28
28
|
#
|
|
29
29
|
# The cap applies only when that node's LATEST transition line in
|
|
30
30
|
# <intent>/savepoint.md is `running` and carries `calls=`; a later `done` or
|
|
@@ -47,10 +47,10 @@ module HookCallBudget
|
|
|
47
47
|
|
|
48
48
|
RETURN_INSTRUCTION = "commit what is green and return failed_verification reason=call_budget"
|
|
49
49
|
|
|
50
|
-
# A node id, then "--a", then an attempt number, inside a /
|
|
50
|
+
# A node id, then "--a", then an attempt number, inside a /attempts/
|
|
51
51
|
# directory: the exact shape RunnerDispatch's spawn block pastes into a
|
|
52
52
|
# subagent's own first prompt. Captures [full_path, node].
|
|
53
|
-
|
|
53
|
+
INPUT_PATH_RE = %r{(\S+/attempts/([a-z]{1,2}\d+)--a\d+\.input)}.freeze
|
|
54
54
|
|
|
55
55
|
FIRST_RECORD_SCAN_LIMIT = 20
|
|
56
56
|
|
|
@@ -78,7 +78,7 @@ module HookCallBudget
|
|
|
78
78
|
end
|
|
79
79
|
return nil unless prompt
|
|
80
80
|
|
|
81
|
-
match =
|
|
81
|
+
match = INPUT_PATH_RE.match(prompt)
|
|
82
82
|
return nil unless match
|
|
83
83
|
|
|
84
84
|
node = match[2]
|
package/scripts/hook-capture
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
require "json"
|
|
13
13
|
require "open3"
|
|
14
|
+
require "time"
|
|
14
15
|
require "fileutils"
|
|
15
16
|
require_relative "lib/session_ledger"
|
|
16
17
|
require_relative "lib/dashboard_banner"
|
|
18
|
+
require_relative "lib/active_delivery"
|
|
17
19
|
|
|
18
20
|
raw = begin
|
|
19
21
|
$stdin.read
|
|
@@ -47,32 +49,31 @@ def truncate(text, max)
|
|
|
47
49
|
"#{text[0, max - 3]}..."
|
|
48
50
|
end
|
|
49
51
|
|
|
50
|
-
# --- (a)
|
|
51
|
-
# Session start
|
|
52
|
+
# --- (a) heartbeat, guarded on the session tmp dir already existing --------
|
|
53
|
+
# Session start creates `.tmp/<sid>/`; a capture with no such directory yet
|
|
52
54
|
# means session start never ran for this session at all (a `-p` print
|
|
53
55
|
# session, a resumed background job, an unregistered SessionStart hook), so
|
|
54
|
-
# neither the
|
|
55
|
-
# fires before PostToolUse, so capture is the FIRST
|
|
56
|
-
#
|
|
57
|
-
#
|
|
56
|
+
# neither the directory nor its heartbeat is created HERE either.
|
|
57
|
+
# UserPromptSubmit fires before PostToolUse, so capture is the FIRST place
|
|
58
|
+
# this guard matters; hook-record carries the same guard for its own
|
|
59
|
+
# (later) heartbeat write. (344 n2, D4-D5)
|
|
58
60
|
begin
|
|
59
|
-
if
|
|
60
|
-
SessionLedger.ensure_tmp_root(store)
|
|
61
|
-
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
61
|
+
if Dir.exist?(SessionLedger.session_tmp_dir(store, sid))
|
|
62
62
|
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
63
63
|
end
|
|
64
64
|
rescue StandardError
|
|
65
65
|
nil
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
# --- (b)
|
|
68
|
+
# --- (b) a live delivery lock names this session (344 n2, D5, D12) ----------
|
|
69
69
|
skip_day_ledger = false
|
|
70
70
|
begin
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
full_session = session_id.empty? ? ENV["CLAUDE_CODE_SESSION_ID"].to_s : session_id
|
|
72
|
+
skip_day_ledger = !ActiveDelivery.resolve(
|
|
73
|
+
global_store: store,
|
|
74
|
+
project_roots: ActiveDelivery.project_roots(plastic_home),
|
|
75
|
+
session: full_session
|
|
76
|
+
).nil?
|
|
76
77
|
rescue StandardError
|
|
77
78
|
nil
|
|
78
79
|
end
|
package/scripts/hook-record
CHANGED
|
@@ -16,6 +16,7 @@ require "fileutils"
|
|
|
16
16
|
require_relative "lib/savepoint"
|
|
17
17
|
require_relative "lib/lock"
|
|
18
18
|
require_relative "lib/session_ledger"
|
|
19
|
+
require_relative "lib/active_delivery"
|
|
19
20
|
|
|
20
21
|
raw = begin
|
|
21
22
|
$stdin.read
|
|
@@ -80,11 +81,20 @@ unless skip_day_ledger
|
|
|
80
81
|
sid = SessionLedger.short_session_id(nil, session_id)
|
|
81
82
|
today = SessionLedger.day_id
|
|
82
83
|
|
|
84
|
+
# A live delivery lock naming this session's full id skips promotion
|
|
85
|
+
# (344 n2, D5, D12): the lock, not the retired pointer, decides.
|
|
83
86
|
proceed = true
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
unless session_id.empty?
|
|
88
|
+
begin
|
|
89
|
+
held = ActiveDelivery.resolve(
|
|
90
|
+
global_store: store,
|
|
91
|
+
project_roots: ActiveDelivery.project_roots(plastic_home),
|
|
92
|
+
session: session_id
|
|
93
|
+
)
|
|
94
|
+
proceed = held.nil?
|
|
95
|
+
rescue StandardError
|
|
96
|
+
proceed = true
|
|
97
|
+
end
|
|
88
98
|
end
|
|
89
99
|
|
|
90
100
|
if proceed
|
|
@@ -135,19 +145,16 @@ unless skip_day_ledger
|
|
|
135
145
|
end
|
|
136
146
|
end
|
|
137
147
|
|
|
138
|
-
# --- (d) heartbeat, guarded on the
|
|
139
|
-
# Session start
|
|
140
|
-
# start never ran for this session (a `-p` print
|
|
141
|
-
# background job, an unregistered SessionStart hook), so
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
# fallback reports as an orphan just the same.
|
|
148
|
+
# --- (d) heartbeat, guarded on the session tmp dir (344 n2, D4) ------------
|
|
149
|
+
# Session start creates the per-session tmp directory; with no such
|
|
150
|
+
# directory at all, session start never ran for this session (a `-p` print
|
|
151
|
+
# session, a resumed background job, an unregistered SessionStart hook), so
|
|
152
|
+
# the heartbeat is not written and no directory is created here either --
|
|
153
|
+
# the session tmp directory, created by session start, is the guard.
|
|
145
154
|
begin
|
|
146
155
|
store ||= File.join(plastic_home, "store")
|
|
147
156
|
sid ||= SessionLedger.short_session_id(nil, session_id)
|
|
148
|
-
if
|
|
149
|
-
SessionLedger.ensure_tmp_root(store)
|
|
150
|
-
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
157
|
+
if Dir.exist?(SessionLedger.session_tmp_dir(store, sid))
|
|
151
158
|
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
152
159
|
end
|
|
153
160
|
rescue StandardError
|
package/scripts/hook-savepoint
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
# hook-savepoint (intent 311): the PreCompact hook body. Reads the harness
|
|
6
6
|
# payload from stdin (session_id, cwd), takes the Plastic home from argv,
|
|
7
|
-
# writes this session's hand-off for the
|
|
7
|
+
# writes this session's hand-off for the session's day from the day ledger,
|
|
8
|
+
# and prints one
|
|
8
9
|
# message: the fixed text, plus the written file's path when a session
|
|
9
10
|
# resolved (both harnesses relay the same launcher and the Codex pin feeds
|
|
10
11
|
# both sides the same payload, so the two stay equal). Always exits 0,
|
|
@@ -15,12 +16,31 @@
|
|
|
15
16
|
require "json"
|
|
16
17
|
require_relative "lib/session_ledger"
|
|
17
18
|
require_relative "lib/handoff"
|
|
19
|
+
require_relative "lib/active_delivery"
|
|
18
20
|
|
|
19
21
|
MESSAGE = "PLASTIC SAVEPOINT - context is being compacted. The hand-off for this " \
|
|
20
22
|
"session is written in today's day ledger " \
|
|
21
23
|
"(store/.sessions/<day>/handoff--<session>.md). " \
|
|
22
24
|
"After compaction say continue to resume from it."
|
|
23
25
|
|
|
26
|
+
# The intent this session is delivering, resolved through the same walk the
|
|
27
|
+
# Stop gate uses (row 4.38): read-config's project_roots, plus the global
|
|
28
|
+
# store, glob for a live delivery.lock this session holds. Never raises
|
|
29
|
+
# (ActiveDelivery.resolve rescues its own reads); a lookup failure just
|
|
30
|
+
# means the Runner section is omitted, never that the hand-off is lost.
|
|
31
|
+
def resolve_intent(plastic_home, session_id, read_config_path)
|
|
32
|
+
return nil if session_id.strip.empty?
|
|
33
|
+
|
|
34
|
+
global_store = File.join(plastic_home, "store")
|
|
35
|
+
roots_raw = IO.popen({ "PLASTIC_HOME" => plastic_home }, [read_config_path, "project_roots"],
|
|
36
|
+
err: File::NULL, &:read).to_s.strip
|
|
37
|
+
project_roots = roots_raw.empty? ? [] : Array(JSON.parse(roots_raw))
|
|
38
|
+
project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
|
|
39
|
+
ActiveDelivery.resolve(global_store: global_store, project_roots: project_roots, session: session_id)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
24
44
|
written = nil
|
|
25
45
|
begin
|
|
26
46
|
plastic_home = ARGV[0].to_s
|
|
@@ -30,11 +50,14 @@ begin
|
|
|
30
50
|
session_id = payload["session_id"].to_s
|
|
31
51
|
|
|
32
52
|
unless plastic_home.empty? || session_id.strip.empty?
|
|
33
|
-
|
|
53
|
+
home = File.expand_path(plastic_home)
|
|
54
|
+
store = File.join(home, "store")
|
|
34
55
|
session = SessionLedger.short_session_id(session_id, nil)
|
|
35
56
|
day = Handoff.day_for(store, session, today: SessionLedger.day_id)
|
|
57
|
+
read_config_path = File.expand_path("read-config", __dir__)
|
|
58
|
+
intent = resolve_intent(home, session_id, read_config_path)
|
|
36
59
|
written = Handoff.write(store: store, day: day, session: session, trigger: "precompact",
|
|
37
|
-
templates: File.expand_path("../templates", __dir__))
|
|
60
|
+
templates: File.expand_path("../templates", __dir__), intent: intent)
|
|
38
61
|
end
|
|
39
62
|
rescue StandardError, JSON::ParserError
|
|
40
63
|
nil
|
|
@@ -7,13 +7,15 @@ require "json"
|
|
|
7
7
|
require "date"
|
|
8
8
|
require "yaml"
|
|
9
9
|
require "fileutils"
|
|
10
|
-
require_relative "lib/bridge"
|
|
11
10
|
require_relative "lib/boot_banner"
|
|
12
11
|
require_relative "lib/qmd_sync"
|
|
13
12
|
require_relative "lib/doctor_core"
|
|
14
13
|
require_relative "lib/session_ledger"
|
|
15
14
|
require_relative "lib/day_summary"
|
|
16
|
-
require_relative "lib/
|
|
15
|
+
require_relative "lib/data_boundary"
|
|
16
|
+
require_relative "lib/active_delivery"
|
|
17
|
+
require_relative "lib/runner_core"
|
|
18
|
+
require_relative "lib/runner_watch"
|
|
17
19
|
|
|
18
20
|
index_path, plastic_home, mode, plugin_root = ARGV
|
|
19
21
|
exit 0 unless index_path && plastic_home && mode
|
|
@@ -233,19 +235,56 @@ begin
|
|
|
233
235
|
if qmd_status[:present]
|
|
234
236
|
# C23 (intent 341, G8): a QMD hit is untrusted text, so it never sits
|
|
235
237
|
# loose in additionalContext alongside the owner's own banners; it
|
|
236
|
-
# rides inside
|
|
238
|
+
# rides inside DataBoundary's data boundary.
|
|
237
239
|
qmd_line = if qmd_status[:all_registered]
|
|
238
240
|
"QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
|
|
239
241
|
else
|
|
240
242
|
"QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
|
|
241
243
|
end
|
|
242
|
-
token =
|
|
243
|
-
parts <<
|
|
244
|
+
token = DataBoundary.boundary_token([qmd_line])
|
|
245
|
+
parts << DataBoundary.wrap(qmd_line, label: "qmd-hit", source: "qmd-sync", token: token).rstrip
|
|
244
246
|
end
|
|
245
247
|
rescue Exception
|
|
246
248
|
# Any failure (timeout, missing binary, parse error) — stay silent, never crash.
|
|
247
249
|
end unless subagent_session
|
|
248
250
|
|
|
251
|
+
# --- Delivery watch (intent 340a, G7b, n3, graph.md D10) ---
|
|
252
|
+
# One unrecorded tick (record: false: reclaim and classify, no snapshot,
|
|
253
|
+
# no record line, so boot never advances a quiet count) per intent
|
|
254
|
+
# ActiveDelivery.candidate_intent_dirs finds, naming every stalled or
|
|
255
|
+
# done_unreported one in a single line, nothing when there are none. The
|
|
256
|
+
# whole block is one guarded unit, like the QMD block above it: a raise or
|
|
257
|
+
# a hang on any one candidate must add nothing rather than name only the
|
|
258
|
+
# candidates seen before the failure, so the line a boot does print is
|
|
259
|
+
# never a partial one.
|
|
260
|
+
begin
|
|
261
|
+
Timeout.timeout(2) do
|
|
262
|
+
roots_raw = IO.popen({ "PLASTIC_HOME" => plastic_home }, [read_config, "project_roots"],
|
|
263
|
+
err: File::NULL, &:read).to_s.strip
|
|
264
|
+
project_roots = roots_raw.empty? ? [] : Array(JSON.parse(roots_raw))
|
|
265
|
+
project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
|
|
266
|
+
|
|
267
|
+
attention = []
|
|
268
|
+
ActiveDelivery.candidate_intent_dirs(global_store: store_dir, project_roots: project_roots).uniq.each do |dir|
|
|
269
|
+
watch_context = RunnerCore.context(intent_dir: dir)
|
|
270
|
+
result = RunnerWatch.tick(watch_context, record: false)
|
|
271
|
+
next unless %w[stalled done_unreported].include?(result[:class])
|
|
272
|
+
|
|
273
|
+
watch_intent_id = File.basename(dir).split("--").first
|
|
274
|
+
if result[:class] == "stalled"
|
|
275
|
+
blocker = Array(result[:blockers]).first
|
|
276
|
+
attention << (blocker ? "#{watch_intent_id} stalled (#{blocker})" : "#{watch_intent_id} stalled")
|
|
277
|
+
else
|
|
278
|
+
attention << "#{watch_intent_id} done_unreported"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
parts << "PLASTIC watch: #{attention.join(', ')}" if attention.any?
|
|
282
|
+
end
|
|
283
|
+
rescue Exception
|
|
284
|
+
# Any failure or timeout (a malformed intent directory, a hung tick) -
|
|
285
|
+
# stay silent, never crash boot.
|
|
286
|
+
end unless subagent_session
|
|
287
|
+
|
|
249
288
|
# --- Project (or global) banner with its active intent ---
|
|
250
289
|
# The one piece of intent context a live boot still carries: which store
|
|
251
290
|
# is live, and which intent (if any) is active in it (intent 341, G8, D4).
|
|
@@ -349,9 +388,11 @@ begin
|
|
|
349
388
|
end
|
|
350
389
|
end
|
|
351
390
|
|
|
352
|
-
# --- Session day ledger: open or join today,
|
|
353
|
-
#
|
|
354
|
-
#
|
|
391
|
+
# --- Session day ledger: open or join today, create the tmp dir and write
|
|
392
|
+
# the heartbeat (344 n2, spec D4). The per-session state retired: hooks
|
|
393
|
+
# now ask ActiveDelivery.resolve for a live delivery lock instead.
|
|
394
|
+
# Best-effort: any failure here degrades to no ledger context line, never
|
|
395
|
+
# blocks the session.
|
|
355
396
|
unless subagent_session
|
|
356
397
|
begin
|
|
357
398
|
templates = File.expand_path("../templates", __dir__)
|
|
@@ -369,8 +410,6 @@ begin
|
|
|
369
410
|
SessionLedger.ensure_tmp_root(store_dir)
|
|
370
411
|
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
|
|
371
412
|
File.write(SessionLedger.heartbeat_path(store_dir, sid), "#{Time.now.utc.iso8601}\n")
|
|
372
|
-
pointer = SessionLedger.pointer_path(store_dir, sid)
|
|
373
|
-
File.write(pointer, "#{day}\n") unless File.exist?(pointer)
|
|
374
413
|
|
|
375
414
|
checklist = SessionLedger.checklist_path(store_dir, day)
|
|
376
415
|
open_count = 0
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# hook-stop (intent 340b, G7c, n4, D5): the Stop hook body. Reads the harness
|
|
6
|
+
# payload from stdin, gathers the runtime config StopGate needs, and asks it
|
|
7
|
+
# whether this session may stop. Prints Claude Code's Stop block schema on
|
|
8
|
+
# stdout only when the answer is block; a permit prints nothing at all.
|
|
9
|
+
# Registration is static (hooks/hooks.json, settings.json) - this launcher
|
|
10
|
+
# always installs - and the decision is runtime, off by default
|
|
11
|
+
# (runner.stop_hook defaults false), so the owner flips continuation on or
|
|
12
|
+
# off by editing config with no reinstall. Every error permits: always
|
|
13
|
+
# exits 0, never raises out to the harness.
|
|
14
|
+
#
|
|
15
|
+
# Usage: hook-stop <plastic_home> (stdin: the hook JSON)
|
|
16
|
+
|
|
17
|
+
require "json"
|
|
18
|
+
require_relative "lib/stop_gate"
|
|
19
|
+
|
|
20
|
+
plastic_home = ARGV[0]
|
|
21
|
+
exit 0 if plastic_home.nil? || plastic_home.empty?
|
|
22
|
+
|
|
23
|
+
def read_config_value(key, home, read_config)
|
|
24
|
+
IO.popen({ "PLASTIC_HOME" => home }, [read_config, key], err: File::NULL, &:read).to_s.strip
|
|
25
|
+
rescue StandardError
|
|
26
|
+
""
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
raw = $stdin.tty? ? "" : $stdin.read.to_s
|
|
31
|
+
payload = raw.strip.empty? ? {} : JSON.parse(raw)
|
|
32
|
+
payload = {} unless payload.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
home = File.expand_path(plastic_home)
|
|
35
|
+
global_store = File.join(home, "store")
|
|
36
|
+
read_config = File.expand_path("read-config", __dir__)
|
|
37
|
+
|
|
38
|
+
config_stop_hook = read_config_value("runner.stop_hook", home, read_config)
|
|
39
|
+
roots_raw = read_config_value("project_roots", home, read_config)
|
|
40
|
+
project_roots =
|
|
41
|
+
begin
|
|
42
|
+
roots_raw.strip.empty? ? [] : Array(JSON.parse(roots_raw))
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
|
|
47
|
+
|
|
48
|
+
session = payload["session_id"].to_s
|
|
49
|
+
|
|
50
|
+
result = StopGate.decide(payload: payload, config_stop_hook: config_stop_hook, session: session,
|
|
51
|
+
global_store: global_store, project_roots: project_roots)
|
|
52
|
+
|
|
53
|
+
puts JSON.generate("decision" => "block", "reason" => result[:reason]) if result[:block]
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
exit 0
|