@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20

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 (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -0
@@ -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)
@@ -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, detects "continue" and "auto" prompts, and hints at
9
- # matching Future intents. Every job runs in its own rescue and the hook
10
- # always exits 0 (spec D2).
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["user_prompt"].to_s
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, unconditional -------------------------------
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.ensure_tmp_root(store)
134
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
135
- File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
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
- project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
156
- clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
157
- if clean.length >= 10
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.match?(/\bcontinue\b/i)
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 = {
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-message-display (intent 316a, O4): the MessageDisplay hook CLI. Reads
6
+ # the harness's per-chunk JSON payload from stdin, hands it to MessageDisplay
7
+ # (the pure handler class), and prints the hookSpecificOutput envelope when a
8
+ # String comes back. Always exits 0, whatever happens — a raised exception
9
+ # here must never surface as a non-zero exit or stray stderr on an ordinary
10
+ # chunk of an ordinary message (matrix 31).
11
+ #
12
+ # This script is the one place in the O4 stack allowed to read ENV, the
13
+ # clock, or Dir.tmpdir: MessageDisplay itself takes tmp_root/plastic_home/
14
+ # color/now as constructor arguments and touches none of them directly.
15
+ #
16
+ # Claude adapter: Claude Code only; the core is harness-agnostic.
17
+
18
+ # 317a (B1): the requires sit inside their own rescue - a missing or broken
19
+ # lib file must never surface as a non-zero exit or stderr on a chunk of an
20
+ # ordinary message (LoadError is a ScriptError, outside StandardError).
21
+ begin
22
+ require "json"
23
+ require "time"
24
+ require "tmpdir"
25
+ require "yaml"
26
+ require_relative "lib/message_display"
27
+ rescue ScriptError, StandardError
28
+ exit 0
29
+ end
30
+
31
+ def color_enabled?(plastic_home)
32
+ return false unless ENV["NO_COLOR"].to_s.empty?
33
+
34
+ cfg_path = File.join(plastic_home, "config.yml")
35
+ return true unless File.exist?(cfg_path)
36
+
37
+ cfg = YAML.safe_load(File.read(cfg_path))
38
+ display = cfg.is_a?(Hash) ? cfg["display"] : nil
39
+ return true unless display.is_a?(Hash)
40
+
41
+ display.fetch("ansi_screen", true) != false
42
+ rescue StandardError
43
+ true
44
+ end
45
+
46
+ begin
47
+ raw = $stdin.tty? ? "" : $stdin.read
48
+ payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
49
+
50
+ if payload.is_a?(Hash)
51
+ tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
52
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
53
+
54
+ # PLASTIC_HOOK_TRACE=<file> (331a1): opt-in, off by default, one JSON
55
+ # object appended per chunk. This script is the only place allowed to
56
+ # read the environment; MessageDisplay takes the sink as an argument.
57
+ trace_path = ENV["PLASTIC_HOOK_TRACE"].to_s
58
+ trace = trace_path.empty? ? nil : MessageDisplay.file_trace(trace_path)
59
+
60
+ handler = MessageDisplay.new(
61
+ tmp_root: tmp_root,
62
+ plastic_home: plastic_home,
63
+ color: color_enabled?(plastic_home),
64
+ now: Time.now,
65
+ trace: trace,
66
+ )
67
+ result = handler.handle(payload)
68
+
69
+ if result.is_a?(String)
70
+ puts JSON.generate(
71
+ "hookSpecificOutput" => {
72
+ "hookEventName" => "MessageDisplay",
73
+ "displayContent" => result,
74
+ },
75
+ )
76
+ end
77
+ end
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ exit 0
@@ -135,13 +135,21 @@ unless skip_day_ledger
135
135
  end
136
136
  end
137
137
 
138
- # --- (d) heartbeat, always -------------------------------------------------
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.
139
145
  begin
140
146
  store ||= File.join(plastic_home, "store")
141
147
  sid ||= SessionLedger.short_session_id(nil, session_id)
142
- SessionLedger.ensure_tmp_root(store)
143
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
144
- File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
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))
151
+ File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
152
+ end
145
153
  rescue StandardError
146
154
  nil
147
155
  end
@@ -18,6 +18,24 @@ require_relative "lib/day_summary"
18
18
  index_path, plastic_home, mode, plugin_root = ARGV
19
19
  exit 0 unless index_path && plastic_home && mode
20
20
 
