@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
@@ -207,11 +207,37 @@ module SessionGit
207
207
 
208
208
  # --- commit message ------------------------------------------------------------
209
209
 
210
- # The first line of `summary`, truncated to MAX_SUBJECT_LENGTH characters,
211
- # with no trailer (spec D5).
210
+ # The first line of `summary`, cut at the last word boundary at or before
211
+ # MAX_SUBJECT_LENGTH characters, falling back to the hard slice when no
212
+ # boundary exists at or before the limit (spec D6, amends spec 300 D5's
213
+ # unconditional `first_line[0, MAX_SUBJECT_LENGTH]`, which cut mid-word).
214
+ # A subject at or under the limit is returned unchanged. Post-execution
215
+ # review item 7: when the character immediately after the 72-char prefix
216
+ # is ITSELF a space, the prefix already ends exactly on a word boundary and
217
+ # needs no trimming at all -- checked before consulting `rindex`, because
218
+ # an earlier internal space inside the 72-char prefix would otherwise make
219
+ # `rindex` walk back past a whole trailing word that fit perfectly.
212
220
  def subject_for(summary)
213
221
  first_line = summary.to_s.split(/\r?\n/, 2).first.to_s.strip
214
- first_line[0, MAX_SUBJECT_LENGTH]
222
+ return first_line if first_line.length <= MAX_SUBJECT_LENGTH
223
+
224
+ cut = first_line[0, MAX_SUBJECT_LENGTH]
225
+ return cut if first_line[MAX_SUBJECT_LENGTH] == " "
226
+
227
+ boundary = cut.rindex(" ")
228
+ boundary ? cut[0, boundary] : cut
229
+ end
230
+
231
+ # The commit body for `summary`/`subject` (spec D6): the full summary when
232
+ # `subject` is a cut-down copy of it, nil when the subject already carries
233
+ # the summary whole (no redundant body on a short, single-line summary).
234
+ # Post-execution review item 4: compares the STRIPPED raw summary, not the
235
+ # raw summary verbatim -- a summary with only trailing/leading whitespace
236
+ # around an otherwise-identical subject must not repeat the same sentence
237
+ # twice as a redundant body.
238
+ def body_for(summary, subject)
239
+ raw = summary.to_s
240
+ raw.strip == subject ? nil : raw
215
241
  end
216
242
 
217
243
  # --- git primitives (all use -C, never cwd) -------------------------------------
@@ -273,9 +299,11 @@ module SessionGit
273
299
  parts.each_cons(2).any? { |a, b| a == ".claude" && b == "worktrees" }
274
300
  end
275
301
 
276
- def stage_and_commit(dir, subject, runner:)
302
+ def stage_and_commit(dir, subject, runner:, body: nil)
277
303
  runner.run("-C", dir, "add", "-A")
278
- runner.run("-C", dir, "commit", "-m", subject)
304
+ args = ["-C", dir, "commit", "-m", subject]
305
+ args += ["-m", body] if body
306
+ runner.run(*args)
279
307
  end
280
308
 
281
309
  def short_sha(dir, runner:)
@@ -310,14 +338,16 @@ module SessionGit
310
338
 
311
339
  flow, flow_notes = load_flow(cwd: cwd, repo: repo, plastic_home: plastic_home, runner: runner)
312
340
  subject = subject_for(summary)
341
+ body = body_for(summary, subject)
313
342
 
314
343
  result =
315
344
  if flow["mode"] == "pull_request"
