@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.
Files changed (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. 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__
@@ -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 packet
25
- # path into that prompt (PACKET_PATH_RE). The packet path names the intent
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 packet path in that first prompt means allow.
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 /packets/
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
- PACKET_PATH_RE = %r{(\S+/packets/([a-z]{1,2}\d+)--a\d+\.packet)}.freeze
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 = PACKET_PATH_RE.match(prompt)
81
+ match = INPUT_PATH_RE.match(prompt)
82
82
  return nil unless match
83
83
 
84
84
  node = match[2]
@@ -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) tmp root + heartbeat, guarded on the pointer (spec D9) ------------
51
- # Session start writes the `current` pointer; a capture with no pointer yet
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 per-session dir nor its heartbeat is created. UserPromptSubmit
55
- # fires before PostToolUse, so capture is the FIRST creator of every orphan
56
- # `.tmp/<sid>/` this guard prevents; hook-record carries the same guard for
57
- # its own (later) heartbeat write.
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 File.exist?(SessionLedger.pointer_path(store, sid))
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) read the pointer -----------------------------------------------
68
+ # --- (b) a live delivery lock names this session (344 n2, D5, D12) ----------
69
69
  skip_day_ledger = false
70
70
  begin
71
- pointer = SessionLedger.pointer_path(store, sid)
72
- if File.exist?(pointer)
73
- current = File.read(pointer).strip
74
- skip_day_ledger = !current.empty? && current != today
75
- end
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
@@ -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
- pointer = SessionLedger.pointer_path(store, sid)
85
- if File.exist?(pointer)
86
- current = File.read(pointer).strip
87
- proceed = current.empty? || current == today
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 pointer (spec D9) -----------------------
139
- # Session start writes the `current` pointer; with no pointer at all, session
140
- # start never ran for this session (a `-p` print session, a resumed
141
- # background job, an unregistered SessionStart hook), so neither the
142
- # per-session dir nor its heartbeat is created here -- guarding only the
143
- # write would still leave an empty directory that heartbeat_age's mtime
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 File.exist?(SessionLedger.pointer_path(store, sid))
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
@@ -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 pointer's day, and prints one
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
- store = File.join(File.expand_path(plastic_home), "store")
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/packet_wrapper"
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 PacketWrapper's data boundary.
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 = PacketWrapper.boundary_token([qmd_line])
243
- parts << PacketWrapper.wrap(qmd_line, label: "qmd-hit", source: "qmd-sync", token: token).rstrip
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, write the per-session pointer
353
- # and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
354
- # to no ledger context line, never blocks the session.
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