21
+ # --- session id: the stdin payload's session_id, then the env var, then the
22
+ # pid (spec 298 D1, row G, spec D4). Guarded on $stdin.tty? so a human running
23
+ # this hook by hand at a real terminal never blocks on a read that never gets
24
+ # an EOF; every real harness invocation pipes the SessionStart JSON payload,
25
+ # never attaches a tty. Malformed or empty stdin (or no payload id) falls
26
+ # through to the same env-var-then-pid chain this always had.
27
+ stdin_payload = begin
28
+ if $stdin.tty?
29
+ nil
30
+ else
31
+ raw = $stdin.read
32
+ raw && !raw.strip.empty? ? JSON.parse(raw) : nil
33
+ end
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ payload_session_id = stdin_payload.is_a?(Hash) ? stdin_payload["session_id"].to_s : ""
38
+
21
39
  # Plastic home and the store are two different paths (intent 231). The shim passes
22
40
  # home (~/.plastic) as argument 2; the store lives one level below it. Compose the
23
41
  # store exactly once here, so no later line re-derives it and no path can gain a
@@ -361,7 +379,11 @@ begin
361
379
  author = "session" if author.empty?
362
380
  SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
363
381
 
364
- session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
382
+ session = if !payload_session_id.empty?
383
+ payload_session_id
384
+ else
385
+ ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
386
+ end
365
387
  sid = SessionLedger.short_session_id(nil, session)
366
388
  SessionLedger.ensure_tmp_root(store_dir)
367
389
  FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # index-projection - the CLI over IndexProjection (intent 337, G4): prints
6
+ # the drift between INDEX.md and the intent ledgers for one store root
7
+ # (INDEX.md plus a store/ directory), exits non-zero when any drift exists.
8
+ # --write renders INDEX.md's four status sections from the projection
9
+ # through AtomicWrite; every other byte, including ## Clusters and
10
+ # ## Relocated, is untouched.
11
+ #
12
+ # Usage:
13
+ # index-projection <store_root> [--write]
14
+ #
15
+ # Exit codes:
16
+ # 0 - no drift (or, with --write, the write landed cleanly)
17
+ # 1 - drift exists (a real terminal-ledger conflict, an INDEX entry with
18
+ # no directory, or a directory INDEX does not list)
19
+ # 2 - usage: no argument, or the path is not a store root
20
+
21
+ require_relative "lib/index_projection"
22
+
23
+ module IndexProjectionCli
24
+ module_function
25
+
26
+ def usage
27
+ warn "Usage: index-projection <store_root> [--write]"
28
+ end
29
+
30
+ def main(argv)
31
+ args = argv.dup
32
+ write = !!args.delete("--write")
33
+
34
+ root = args.shift
35
+ unless root && Dir.exist?(root)
36
+ warn "index-projection: #{root.inspect} is not a store root"
37
+ usage
38
+ return 2
39
+ end
40
+
41
+ index_path = File.join(root, "INDEX.md")
42
+ store_dir = File.join(root, "store")
43
+ unless File.exist?(index_path)
44
+ warn "index-projection: no INDEX.md at #{index_path}"
45
+ return 2
46
+ end
47
+
48
+ if write
49
+ result = IndexProjection.write(store_dir, index_path: index_path)
50
+ unless result[:ok]
51
+ warn "index-projection: #{result[:error]}"
52
+ return 2
53
+ end
54
+ puts "index-projection: wrote #{index_path} (#{result[:moved].length} moved)"
55
+ return 0
56
+ end
57
+
58
+ analysis = IndexProjection.analyze(store_dir, index_path: index_path)
59
+ print_report(analysis)
60
+
61
+ (analysis[:drift].empty? && analysis[:index_only].empty? && analysis[:directory_only].empty?) ? 0 : 1
62
+ end
63
+
64
+ def print_report(analysis)
65
+ puts "Drift (#{analysis[:drift].length}):"
66
+ analysis[:drift].each { |r| puts " #{r[:id]}: INDEX=#{r[:index_status]} ledger=#{r[:ledger_status]}" }
67
+ puts "INDEX-only (#{analysis[:index_only].length}):"
68
+ analysis[:index_only].each { |r| puts " #{r[:id]} (#{r[:index_status]})" }
69
+ puts "Directory-only (#{analysis[:directory_only].length}):"
70
+ analysis[:directory_only].each { |r| puts " #{r[:id]}" }
71
+ end
72
+ end
73
+
74
+ exit(IndexProjectionCli.main(ARGV)) if $PROGRAM_NAME == __FILE__