316
- commit_pull_request(repo: repo, subject: subject, day: day, session: session,
345
+ commit_pull_request(repo: repo, subject: subject, body: body, day: day, session: session,
317
346
  store: effective_store, flow: flow, branch_now: branch_now,
318
347
  runner: runner, gh_runner: gh_runner)
319
348
  else
320
- commit_direct(repo: repo, subject: subject, day: day, flow: flow, branch_now: branch_now, runner: runner)
349
+ commit_direct(repo: repo, subject: subject, body: body, day: day, flow: flow, branch_now: branch_now,
350
+ runner: runner)
321
351
  end
322
352
 
323
353
  return result if flow_notes.empty?
@@ -331,7 +361,7 @@ module SessionGit
331
361
 
332
362
  # --- direct mode (spec D3) --------------------------------------------------------
333
363
 
334
- def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:)
364
+ def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:, body: nil)
335
365
  return note("nothing to commit") unless dirty?(repo, runner: runner)
336
366
  return note("summary is empty after truncation: no commit") if blank?(subject)
337
367
 
@@ -351,10 +381,10 @@ module SessionGit
351
381
  end
352
382
 
353
383
  if branch_now == base || branch_now == session_branch
354
- commit_on_session_branch(repo: repo, subject: subject, base: base,
384
+ commit_on_session_branch(repo: repo, subject: subject, base: base, body: body,
355
385
  session_branch: session_branch, branch_now: branch_now, runner: runner)
356
386
  else
357
- commit_on_other_branch(repo: repo, subject: subject, branch_now: branch_now, runner: runner)
387
+ commit_on_other_branch(repo: repo, subject: subject, body: body, branch_now: branch_now, runner: runner)
358
388
  end
359
389
  end
360
390
 
@@ -367,7 +397,7 @@ module SessionGit
367
397
  # session branch. `current_branch` is re-read after the switch and used
368
398
  # for the commit message instead of trusting the branch this method
369
399
  # intended to reach.
370
- def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:)
400
+ def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:, body: nil)
371
401
  unless branch_exists?(repo, session_branch, runner: runner)
372
402
  create = runner.run("-C", repo, "branch", session_branch, base.to_s)
373
403
  return note("could not create session branch #{session_branch}: #{diagnose(create)}") unless create.success?
@@ -383,11 +413,12 @@ module SessionGit
383
413
  return note("expected to be on #{session_branch} but the checkout is on #{actual_branch.inspect}")
384
414
  end
385
415
 
386
- commit_and_push(dir: repo, push_dir: repo, subject: subject, from: actual_branch, base: base, runner: runner)
416
+ commit_and_push(dir: repo, push_dir: repo, subject: subject, body: body, from: actual_branch, base: base,
417
+ runner: runner)
387
418
  end
388
419
 
389
- def commit_on_other_branch(repo:, subject:, branch_now:, runner:)
390
- res = stage_and_commit(repo, subject, runner: runner)
420
+ def commit_on_other_branch(repo:, subject:, branch_now:, runner:, body: nil)
421
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
391
422
  return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
392
423
 
393
424
  sha = short_sha(repo, runner: runner)
@@ -398,8 +429,8 @@ module SessionGit
398
429
  # shared tail. A non-fast-forward push (spec D3, "base moved ahead
399
430
  # independently") stays a Note: the commit itself already landed on the
400
431
  # session branch.
401
- def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:)
402
- res = stage_and_commit(dir, subject, runner: runner)
432
+ def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:, body: nil)
433
+ res = stage_and_commit(dir, subject, runner: runner, body: body)
403
434
  return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
404
435
 
405
436
  sha = short_sha(dir, runner: runner)
@@ -414,7 +445,7 @@ module SessionGit
414
445
 
415
446
  # --- pull request mode (spec D4) ------------------------------------------------
416
447
 
417
- def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:)
448
+ def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:, body: nil)
418
449
  return note("nothing to commit") unless dirty?(repo, runner: runner)
419
450
  return note("summary is empty after truncation: no commit") if blank?(subject)
420
451
 
@@ -439,7 +470,7 @@ module SessionGit
439
470
  return note("could not check out branch #{branch}: #{diagnose(switch)}") unless switch.success?
440
471
  end
441
472
 
442
- res = stage_and_commit(repo, subject, runner: runner)
473
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
443
474
  outcome =
