@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
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/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
package/scripts/end-intent
CHANGED
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
# refused before removal; pass --discard-worktree-changes to override
|
|
67
67
|
# deliberately.
|
|
68
68
|
# 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
|
|
69
|
+
# 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
|
|
70
|
+
# exception to report-and-proceed; --allow-hollow-report overrides.
|
|
69
71
|
|
|
70
72
|
require "fileutils"
|
|
71
73
|
require "date"
|
|
@@ -77,7 +79,9 @@ require_relative "lib/savepoint"
|
|
|
77
79
|
require_relative "lib/lock"
|
|
78
80
|
require_relative "lib/intent_validator"
|
|
79
81
|
require_relative "lib/outcome_guard"
|
|
82
|
+
require_relative "lib/report_screen"
|
|
80
83
|
require_relative "lib/backfill_intent"
|
|
84
|
+
require_relative "lib/outcome_report"
|
|
81
85
|
|
|
82
86
|
DISPOSITIONS = %w[delivered abandoned].freeze
|
|
83
87
|
|
|
@@ -99,6 +103,7 @@ def parse_args(argv)
|
|
|
99
103
|
opts = {
|
|
100
104
|
store: nil, id: nil, disposition: nil, index: nil,
|
|
101
105
|
outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
|
|
106
|
+
allow_hollow_report: false,
|
|
102
107
|
session: nil, discard_worktree_changes: false,
|
|
103
108
|
}
|
|
104
109
|
i = 0
|
|
@@ -115,6 +120,7 @@ def parse_args(argv)
|
|
|
115
120
|
when "--discard-worktree-changes" then opts[:discard_worktree_changes] = true
|
|
116
121
|
when "--no-commit" then opts[:no_commit] = true
|
|
117
122
|
when "--dry-run" then opts[:dry_run] = true
|
|
123
|
+
when "--allow-hollow-report" then opts[:allow_hollow_report] = true
|
|
118
124
|
else
|
|
119
125
|
usage_abort("unknown argument #{arg.inspect}")
|
|
120
126
|
end
|
|
@@ -171,6 +177,64 @@ def resolve_plastic_home_and_scope(store)
|
|
|
171
177
|
end
|
|
172
178
|
end
|
|
173
179
|
|
|
180
|
+
# --- outcome generation, before the backfill (intent 339, G6, spec D10) -----
|
|
181
|
+
#
|
|
182
|
+
# Generates outcome.md from graph.md, nodes/, and the ledger, BEFORE the
|
|
183
|
+
# backfill step runs, and only into a file that is missing or still the
|
|
184
|
+
# scaffold placeholder (`Savepoint.stage_file_present?` false) - a real,
|
|
185
|
+
# hand-written outcome.md is never touched (row 7.2). Only when a graph.md
|
|
186
|
+
# exists at all (row 7.3): every intent without one closes exactly as it did
|
|
187
|
+
# before this intent. The model is parsed AFTER the `stage_file_present?`
|
|
188
|
+
# guard, not before: a real hand-written outcome.md returns at that guard
|
|
189
|
+
# regardless of graph.md's own health, so a close that was never going to
|
|
190
|
+
# generate anything must never print a parse warning about it. Refuses a
|
|
191
|
+
# report model that reports itself broken (row v1f.2, B2): a malformed
|
|
192
|
+
# `## Graph` fails `model[:ok]` and the close falls straight through to the
|
|
193
|
+
# backfill, rather than adopting parse damage as delivery fact - agreeing
|
|
194
|
+
# with the shipped `outcome-report` CLI, which exits 3 on the same
|
|
195
|
+
# directory. The generated text is then checked against
|
|
196
|
+
# BOTH close gates - `OutcomeGuard` and the hollow-report gate below - before
|
|
197
|
+
# being adopted (row 7.4); when either would refuse it, the write is
|
|
198
|
+
# reverted and the close falls through to intent 308's unchanged backfill
|
|
199
|
+
# (row 7.5), so no close gains an exit 7 it did not have before this intent.
|
|
200
|
+
# Fails open on any exception (row 7.6), the same report-and-proceed shape
|
|
201
|
+
# every other close step in this file uses. `generator:` is the injectable
|
|
202
|
+
# seam (mirrors run_structure_check's `gate:`).
|
|
203
|
+
def run_generate_outcome(intent_dir, disposition:, generator: OutcomeReport.method(:write))
|
|
204
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
205
|
+
return unless File.exist?(graph_path)
|
|
206
|
+
|
|
207
|
+
outcome_path = File.join(intent_dir, "outcome.md")
|
|
208
|
+
return if Savepoint.stage_file_present?(outcome_path)
|
|
209
|
+
|
|
210
|
+
report_model = OutcomeReport.model(intent_dir)
|
|
211
|
+
unless report_model[:ok]
|
|
212
|
+
warn "end-intent: graph.md failed to parse (#{report_model[:errors].join('; ')}); falling back to the backfill"
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
original_exists = File.exist?(outcome_path)
|
|
217
|
+
original_content = original_exists ? File.read(outcome_path) : nil
|
|
218
|
+
|
|
219
|
+
generator.call(intent_dir, disposition: disposition)
|
|
220
|
+
|
|
221
|
+
guard_reason = OutcomeGuard.reason(intent_dir, disposition)
|
|
222
|
+
hollow = hollow_report_reason(intent_dir, disposition)
|
|
223
|
+
if guard_reason || hollow
|
|
224
|
+
if original_exists
|
|
225
|
+
File.write(outcome_path, original_content)
|
|
226
|
+
elsif File.exist?(outcome_path)
|
|
227
|
+
File.delete(outcome_path)
|
|
228
|
+
end
|
|
229
|
+
warn "end-intent: generated outcome.md would be refused (#{[guard_reason, hollow].compact.join('; ')}); " \
|
|
230
|
+
"falling back to the backfill"
|
|
231
|
+
else
|
|
232
|
+
warn "end-intent: outcome.md generated from graph.md and the ledger"
|
|
233
|
+
end
|
|
234
|
+
rescue StandardError => e
|
|
235
|
+
warn "end-intent: outcome generation crashed (#{e.message}); proceeding without it"
|
|
236
|
+
end
|
|
237
|
+
|
|
174
238
|
# Backfill (intent 308): write the judgment documents that are missing or still the
|
|
175
239
|
# placeholder from the record (BackfillIntent). Fail-open: a crash here warns and the
|
|
176
240
|
# close proceeds, the same contract as the structure check below.
|
|
@@ -215,7 +279,137 @@ end
|
|
|
215
279
|
# Replace the body of the intent file's `## Outcome` section with `summary`,
|
|
216
280
|
# leaving every other section and the frontmatter untouched. No-op (returns
|
|
217
281
|
# content unchanged) when summary is blank or the section is not found.
|
|
282
|
+
# 317a S9 (D7): the self-render gate. Renders the delivered screen's sources on
|
|
283
|
+
# the intent's own record and names what would come out hollow. This is intent
|
|
284
|
+
# 308's ONE deliberate exception to report-and-proceed: a hollow DELIVERED
|
|
285
|
+
# close is refused with exit 7 (--allow-hollow-report overrides), because 317
|
|
286
|
+
# closed exactly this way and the owner read a screen full of "not recorded".
|
|
287
|
+
# Abandoned closes and machine-backfilled outcomes (the backfill marker) are
|
|
288
|
+
# exempt: the reader cannot expect labels the writer never had.
|
|
289
|
+
#
|
|
290
|
+
# Intent 334 (G1, fold A3; post-execution review, blocking 2): the legacy
|
|
291
|
+
# "S3" label grammar applies only to actions/*.md headings, and the node id
|
|
292
|
+
# grammar (kind prefix n/v/d/r plus digits, D1r) applies only to nodes/*.md
|
|
293
|
+
# headings - never both over both directories. This repo's own house words
|
|
294
|
+
# for plan review and post-execution review are "v1" and "v2"; widening the
|
|
295
|
+
# node-id alternative onto actions/ headings turned that prose into a
|
|
296
|
+
# manufactured label and refused legitimate legacy closes at exit 7.
|
|
297
|
+
ACTION_ID_LABEL_RE = /\AS\d+\z/.freeze
|
|
298
|
+
NODE_ID_LABEL_RE = /\A[nvdr][1-9]\d*\z/.freeze
|
|
299
|
+
|
|
300
|
+
# The id-shaped tokens found in `paths`' headings, matched against
|
|
301
|
+
# `id_label_re` only - the caller picks the grammar for the directory.
|
|
302
|
+
def heading_id_labels(paths, id_label_re)
|
|
303
|
+
paths.flat_map do |path|
|
|
304
|
+
ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
|
|
305
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).select { |tok| tok.match?(id_label_re) }
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# 339 S9 (D18): a node id counts as a label in an actions/*.md heading too,
|
|
311
|
+
# but only when it stands alone as one of the heading's " - "-delimited
|
|
312
|
+
# segments - this repo's own convention, "### S1 - n1 - Title" (this action
|
|
313
|
+
# file's own headings, post-fold) or the bare "### n1 - Title". Never as a
|
|
314
|
+
# free word inside ordinary prose: the comment above ACTION_ID_LABEL_RE
|
|
315
|
+
# records exactly why intent 334 restricted the node-id grammar to
|
|
316
|
+
# nodes/*.md headings in the first place, and that reasoning does not change
|
|
317
|
+
# here - a word-scan widened onto actions/ headings would let this repo's own
|
|
318
|
+
# house words ("v1", "v2" for plan review and post-execution review)
|
|
319
|
+
# manufacture a label out of ordinary prose and refuse a legitimate legacy
|
|
320
|
+
# close. Segment position is what a word-scan cannot see: "v2" embedded
|
|
321
|
+
# mid-sentence is never its own " - "-delimited segment, but a heading built
|
|
322
|
+
# to this convention always puts the node id in one.
|
|
323
|
+
def heading_node_id_segments(paths, id_label_re)
|
|
324
|
+
paths.flat_map do |path|
|
|
325
|
+
ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
|
|
326
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/\s+-\s+/).map(&:strip).select { |seg| seg.match?(id_label_re) }
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def hollow_report_reason(intent_dir, disposition)
|
|
332
|
+
return nil unless disposition == "delivered"
|
|
333
|
+
|
|
334
|
+
outcome = File.join(intent_dir, "outcome.md")
|
|
335
|
+
return nil unless File.exist?(outcome)
|
|
336
|
+
text = File.read(outcome)
|
|
337
|
+
return nil if text.include?("<!-- backfilled from the record by end-intent on ")
|
|
338
|
+
|
|
339
|
+
# The gate's evidence bar (A7/B7): it judges only records that CLAIM a
|
|
340
|
+
# labeled convention - at least one actions/*.md heading carrying an
|
|
341
|
+
# S-label or a node-id segment, or at least one nodes/*.md heading carrying
|
|
342
|
+
# a node id ("n1", "v2", "d3", "r4"). A terse legacy close with no labeled
|
|
343
|
+
# matrix stays report-and-proceed (intent 334, G1, D10r).
|
|
344
|
+
#
|
|
345
|
+
# Every matching token per heading is kept, not just the first (fold A3):
|
|
346
|
+
# a legacy actions/ heading can carry BOTH an S-label and prose that reads
|
|
347
|
+
# like a node id ("### The v2 rewrite (S3)"). Restricting the word-scan
|
|
348
|
+
# node-id grammar to nodes/*.md headings (post-execution review, blocking
|
|
349
|
+
# 2) means "v2" in that heading is never even tested against it, so the
|
|
350
|
+
# heading's real "S3" label is never at risk.
|
|
351
|
+
#
|
|
352
|
+
# 339 S9 (D18): a graph-era action file headed by node id alone ("### n1 -
|
|
353
|
+
# Title", no S-label) used to yield an empty label set here and the gate
|
|
354
|
+
# returned before judging anything - every such record closed unjudged by
|
|
355
|
+
# silent exemption. `heading_node_id_segments` widens the actions/*.md side
|
|
356
|
+
# to see that shape without reopening the word-scan hole above: it matches
|
|
357
|
+
# a node id only when the heading's own " - "-delimited segments carry one,
|
|
358
|
+
# never a free word in prose. The node_paths call stays exactly as it was;
|
|
359
|
+
# nodes/*.md headings already resolve node ids correctly (test_node_id_
|
|
360
|
+
# labels_are_recognized) and their heading grammar is controlled, so a
|
|
361
|
+
# word-scan there carries none of the prose risk.
|
|
362
|
+
action_paths = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort
|
|
363
|
+
node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
|
|
364
|
+
heading_labels = (heading_id_labels(action_paths, ACTION_ID_LABEL_RE) +
|
|
365
|
+
heading_node_id_segments(action_paths, NODE_ID_LABEL_RE) +
|
|
366
|
+
heading_id_labels(node_paths, NODE_ID_LABEL_RE)).uniq
|
|
367
|
+
return nil if heading_labels.empty?
|
|
368
|
+
|
|
369
|
+
rows = ReportScreen.delivered_rows(intent_dir)
|
|
370
|
+
return "outcome.md ## Delivered renders no rows although a labeled action matrix exists" if rows.empty?
|
|
371
|
+
|
|
372
|
+
needs = ReportScreen.section_of(text, "## Needs you").gsub(/<!--.*?-->/m, "").strip
|
|
373
|
+
if !needs.empty? && needs != "None" && ReportScreen.needs_you_rows(intent_dir).empty?
|
|
374
|
+
return "## Needs you has content that would render as None"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
labels = rows.map { |r| r[:label] }
|
|
378
|
+
if (labels & heading_labels).empty?
|
|
379
|
+
return "delivered row labels (#{labels.first(3).join(', ')}...) match no action-file heading (#{heading_labels.first(3).join(', ')}...)"
|
|
380
|
+
end
|
|
381
|
+
if rows.all? { |r| ReportScreen.proven_by(intent_dir, r[:label]) == ReportScreen::NOT_RECORDED }
|
|
382
|
+
return "every Proven-by cell renders not recorded although labeled action headings exist"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
nil
|
|
386
|
+
rescue StandardError => e
|
|
387
|
+
warn "end-intent: hollow-report gate crashed (#{e.message}); proceeding without it"
|
|
388
|
+
nil
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# 317a S7 (D5, A8): stamp the live lock's run_mode into outcome.md frontmatter
|
|
392
|
+
# INSIDE the close step - after the gate, before the INDEX move and the store
|
|
393
|
+
# commit - so the stamped file rides the close commit and mode survives disarm.
|
|
394
|
+
def stamp_outcome_mode(intent_dir)
|
|
395
|
+
data = Lock.read(intent_dir)
|
|
396
|
+
run_mode = data && data["run_mode"].to_s
|
|
397
|
+
return if run_mode.nil? || run_mode.empty?
|
|
398
|
+
|
|
399
|
+
path = File.join(intent_dir, "outcome.md")
|
|
400
|
+
return unless File.exist?(path)
|
|
401
|
+
text = File.read(path)
|
|
402
|
+
return unless text.start_with?("---")
|
|
403
|
+
head, body = text[3..].split("---", 2)
|
|
404
|
+
return if body.nil? || head.match?(/^mode:/)
|
|
405
|
+
|
|
406
|
+
File.write(path, "---#{head}mode: #{run_mode}\n---#{body}")
|
|
407
|
+
rescue StandardError => e
|
|
408
|
+
warn "end-intent: mode stamp failed (#{e.message}); proceeding without it"
|
|
409
|
+
end
|
|
410
|
+
|
|
218
411
|
def stamp_outcome_summary(content, summary)
|
|
412
|
+
|
|
219
413
|
return content if summary.nil? || summary.to_s.strip.empty?
|
|
220
414
|
|
|
221
415
|
lines = content.lines
|
|
@@ -645,6 +839,11 @@ def main(argv)
|
|
|
645
839
|
end
|
|
646
840
|
end
|
|
647
841
|
|
|
842
|
+
# 0a. Generate outcome.md from graph.md and the ledger, when there is one
|
|
843
|
+
# (intent 339, G6, spec D10) - before the backfill, so a real graph never
|
|
844
|
+
# falls through to the terse backfill it exists to replace.
|
|
845
|
+
run_generate_outcome(intent_dir, disposition: disposition)
|
|
846
|
+
|
|
648
847
|
# 1. Backfill from the record (intent 308), then the structure self-check and the
|
|
649
848
|
# outcome guard, both report-only since 308.
|
|
650
849
|
run_backfill(intent_dir, store: store, id: id, disposition: disposition,
|
|
@@ -654,6 +853,26 @@ def main(argv)
|
|
|
654
853
|
reason = OutcomeGuard.reason(intent_dir, disposition)
|
|
655
854
|
warn "end-intent: outcome.md: #{reason} (proceeding; doctor --intent #{id} reports it)" if reason
|
|
656
855
|
|
|
856
|
+
# 1a2. The self-render gate (317a S9, D7): refuse a hollow delivered close
|
|
857
|
+
# BEFORE any terminal mutation - INDEX, savepoint, and store stay untouched
|
|
858
|
+
# on exit 7 (A9).
|
|
859
|
+
hollow = hollow_report_reason(intent_dir, disposition)
|
|
860
|
+
if hollow
|
|
861
|
+
if opts[:allow_hollow_report]
|
|
862
|
+
warn "end-intent: hollow delivered report overridden (--allow-hollow-report): #{hollow}"
|
|
863
|
+
else
|
|
864
|
+
warn "end-intent: refusing a hollow delivered close: #{hollow}"
|
|
865
|
+
warn "end-intent: write outcome.md's ## Delivered as the labeled | Row | What | table " \
|
|
866
|
+
"matching the action-file headings (templates/outcome.md shows the shape), " \
|
|
867
|
+
"or pass --allow-hollow-report to close anyway"
|
|
868
|
+
exit 7
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# 1a3. Mode stamp (317a S7, D5): run_mode into outcome frontmatter while the
|
|
873
|
+
# lock still exists, so the stamped file rides the close commit.
|
|
874
|
+
stamp_outcome_mode(intent_dir)
|
|
875
|
+
|
|
657
876
|
# 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
|
|
658
877
|
if opts[:outcome_summary]
|
|
659
878
|
intent_file = Savepoint.intent_file(intent_dir)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Usage: hook-call-budget (reads the PreToolUse stdin JSON payload)
|
|
6
|
+
#
|
|
7
|
+
# Intent 355, n2 (review fix n8). A node gets a call budget by kind
|
|
8
|
+
# (RunnerPolicy.call_cap): a cap on tool calls per attempt. RunnerDispatch
|
|
9
|
+
# writes the cap onto the running line as `calls=` and states it as one
|
|
10
|
+
# sentence in the node's own input, so the executor learns the number before
|
|
11
|
+
# it ever gets denied.
|
|
12
|
+
#
|
|
13
|
+
# Inside a subagent the hook input's session_id and transcript_path are the
|
|
14
|
+
# MAIN session's, never the subagent's own (v1 finding). So this hook caps
|
|
15
|
+
# ONLY a subagent call: agent_id present in the payload. A call with no
|
|
16
|
+
# agent_id (the main thread, a plain conversation, a live --agent session's
|
|
17
|
+
# own turns) is never capped and the decision returns before any file is
|
|
18
|
+
# touched.
|
|
19
|
+
#
|
|
20
|
+
# Which node a subagent is working is read from the subagent's OWN
|
|
21
|
+
# transcript, at <dirname(transcript_path)>/<session_id>/subagents/
|
|
22
|
+
# agent-<agent_id>.jsonl (the main transcript_path plus the main session_id
|
|
23
|
+
# plus the subagent's own agent_id): its first user record carries the
|
|
24
|
+
# dispatch prompt, and the lead/spawn block always pastes the node's packet
|
|
25
|
+
# path into that prompt (PACKET_PATH_RE). The packet path names the intent
|
|
26
|
+
# directory directly (its grandparent), so this hook never scans the store to
|
|
27
|
+
# find it. No packet path in that first prompt means allow.
|
|
28
|
+
#
|
|
29
|
+
# The cap applies only when that node's LATEST transition line in
|
|
30
|
+
# <intent>/savepoint.md is `running` and carries `calls=`; a later `done` or
|
|
31
|
+
# `failed_verification` line (or a sibling node's own running line) means no
|
|
32
|
+
# cap. Past the cap, a Bash call that runs only a safe git verb (add, commit,
|
|
33
|
+
# status, rev-parse, log) is still allowed, so the denial the hook itself
|
|
34
|
+
# orders (commit what is green) is never the thing the hook blocks.
|
|
35
|
+
#
|
|
36
|
+
# Pure and dependency-injected down to the paths it reads; the trailing block
|
|
37
|
+
# guarded by `$PROGRAM_NAME == __FILE__` is this file's only I/O (stdin,
|
|
38
|
+
# stdout), so a test can `load` this file and call HookCallBudget's module
|
|
39
|
+
# functions directly without spawning a process or touching stdin.
|
|
40
|
+
|
|
41
|
+
require "json"
|
|
42
|
+
require_relative "lib/node_ledger"
|
|
43
|
+
require_relative "lib/savepoint"
|
|
44
|
+
|
|
45
|
+
module HookCallBudget
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
RETURN_INSTRUCTION = "commit what is green and return failed_verification reason=call_budget"
|
|
49
|
+
|
|
50
|
+
# A node id, then "--a", then an attempt number, inside a /packets/
|
|
51
|
+
# directory: the exact shape RunnerDispatch's spawn block pastes into a
|
|
52
|
+
# subagent's own first prompt. Captures [full_path, node].
|
|
53
|
+
PACKET_PATH_RE = %r{(\S+/packets/([a-z]{1,2}\d+)--a\d+\.packet)}.freeze
|
|
54
|
+
|
|
55
|
+
FIRST_RECORD_SCAN_LIMIT = 20
|
|
56
|
+
|
|
57
|
+
GIT_ALLOWED_VERBS = %w[add commit status rev-parse log].freeze
|
|
58
|
+
GIT_UNSAFE_RE = /[;&|\n]|\$\(|`/.freeze
|
|
59
|
+
|
|
60
|
+
# The one decision: nil (allow, no output) or a deny payload Hash. Never
|
|
61
|
+
# raises across its own boundary.
|
|
62
|
+
def decide(payload)
|
|
63
|
+
return nil unless payload.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
agent_id = payload["agent_id"]
|
|
66
|
+
return nil if agent_id.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
session_id = payload["session_id"].to_s
|
|
69
|
+
transcript_path = payload["transcript_path"].to_s
|
|
70
|
+
return nil if session_id.empty? || transcript_path.empty?
|
|
71
|
+
|
|
72
|
+
sub_path = subagent_transcript_path(transcript_path, session_id, agent_id)
|
|
73
|
+
|
|
74
|
+
prompt = begin
|
|
75
|
+
first_user_prompt_text(sub_path)
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
return nil unless prompt
|
|
80
|
+
|
|
81
|
+
match = PACKET_PATH_RE.match(prompt)
|
|
82
|
+
return nil unless match
|
|
83
|
+
|
|
84
|
+
node = match[2]
|
|
85
|
+
intent_dir = File.dirname(File.dirname(match[1]))
|
|
86
|
+
|
|
87
|
+
cap = node_cap(intent_dir, node)
|
|
88
|
+
return nil unless cap
|
|
89
|
+
|
|
90
|
+
count = begin
|
|
91
|
+
count_tool_use_blocks(sub_path)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
note_unreadable(intent_dir, session_id, e.message)
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return nil if count <= cap
|
|
98
|
+
return nil if git_only_bash_call?(payload)
|
|
99
|
+
|
|
100
|
+
deny_payload("#{node} is past its call budget (#{count}/#{cap} tool calls); #{RETURN_INSTRUCTION}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def subagent_transcript_path(main_transcript_path, session_id, agent_id)
|
|
104
|
+
File.join(File.dirname(main_transcript_path), session_id, "subagents", "agent-#{agent_id}.jsonl")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The first `"type":"user"` record in `path`'s own transcript, rendered to
|
|
108
|
+
# plain text (message.content as a string, or the joined text blocks of a
|
|
109
|
+
# content array). Scans at most FIRST_RECORD_SCAN_LIMIT lines: the dispatch
|
|
110
|
+
# prompt is always the transcript's opening record, so this never touches
|
|
111
|
+
# the bulk of a long-running subagent's history. Raises on a genuinely
|
|
112
|
+
# unreadable path (a directory, a permission error); the caller decides
|
|
113
|
+
# what that means.
|
|
114
|
+
def first_user_prompt_text(path)
|
|
115
|
+
File.open(path) do |f|
|
|
116
|
+
f.each_line.first(FIRST_RECORD_SCAN_LIMIT).each do |raw|
|
|
117
|
+
record = begin
|
|
118
|
+
JSON.parse(raw)
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
next unless record.is_a?(Hash) && record["type"] == "user"
|
|
123
|
+
|
|
124
|
+
return message_text(record)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def message_text(record)
|
|
131
|
+
content = record.dig("message", "content")
|
|
132
|
+
case content
|
|
133
|
+
when String
|
|
134
|
+
content
|
|
135
|
+
when Array
|
|
136
|
+
content.select { |b| b.is_a?(Hash) && b["type"] == "text" }.map { |b| b["text"].to_s }.join("\n")
|
|
137
|
+
else
|
|
138
|
+
""
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The cap named on `node`'s LATEST (file-order) non-torn transition line in
|
|
143
|
+
# `intent_dir`/savepoint.md, or nil when that line is not `running`, carries
|
|
144
|
+
# no `calls=`, or does not exist at all (matrix 2.6/2.6b: a later done/
|
|
145
|
+
# failed_verification line, or a sibling node's own running line, never
|
|
146
|
+
# caps this node).
|
|
147
|
+
def node_cap(intent_dir, node)
|
|
148
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
149
|
+
return nil unless File.exist?(path)
|
|
150
|
+
|
|
151
|
+
latest = NodeLedger.entries(path).select { |e| !e[:torn] && e[:subject] == node }.last
|
|
152
|
+
return nil unless latest && latest[:state] == "running"
|
|
153
|
+
|
|
154
|
+
cap = (latest[:fields] || {})["calls"]
|
|
155
|
+
cap ? cap.to_i : nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Streams `path` a line at a time, counting `"type":"tool_use"` content
|
|
159
|
+
# blocks with a plain regex rather than a full JSON parse per line (matrix
|
|
160
|
+
# 2.5/2.8): a 3 MB transcript counts in well under 50 ms this way.
|
|
161
|
+
def count_tool_use_blocks(path)
|
|
162
|
+
count = 0
|
|
163
|
+
File.foreach(path) { |ln| count += ln.scan(/"type"\s*:\s*"tool_use"/).length }
|
|
164
|
+
count
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Past the cap, a Bash call is still allowed when its command runs only one
|
|
168
|
+
# safe git verb (matrix 2.7/D3): the git add/commit the denial itself
|
|
169
|
+
# orders must never be the call the hook blocks. Any chaining token
|
|
170
|
+
# (`;`, `&`, `|`, a newline, a subshell or a backtick) refuses the whole
|
|
171
|
+
# command, so a compound command can never smuggle an unrelated call past
|
|
172
|
+
# the cap riding on a leading `git`.
|
|
173
|
+
def git_only_bash_call?(payload)
|
|
174
|
+
return false unless payload["tool_name"].to_s == "Bash"
|
|
175
|
+
|
|
176
|
+
command = payload.dig("tool_input", "command").to_s.strip
|
|
177
|
+
return false if command.empty? || GIT_UNSAFE_RE.match?(command)
|
|
178
|
+
|
|
179
|
+
tokens = command.split(/\s+/)
|
|
180
|
+
return false unless tokens.shift == "git"
|
|
181
|
+
|
|
182
|
+
tokens.shift(2) if tokens.first == "-C"
|
|
183
|
+
GIT_ALLOWED_VERBS.include?(tokens.first)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def deny_payload(reason)
|
|
187
|
+
{ "hookSpecificOutput" => { "hookEventName" => "PreToolUse", "permissionDecision" => "deny",
|
|
188
|
+
"permissionDecisionReason" => reason } }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Best-effort note that the transcript could not be counted (matrix 2.9): a
|
|
192
|
+
# plain milestone line, the same shared primitive Commit/Review/Report
|
|
193
|
+
# lines already use, dedup'd on (stage, milestone) so a repeat failure in
|
|
194
|
+
# the same session with the same message is not written twice.
|
|
195
|
+
def note_unreadable(intent_dir, session_id, message, now: Time.now)
|
|
196
|
+
Savepoint.append_savepoint_line(intent_dir, "CallBudget", "transcript unreadable for #{session_id}: #{message}",
|
|
197
|
+
now)
|
|
198
|
+
rescue StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if $PROGRAM_NAME == __FILE__
|
|
204
|
+
raw = begin
|
|
205
|
+
$stdin.read
|
|
206
|
+
rescue StandardError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
if raw && !raw.strip.empty?
|
|
211
|
+
payload = begin
|
|
212
|
+
JSON.parse(raw)
|
|
213
|
+
rescue StandardError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
result = HookCallBudget.decide(payload)
|
|
218
|
+
puts JSON.generate(result) if result
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
exit 0
|
|
222
|
+
end
|
package/scripts/hook-capture
CHANGED
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
# Usage: hook-capture (reads the UserPromptSubmit stdin JSON payload)
|
|
6
6
|
# Intent 298. One UserPromptSubmit process replacing hook-continue,
|
|
7
7
|
# hook-future-intent-check, and hook-auto-arm: it appends a pending line to
|
|
8
|
-
# the session day ledger
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# the session day ledger and detects "continue" and "auto" prompts. Every job
|
|
9
|
+
# runs in its own rescue and the hook always exits 0 (spec D2). Intent 345
|
|
10
|
+
# (D7, 323) removed the per-prompt Future-intent hint step entirely.
|
|
11
11
|
|
|
12
12
|
require "json"
|
|
13
|
-
require "yaml"
|
|
14
13
|
require "open3"
|
|
15
14
|
require "fileutils"
|
|
16
15
|
require_relative "lib/session_ledger"
|
|
@@ -31,7 +30,8 @@ end
|
|
|
31
30
|
exit 0 unless payload.is_a?(Hash)
|
|
32
31
|
|
|
33
32
|
session_id = payload["session_id"].to_s
|
|
34
|
-
prompt = payload["
|
|
33
|
+
prompt = payload["prompt"].to_s
|
|
34
|
+
prompt = payload["user_prompt"].to_s if prompt.empty?
|
|
35
35
|
cwd = payload["cwd"].to_s
|
|
36
36
|
cwd = Dir.pwd if cwd.empty?
|
|
37
37
|
|
|
@@ -41,98 +41,26 @@ templates = File.expand_path("../templates", __dir__)
|
|
|
41
41
|
sid = SessionLedger.short_session_id(nil, session_id)
|
|
42
42
|
today = SessionLedger.day_id
|
|
43
43
|
|
|
44
|
-
# --- Extracted matching logic, shared by both the global and project store
|
|
45
|
-
# passes of job (f) below (mirrors hook-future-intent-check verbatim). ---
|
|
46
|
-
def future_intent_matches(store_root, message)
|
|
47
|
-
index_path = File.join(store_root, "INDEX.md")
|
|
48
|
-
return [] unless File.exist?(index_path)
|
|
49
|
-
|
|
50
|
-
lines = File.readlines(index_path)
|
|
51
|
-
future_dirs = []
|
|
52
|
-
section = nil
|
|
53
|
-
|
|
54
|
-
lines.each do |line|
|
|
55
|
-
section = :future if line.start_with?("## Future")
|
|
56
|
-
section = nil if line.start_with?("## ") && !line.start_with?("## Future")
|
|
57
|
-
next unless section == :future && line.strip.start_with?("- [")
|
|
58
|
-
|
|
59
|
-
future_dirs << $1 if line =~ /store\/([\w-]+)\//
|
|
60
|
-
end
|
|
61
|
-
return [] if future_dirs.empty?
|
|
62
|
-
|
|
63
|
-
matches = []
|
|
64
|
-
message_words = message.split(/\W+/).reject { |w| w.length < 4 }
|
|
65
|
-
|
|
66
|
-
future_dirs.each do |dir|
|
|
67
|
-
intent_path = File.join(store_root, "store", dir, "#{dir}.md")
|
|
68
|
-
next unless File.exist?(intent_path)
|
|
69
|
-
|
|
70
|
-
content = File.read(intent_path)
|
|
71
|
-
|
|
72
|
-
tags = []
|
|
73
|
-
tags = $1.split(",").map(&:strip).map(&:downcase) if content =~ /^tags:\s*\[([^\]]+)\]/
|
|
74
|
-
|
|
75
|
-
intent_name = ""
|
|
76
|
-
intent_name = $1.downcase if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
|
|
77
|
-
|
|
78
|
-
keywords = (tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }).map(&:downcase).uniq
|
|
79
|
-
|
|
80
|
-
matched_keywords = keywords.select { |kw| message.include?(kw) }
|
|
81
|
-
name_matches = message_words.select { |w| intent_name.include?(w) }
|
|
82
|
-
matched_keywords = (matched_keywords + name_matches).uniq
|
|
83
|
-
|
|
84
|
-
next if matched_keywords.empty?
|
|
85
|
-
|
|
86
|
-
index_line = lines.find { |l| l.include?(dir) }&.strip || "#{dir} - #{intent_name}"
|
|
87
|
-
matches << { "index_line" => index_line, "keywords" => matched_keywords }
|
|
88
|
-
end
|
|
89
|
-
matches
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# The store_root that carries the current project's own store, resolved from
|
|
93
|
-
# cwd through projects.yml, mirroring SessionLedger.project_slug's matching
|
|
94
|
-
# but returning nil (not "global") when nothing matches, since there is then
|
|
95
|
-
# no distinct second store to hint against.
|
|
96
|
-
def resolve_project_store_root(cwd, plastic_home)
|
|
97
|
-
projects_path = File.join(plastic_home, "projects.yml")
|
|
98
|
-
return nil unless File.exist?(projects_path)
|
|
99
|
-
|
|
100
|
-
data = begin
|
|
101
|
-
YAML.safe_load(File.read(projects_path))
|
|
102
|
-
rescue StandardError
|
|
103
|
-
nil
|
|
104
|
-
end
|
|
105
|
-
data = {} unless data.is_a?(Hash)
|
|
106
|
-
projects = data["projects"].is_a?(Hash) ? data["projects"] : {}
|
|
107
|
-
expanded_cwd = File.expand_path(cwd)
|
|
108
|
-
|
|
109
|
-
matches = projects.filter_map do |slug, info|
|
|
110
|
-
next unless info.is_a?(Hash) && slug.is_a?(String)
|
|
111
|
-
|
|
112
|
-
path = info["path"]
|
|
113
|
-
next unless path
|
|
114
|
-
|
|
115
|
-
root = File.expand_path(path)
|
|
116
|
-
next unless expanded_cwd == root || expanded_cwd.start_with?("#{root}#{File::SEPARATOR}")
|
|
117
|
-
|
|
118
|
-
[root.length, slug]
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
best = matches.max_by { |(length, _slug)| length }
|
|
122
|
-
best ? File.join(plastic_home, "projects", best[1]) : nil
|
|
123
|
-
end
|
|
124
|
-
|
|
125
44
|
def truncate(text, max)
|
|
126
45
|
return text if text.length <= max
|
|
127
46
|
|
|
128
47
|
"#{text[0, max - 3]}..."
|
|
129
48
|
end
|
|
130
49
|
|
|
131
|
-
# --- (a) tmp root + heartbeat,
|
|
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
|
+
# means session start never ran for this session at all (a `-p` print
|
|
53
|
+
# 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.
|
|
132
58
|
begin
|
|
133
|
-
SessionLedger.
|
|
134
|
-
|
|
135
|
-
|
|
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))
|
|
62
|
+
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
63
|
+
end
|
|
136
64
|
rescue StandardError
|
|
137
65
|
nil
|
|
138
66
|
end
|
|
@@ -150,11 +78,14 @@ rescue StandardError
|
|
|
150
78
|
end
|
|
151
79
|
|
|
152
80
|
# --- (c) pending checklist line -------------------------------------------
|
|
81
|
+
# Gated on SessionLedger.capture_worthy?(prompt) (spec D2), a pure predicate
|
|
82
|
+
# over the RAW prompt so it sees the true first character and multi-line
|
|
83
|
+
# shape; the line text itself is still the sanitized, truncated copy.
|
|
153
84
|
unless skip_day_ledger
|
|
154
85
|
begin
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
86
|
+
if SessionLedger.capture_worthy?(prompt)
|
|
87
|
+
project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
|
|
88
|
+
clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
|
|
158
89
|
SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
|
|
159
90
|
line = SessionLedger.checklist_line(:pending, sid, project, clean)
|
|
160
91
|
SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
|
|
@@ -170,7 +101,7 @@ system_message = nil
|
|
|
170
101
|
|
|
171
102
|
# --- (d) "continue" cockpit -------------------------------------------------
|
|
172
103
|
begin
|
|
173
|
-
if prompt.
|
|
104
|
+
if prompt.to_s.strip.downcase == "continue"
|
|
174
105
|
dashboard = File.expand_path("dashboard.rb", __dir__)
|
|
175
106
|
if File.exist?(dashboard)
|
|
176
107
|
cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
|
|
@@ -211,25 +142,6 @@ rescue StandardError
|
|
|
211
142
|
nil
|
|
212
143
|
end
|
|
213
144
|
|
|
214
|
-
# --- (f) Future-intent hint, global store then the project's own store -----
|
|
215
|
-
begin
|
|
216
|
-
message = prompt.to_s.downcase
|
|
217
|
-
if message.strip.length >= 10 && message.strip != "continue"
|
|
218
|
-
matches = future_intent_matches(plastic_home, message)
|
|
219
|
-
project_root = resolve_project_store_root(cwd, plastic_home)
|
|
220
|
-
matches += future_intent_matches(project_root, message) if project_root
|
|
221
|
-
|
|
222
|
-
if matches.any?
|
|
223
|
-
parts = ["PLASTIC - Future intents related to this message:\n"]
|
|
224
|
-
matches.each { |m| parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})" }
|
|
225
|
-
parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
|
|
226
|
-
context_parts << parts.join("\n")
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
rescue StandardError
|
|
230
|
-
nil
|
|
231
|
-
end
|
|
232
|
-
|
|
233
145
|
exit 0 if context_parts.empty?
|
|
234
146
|
|
|
235
147
|
payload_out = {
|