444
475
  if res.success?
445
476
  pull_request_outcome(repo: repo, subject: subject, branch: branch, base: base, gh_runner: gh_runner, runner: runner)
@@ -29,6 +29,10 @@ module SessionLedger
29
29
 
30
30
  SESSIONS_DIR = ".sessions"
31
31
  TMP_DIR = ".tmp"
32
+ # Named here, not in a CLI, so a caller can reference the key without the
33
+ # literal harness-branded env var name appearing in its own source (a few
34
+ # scripts are guarded against naming a harness at all).
35
+ SESSION_ID_ENV_KEY = "CLAUDE_CODE_SESSION_ID"
32
36
  DAY_ID = /\A\d{8}\z/
33
37
  EVENTS = %w[Item Done Note]
34
38
  STATES = { pending: "~", open: " ", done: "x", moved: ">", dropped: "-", promoted: "^" }.freeze
@@ -186,6 +190,130 @@ module SessionLedger
186
190
  "#{collapsed[0, 197]}..."
187
191
  end
188
192
 
193
+ # --- capture_worthy? (spec D2, D7; supersedes 298 D2(c)) -------------------
194
+
195
+ # One complete top-level harness envelope tag block: "<name ...>...</name>".
196
+ # Non-greedy (.*?) so sibling blocks are each matched on their own rather
197
+ # than one match spanning from the first block's opening tag all the way to
198
+ # the LAST block's closing tag (post-execution review item 3: an envelope
199
+ # on both sides of real work, "<system-reminder>...</system-reminder>\nfix
200
+ # the parser\n<task-notification>...</task-notification>", must not be
201
+ # read as one giant envelope swallowing the work in the middle).
202
+ ENVELOPE_BLOCK_RE = /<([A-Za-z][\w-]*)(?:\s[^>]*)?>.*?<\/\1>/m
203
+ private_constant :ENVELOPE_BLOCK_RE
204
+
205
+ # The whole prompt, case- and whitespace-insensitively, and nothing else
206
+ # (rule 3, D2): a trigger word inside a longer real instruction ("continue
207
+ # the dashboard fix and then release") must not match this.
208
+ BARE_TRIGGERS = %w[continue auto].freeze
209
+ private_constant :BARE_TRIGGERS
210
+
211
+ # Words whose presence marks a prompt as actionable work (rule 4's escape
212
+ # hatch, D2's accept bias). Deliberately excludes common nouns that also
213
+ # read as everyday verbs in casual remarks (e.g. "release", "ship", "plan"):
214
+ # including them would make ordinary conversation about a past release or
215
+ # plan look like a work request. Matched with an optional inflection suffix
216
+ # (post-execution review BLOCKER): the bare stems alone missed "fixed",
217
+ # "updated", "added", "reviewed", "implemented" -- exactly the past-tense
218
+ # and -ing forms real work summaries use.
219
+ WORK_MARKER_WORDS = %w[
220
+ fix add remove delete update upgrade implement write build create refactor
221
+ debug investigate review test deploy commit merge revert rename configure
222
+ install migrate document generate draft resolve help need want make change
223
+ setup
224
+ ].freeze
225
+ private_constant :WORK_MARKER_WORDS
226
+
227
+ WORK_MARKER_PHRASES = [
228
+ "can you", "could you", "would you", "let's", "let us", "set up", "look into", "figure out",
229
+ ].freeze
230
+ private_constant :WORK_MARKER_PHRASES
231
+
232
+ WORK_MARKER_RE = /\b(?:#{WORK_MARKER_WORDS.join("|")})(?:s|d|ed|ing)?\b/i
233
+ private_constant :WORK_MARKER_RE
234
+
235
+ # A first word that reads as an interrogative opener, checked case-
236
+ # insensitively against the prompt's first whitespace-separated token.
237
+ # Post-execution review BLOCKER: trimmed from the original, wider list
238
+ # (which also carried "how", "when", "where", "was", "were", "do", "did",
239
+ # "will", "shall", "should") down to words that open a genuine QUESTION at
240
+ # least as often as an ordinary command or request. Measured against 43
241
+ # invented and 27 real day-ledger prompts: the dropped words open ordinary
242
+ # work requests ("do the release now...", "when you are done, tag the
243
+ # release...", "will you push that branch...", "should I bump the version
244
+ # files...") far more often than they open a bare question worth rejecting.
245
+ QUESTION_STARTERS = %w[
246
+ what why who whom whose which is are am does can could would
247
+ ].freeze
248
+ private_constant :QUESTION_STARTERS
249
+
250
+ # A narrow set of retrospective-remark shapes ("that release went smoother
251
+ # than the last one"): comparative or evaluative observations about how
252
+ # something already went. Deliberately narrow (D2's accept bias): a broad
253
+ # "any declarative sentence with no recognized verb" rule would also catch
254
+ # ordinary work summaries like "harness text wins the pending line", which
255
+ # must stay accepted.
256
+ REMARK_PATTERNS = [
257
+ /\bwent\s+\w+\s+than\b/i,
258
+ /\bwent\s+(?:well|badly|smoothly|great|poorly|terribly)\b/i,
259
+ ].freeze
260
+ private_constant :REMARK_PATTERNS
261
+
262
+ # True iff nothing but harness envelope tag block(s) -- and whitespace --
263
+ # remain once every complete top-level block is stripped out. A prompt
264
+ # that is one envelope alone, or several envelopes with no other content,
265
+ # matches; a prompt carrying real work anywhere outside an envelope (before,
266
+ # after, or between several of them) does not.
267
+ def whole_prompt_envelope?(stripped)
268
+ stripped.gsub(ENVELOPE_BLOCK_RE, "").strip.empty?
269
+ end
270
+
271
+ def bare_trigger?(stripped)
272
+ BARE_TRIGGERS.include?(stripped.downcase)
273
+ end
274
+
275
+ def work_marker?(text)
276
+ return true if WORK_MARKER_RE.match?(text)
277
+
278
+ downcased = text.downcase
279
+ WORK_MARKER_PHRASES.any? { |p| downcased.include?(p) }
280
+ end
281
+
282
+ def interrogative?(stripped)
283
+ return true if stripped.end_with?("?")
284
+
285
+ first_word = stripped.split(/\s+/).first.to_s.downcase.gsub(/[^a-z]/, "")
286
+ QUESTION_STARTERS.include?(first_word)
287
+ end
288
+
289
+ def bare_remark?(stripped)
290
+ REMARK_PATTERNS.any? { |re| re.match?(stripped) }
291
+ end
292
+
293
+ # Internal helpers only: #capture_worthy? is the sole public contract
294
+ # (post-execution review item 9).
295
+ private_class_method :whole_prompt_envelope?, :bare_trigger?, :work_marker?, :interrogative?, :bare_remark?
296
+
297
+ # Whether `prompt` earns a pending checklist line (spec D2). Bias is
298
+ # ACCEPT: this rejects only on four named rules -- the 10-char floor (on
299
+ # its own collapsed copy), a whole-prompt harness envelope, a bare
300
+ # continue/auto trigger, and an interrogative or bare-remark prompt
301
+ # carrying no work marker -- and accepts everything else, including a
302
+ # work-shaped question and an envelope followed by real work. Takes the
303
+ # RAW prompt (not the sanitized/truncated line text) so rule 2 sees the
304
+ # prompt's true first character and multi-line shape.
305
+ def capture_worthy?(prompt)
306
+ raw = prompt.to_s
307
+ return false if sanitize_summary(raw).length < 10
308
+
309
+ stripped = raw.strip
310
+ return false if whole_prompt_envelope?(stripped)
311
+ return false if bare_trigger?(stripped)
312
+ return false if !work_marker?(stripped) && (interrogative?(stripped) || bare_remark?(stripped))
313
+
314
+ true
315
+ end
316
+
189
317
  # One LF-terminated checklist line, byte exact per spec D5. The state
190
318
  # marker is fixed width across all three states, which is what lets a later
191
319
  # promote or tick be a one-byte write at a known offset.
@@ -70,6 +70,10 @@ module VerifyIntent
70
70
  lines.concat(diffstat_lines)
71
71
  checks[:diffstat] = diffstat_check
72
72
 
73
+ report_lines_out, report_check = run_report_lines_check(intent_dir: intent_dir)
74
+ lines.concat(report_lines_out)
75
+ checks[:report] = report_check
76
+
73
77
  if Worktree.blank?(suite)
74
78
  checks[:suite] = { status: "skipped" }
75
79
  else
@@ -235,6 +239,35 @@ module VerifyIntent
235
239
  end
236
240
  end
237
241
 
242
+ # --- check: the Report savepoint lines (intent 331f, F17) ------------------------
243
+
244
+ REPORT_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/.freeze
245
+
246
+ # Every `Report`-kind savepoint line for this intent, oldest first: [timestamp, text].
247
+ # Never fails or gates anything (D3: the diffstat check already prints a summary block,
248
+ # this folds into the same verdict so verify-intent surfaces them too) - a delivery with
249
+ # no Report line is visible to `doctor`'s intent_reports_printed_check instead.
250
+ def report_lines(intent_dir)
251
+ path = File.join(intent_dir, "savepoint.md")
252
+ return [] unless File.exist?(path)
253
+
254
+ File.readlines(path).filter_map do |line|
255
+ m = line.strip.match(REPORT_LINE_RE)
256
+ next nil unless m && m[2] == "Report"
257
+ [m[1], m[3]]
258
+ end
259
+ end
260
+
261
+ def run_report_lines_check(intent_dir:)
262
+ entries = report_lines(intent_dir)
263
+ if entries.empty?
264
+ [["report lines: none recorded"], { status: "pass", lines: [] }]
265
+ else
266
+ lines = ["report lines:"] + entries.map { |ts, text| "#{ts} Report #{text}" }
267
+ [lines, { status: "pass", lines: entries }]
268
+ end
269
+ end
270
+
238
271
  # --- check 4: the optional suite --------------------------------------------------
239
272
 
240
273
  # The supplied command is very likely a ruby command, so it is spawned with RUBYOPT
@@ -0,0 +1,201 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_file"
5
+ require_relative "graph_edges"
6
+ require_relative "node_file"
7
+ require_relative "action_graph_shim"
8
+
9
+ # WorkGraphValidator (intent 334, n4): the in-batch reader over one intent's
10
+ # graph.md and nodes/ - 327's rule for budget, files, and the decision and
11
+ # research kinds (fold A17). {ok:, missing:, errors:}, the same Result shape
12
+ # ProjectValidator returns (fold B7). Named apart from
13
+ # IntentValidator#validate_graph, which already exists for the KNOWLEDGE
14
+ # graph and stays unrelated to this one under D40 (fold B9).
15
+ #
16
+ # Built over GraphFile, GraphEdges, and NodeFile only - never IntentValidator,
17
+ # never ReportScreen. Every check accumulates into `errors` rather than
18
+ # short-circuiting on the first one (fold: "the owner fixes one thing per
19
+ # run").
20
+ module WorkGraphValidator
21
+ module_function
22
+
23
+ def validate(intent_dir)
24
+ missing = []
25
+ errors = []
26
+
27
+ graph_path = File.join(intent_dir, "graph.md")
28
+ parsed = GraphFile.parse(graph_path)
29
+ errors.concat(parsed[:errors])
30
+
31
+ graph = parsed[:graph]
32
+ if graph.nil?
33
+ # G9 (intent 342, D6/D7/D12): a legacy intent that never got a
34
+ # graph.md still reads as a valid work graph, structurally, through
35
+ # the backward shim - but only when graph.md is genuinely absent.
36
+ # A graph.md that exists but is malformed (no "## Graph" section)
37
+ # must never fall through to the synthetic chain and hide its own
38
+ # error (D12's whole point). The fresh error list here is
39
+ # deliberate: `errors` above already holds the not-found error from
40
+ # `parsed[:errors]`, and reusing it would return ok: false for every
41
+ # legacy intent in the store.
42
+ if !File.exist?(graph_path) && ActionGraphShim.shape(intent_dir) == :actions
43
+ return validate_actions_shape(intent_dir)
44
+ end
45
+
46
+ missing << "graph.md ## Graph section"
47
+ return { ok: false, missing: missing, errors: errors }
48
+ end
49
+
50
+ nodes = graph[:nodes]
51
+ edges = graph[:edges]
52
+ declared_ids = edges.keys
53
+
54
+ # Intent-scope dangling check: every node must be declared with its own
55
+ # needs-line here (unlike a roadmap, which may have undeclared roots -
56
+ # that reconciliation is G4's, not this validator's).
57
+ (nodes - declared_ids).each { |id| errors << "needs target #{id.inspect} names no declared node" }
58
+
59
+ cyc = GraphEdges.cycle(edges)
60
+ errors << "cyclic graph, cannot validate: #{cyc.join(' > ')}" if cyc
61
+
62
+ node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
63
+ parsed_nodes = {}
64
+ paths_by_id = Hash.new { |h, k| h[k] = [] }
65
+
66
+ node_paths.each do |path|
67
+ nf = NodeFile.parse(path)
68
+ nf[:errors].each { |e| errors << "#{File.basename(path)}: #{e}" }
69
+ id = nf[:node]
70
+ next unless id
71
+
72
+ paths_by_id[id] << path
73
+ parsed_nodes[id] ||= nf
74
+ end
75
+
76
+ paths_by_id.each do |id, paths|
77
+ next unless paths.length > 1
78
+
79
+ errors << "node id #{id.inspect} claimed by more than one file: #{paths.map { |p| File.basename(p) }.join(', ')}"
80
+ end
81
+
82
+ declared_ids.each do |id|
83
+ missing << "nodes/ file for #{id}" unless parsed_nodes.key?(id)
84
+ errors << "declared node #{id.inspect} has no node file under nodes/" unless parsed_nodes.key?(id)
85
+ end
86
+ parsed_nodes.each_key do |id|
87
+ errors << "node file for #{id.inspect} exists but ## Graph declares no such node" unless declared_ids.include?(id)
88
+ end
89
+
90
+ work_nodes = []
91
+ verify_nodes = []
92
+
93
+ declared_ids.each do |id|
94
+ nf = parsed_nodes[id]
95
+ next unless nf
96
+
97
+ case nf[:kind]
98
+ when "verify"
99
+ verify_nodes << id
100
+ errors << "verify node #{id.inspect} has no ## Criteria" unless has_section?(nf[:body], "## Criteria")
101
+ when "decision"
102
+ errors << "decision node #{id.inspect} has no ## Question with a question" unless has_nonblank_section?(nf[:body], "## Question")
103
+ when "research"
104
+ errors << "research node #{id.inspect} has no ## Deposit" unless has_section?(nf[:body], "## Deposit")
105
+ when "work"
106
+ work_nodes << id
107
+ errors << "work node #{id.inspect} has no ## Steps" unless has_section?(nf[:body], "## Steps")
108
+ errors << "work node #{id.inspect} has no ## Proven by" unless has_section?(nf[:body], "## Proven by")
109
+ end
110
+ end
111
+
112
+ above_trivial_bar = work_nodes.length >= 2
113
+ if above_trivial_bar
114
+ verify_directive = parsed[:verify]
115
+ directive_ok = verify_directive && !verify_directive[:reason].to_s.empty?
116
+
117
+ unless directive_ok
118
+ attached = verify_nodes.select { |vid| node_touches_graph?(vid, edges) }
119
+ if attached.empty?
120
+ errors << "two or more work nodes require a verify node attached to the graph, or a verify: none reason=<text> directive"
121
+ end
122
+ end
123
+
124
+ work_nodes.each do |id|
125
+ nf = parsed_nodes[id]
126
+ next unless nf
127
+
128
+ errors << "work node #{id.inspect} has no failure-mode matrix under a heading carrying its id" unless has_valid_matrix?(id, nf[:body])
129
+ end
130
+ end
131
+
132
+ verify_nodes.each do |id|
133
+ next if node_touches_graph?(id, edges)
134
+
135
+ errors << "verify node #{id.inspect} exists but no edge reaches it (attach it or drop it)"
136
+ end
137
+
138
+ { ok: errors.empty?, missing: missing, errors: errors }
139
+ end
140
+
141
+ # A node "touches" the graph when some edge involves it on either side:
142
+ # it targets something, or something targets it. A verify node declared
143
+ # with "needs nothing" and targeted by nothing is fully isolated (fold
144
+ # A13).
145
+ def node_touches_graph?(id, edges)
146
+ (edges[id] || []).any? || edges.values.any? { |targets| targets.include?(id) }
147
+ end
148
+
149
+ def has_section?(body, heading_text)
150
+ NodeFile.split_by_headings(body.to_s).any? { |heading, _| heading.strip == heading_text }
151
+ end
152
+
153
+ def has_nonblank_section?(body, heading_text)
154
+ NodeFile.split_by_headings(body.to_s).any? { |heading, section| heading.strip == heading_text && !section.strip.empty? }
155
+ end
156
+
157
+ def heading_tokens(heading)
158
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
159
+ end
160
+
161
+ # The matrix table must sit under a heading whose tokens include the
162
+ # node's own id, and that heading must own at least one data row (fold
163
+ # A1 - the same table-owning rule report_screen's resolver uses, so a
164
+ # node's own Proven-by cell is never "not recorded" the moment it ships).
165
+ def has_valid_matrix?(id, body)
166
+ NodeFile.split_by_headings(body.to_s).any? do |heading, section|
167
+ heading_tokens(heading).include?(id) && NodeFile.table_rows(section).any?
168
+ end
169
+ end
170
+
171
+ # The structural check the synthetic (actions/-only) shape gets, and
172
+ # nothing more (D6): at least one node, unique ids, every needs target
173
+ # names a declared node, acyclic. Never the kind-section rules, the
174
+ # failure-mode matrix bar, or the verify-attachment bar - a legacy action
175
+ # file labels its headings "S1", or nothing at all, and was never asked
176
+ # to meet a bar written for a graph authored under 327.
177
+ def validate_actions_shape(intent_dir)
178
+ errors = []
179
+ graph = ActionGraphShim.view(intent_dir)[:graph] || { nodes: [], edges: {}, errors: [] }
180
+ nodes = graph[:nodes]
181
+ edges = graph[:edges]
182
+
183
+ errors << "actions/ yields no nodes" if nodes.empty?
184
+ # D16: uniqueness is checked over the node array, not edges.keys - a
185
+ # Hash key set is unique by construction, so checking edges.keys can
186
+ # never fire.
187
+ errors << "duplicate node ids in synthetic chain" if nodes.uniq.length != nodes.length
188
+
189
+ # D16: every needs target is checked against the declared node list,
190
+ # not the node list against its own key set (nodes - edges.keys, which
191
+ # can never differ since the builder mints edges.keys from nodes).
192
+ (edges.values.flatten.uniq - nodes).each do |id|
193
+ errors << "needs target #{id.inspect} names no declared node"
194
+ end
195
+
196
+ cyc = GraphEdges.cycle(edges)
197
+ errors << "cyclic graph, cannot validate: #{cyc.join(' > ')}" if cyc
198
+
199
+ { ok: errors.empty?, missing: [], errors: errors }
200
+ end
201
+ end
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require_relative "lib/node_packet"
6
+ require_relative "lib/savepoint"
7
+
8
+ # node-packet - the CLI over NodePacket (intent 338, G5). Builds one node's
9
+ # whole input from disk and writes it to packets/<node>--a<N>.packet (or
10
+ # --out), printing a parsable summary and the exact node-transition running
11
+ # command intent 340's runner should record.
12
+ #
13
+ # Usage:
14
+ # node-packet <intent_dir> --node <id> [--budget N] [--hop-tokens N]
15
+ # [--holder H] [--expires E] [--model M] [--attempt N]
16
+ # [--out PATH] [--force]
17
+ #
18
+ # Exit codes (spec D17, shared with node-transition's family):
19
+ # 0 - the packet was written (or the identical bytes already existed)
20
+ # 2 - usage: not an intent directory, missing --node, or an unknown node
21
+ # 3 - an unreadable, cyclic or unparsable graph, node file, or record
22
+ # 4 - overflow past the third cut; nothing was written
23
+ # 5 - an existing attempt file whose bytes differ; nothing was written
24
+ module NodePacketCLI
25
+ module_function
26
+
27
+ def usage
28
+ warn "Usage: node-packet <intent_dir> --node <id> [--budget N] [--hop-tokens N] " \
29
+ "[--holder H] [--expires E] [--model M] [--attempt N] [--out PATH] [--force]"
30
+ end
31
+
32
+ def intent_directory?(dir)
33
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
34
+ end
35
+
36
+ def opt(args, name)
37
+ (i = args.index(name)) && args[i + 1]
38
+ end
39
+
40
+ def main(argv)
41
+ args = argv.dup
42
+ intent_dir_arg = args.shift
43
+
44
+ unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
45
+ warn "node-packet: #{intent_dir_arg.inspect} is not an intent directory"
46
+ usage
47
+ exit 2
48
+ end
49
+ intent_dir = File.expand_path(intent_dir_arg)
50
+
51
+ node = opt(args, "--node")
52
+ if node.to_s.strip.empty?
53
+ warn "node-packet: --node is required"
54
+ usage
55
+ exit 2
56
+ end
57
+
58
+ budget = opt(args, "--budget")
59
+ hop_tokens = opt(args, "--hop-tokens")
60
+ attempt = opt(args, "--attempt")
61
+ out = opt(args, "--out")
62
+ holder = opt(args, "--holder")
63
+ expires = opt(args, "--expires")
64
+ model = opt(args, "--model")
65
+ force = args.include?("--force")
66
+
67
+ result = NodePacket.build(
68
+ intent_dir: intent_dir,
69
+ node: node,
70
+ budget_tokens: budget ? budget.to_i : NodePacket::DEFAULT_BUDGET_TOKENS,
71
+ hop_tokens: hop_tokens ? hop_tokens.to_i : NodePacket::DEFAULT_HOP_TOKENS,
72
+ holder: holder,
73
+ expires: expires,
74
+ model: model,
75
+ attempt: attempt ? attempt.to_i : nil,
76
+ out: out,
77
+ force: force,
78
+ )
79
+
80
+ if result[:ok]
81
+ puts NodePacket.summary_line(result)
82
+ puts result[:running_command]
83
+ exit 0
84
+ end
85
+
86
+ Array(result[:errors]).each { |e| warn "node-packet: #{e}" }
87
+ puts result[:needs_decision_command] if result[:needs_decision_command]
88
+ exit result[:exit_code]
89
+ end
90
+ end
91
+
92
+ NodePacketCLI.main(ARGV) if $PROGRAM_NAME == __FILE__