@zalom/plastic 2.0.0-alpha.17 → 2.0.0-alpha.19

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 (46) hide show
  1. package/package.json +2 -2
  2. package/scripts/dashboard.rb +20 -0
  3. package/scripts/doctor.rb +120 -2
  4. package/scripts/end-intent +134 -8
  5. package/scripts/hook-capture +4 -105
  6. package/scripts/lib/action_graph_shim.rb +277 -0
  7. package/scripts/lib/atomic_write.rb +31 -0
  8. package/scripts/lib/graph_edges.rb +121 -0
  9. package/scripts/lib/graph_file.rb +246 -0
  10. package/scripts/lib/guarded_append.rb +155 -0
  11. package/scripts/lib/installer_core.rb +32 -0
  12. package/scripts/lib/node_file.rb +214 -0
  13. package/scripts/lib/node_ids.rb +99 -0
  14. package/scripts/lib/node_ledger.rb +377 -0
  15. package/scripts/lib/node_packet.rb +873 -0
  16. package/scripts/lib/outcome_report.rb +440 -0
  17. package/scripts/lib/packet_wrapper.rb +132 -0
  18. package/scripts/lib/ready_set.rb +462 -0
  19. package/scripts/lib/release_guard.rb +16 -0
  20. package/scripts/lib/report_screen.rb +122 -12
  21. package/scripts/lib/roadmap_queue.rb +161 -3
  22. package/scripts/lib/roadmap_savepoint.rb +26 -5
  23. package/scripts/lib/savepoint.rb +123 -12
  24. package/scripts/lib/work_graph_validator.rb +201 -0
  25. package/scripts/node-packet +92 -0
  26. package/scripts/node-transition +291 -0
  27. package/scripts/outcome-report +74 -0
  28. package/scripts/ready-set +126 -0
  29. package/scripts/release-check +118 -0
  30. package/scripts/report-screen +8 -1
  31. package/scripts/roadmap-savepoint +7 -0
  32. package/scripts/validate-work-graph +39 -0
  33. package/skills/auto/SKILL.md +2 -3
  34. package/skills/auto/references/human-report-contract.md +3 -2
  35. package/skills/intent-continuing/references/boarding-matrix.md +1 -0
  36. package/skills/intent-ending/SKILL.md +30 -19
  37. package/skills/intent-executing/SKILL.md +1 -1
  38. package/skills/releasing/SKILL.md +39 -0
  39. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  40. package/skills/releasing/references/release-lines.md +1 -1
  41. package/templates/graph.md +16 -0
  42. package/templates/node-decision.md +11 -0
  43. package/templates/node-research.md +11 -0
  44. package/templates/node-verify.md +13 -0
  45. package/templates/node-work.md +22 -0
  46. package/templates/outcome.md +8 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.17",
3
+ "version": "2.0.0-alpha.19",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "license": "MIT",
28
28
  "repository": {
29
29
  "type": "git",
30
- "url": "https://github.com/zalom/plastic"
30
+ "url": "git+https://github.com/zalom/plastic.git"
31
31
  },
32
32
  "homepage": "https://github.com/zalom/plastic",
33
33
  "files": [
@@ -33,6 +33,7 @@ require_relative "lib/report_screen"
33
33
  require_relative "lib/roadmap_queue"
34
34
  require_relative "lib/day_summary"
35
35
  require_relative "lib/screen_paint"
36
+ require_relative "lib/ready_set"
36
37
 
37
38
  # Intent 331a (D6/R8): every caller-added screen kind file registers itself on
38
39
  # load, so this glob is the only wiring a new kind needs (scripts/report-screen:42
@@ -268,9 +269,27 @@ def parse_intent(store_info, dir_name, status_index)
268
269
  # durable lock beside the intent. intent_line deliberately does not expose
269
270
  # this filesystem path in --data or any rendered surface.
270
271
  intent_dir: File.expand_path(dir),
272
+ # Intent 336 (G3, D14): the ready node count for a graph-shaped intent,
273
+ # read through ReadySet, nil for every other intent. Explicitly advisory,
274
+ # entirely separate from the sources-derived "unblocked" flag below,
275
+ # which is the cross-intent knowledge graph and stays exactly as it was
276
+ # (327 D40 forbids that field from gating work). ReadySet.analyze is
277
+ # called only when real.("graph.md") is true, so the cost of this read
278
+ # is zero for every intent that has no graph.
279
+ ready_node_count: real.("graph.md") ? ready_node_count_for(dir) : nil,
271
280
  }
272
281
  end
273
282
 
283
+ # The count of currently-ready nodes in a graph-shaped intent's own graph.md,
284
+ # or nil when ReadySet cannot analyze it (a malformed or cyclic graph never
285
+ # raises out of the dashboard).
286
+ def ready_node_count_for(dir)
287
+ analysis = ReadySet.analyze(dir)
288
+ return nil unless analysis[:ok]
289
+
290
+ analysis[:nodes].count { |_, view| view[:ready] }
291
+ end
292
+
274
293
  def checklist_partially_done?(path)
275
294
  txt = File.read(path)
276
295
  checked = txt.scan(/^\s*- \[x\]/i).size
@@ -828,6 +847,7 @@ def next_work(records, cap: NEXT_WORK_CAP)
828
847
  { id: r[:id], intent: r[:intent], scope: r[:scope], lifecycle: r[:lifecycle],
829
848
  value: r[:value].to_s, disposition: r[:disposition], flags: r[:flags],
830
849
  what: cell(text), flags_label: cell(Array(r[:flags]).join(", ")),
850
+ ready_node_count: r[:ready_node_count],
831
851
  line: "#{r[:id]} #{text}" }
832
852
  end
833
853
  end
package/scripts/doctor.rb CHANGED
@@ -27,6 +27,8 @@ require_relative "lib/links_projection"
27
27
  require_relative "lib/links_section"
28
28
  require_relative "lib/lock"
29
29
  require_relative "lib/savepoint"
30
+ require_relative "lib/node_ledger"
31
+ require_relative "lib/ready_set"
30
32
  require_relative "lib/agent_models"
31
33
  require_relative "lib/outcome_guard"
32
34
  require_relative "lib/skill_lint"
@@ -620,6 +622,14 @@ class Doctor
620
622
  # flagged: validate_graph does not compute it.
621
623
  checks.concat(graph_invariant_checks(intent_dirs))
622
624
 
625
+ # node_graph - ReadySet's own view of every intent carrying a real graph.md
626
+ # (intent 336, n7): dead ends, stale done nodes, and expired running leases,
627
+ # none of which any other doctor rule surfaces. Lives here rather than
628
+ # doctor_core.rb because test/doctor_core_split_test.rb pins that file's
629
+ # boot path to exactly three project files and 781 bytes of headroom;
630
+ # ReadySet's own require chain is 65,648 bytes, far past that budget.
631
+ checks.concat(node_graph_checks(intent_dirs))
632
+
623
633
  # cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
624
634
  # `store:id` ref against the FULL store family via the relocation map
625
635
  # (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
@@ -785,7 +795,14 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
785
795
  # reported as a fixable warn (backfilled_complete). Its exclusions go to their OWN
786
796
  # bucket so savepoint_operational's consumed/dead-row bookkeeping above never sees them.
787
797
  backfill_gaps = %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
788
- backfill_gaps << "actions/" unless Savepoint.has_real_action?(dir)
798
+ # Name whichever directory the intent actually used (post-execution
799
+ # review, non-blocking 8), consistent with Savepoint.missing_for_stage:
800
+ # a nodes/ directory on disk means the intent chose the node-graph
801
+ # convention, so a gap here is a missing nodes/, never the literal
802
+ # actions/ has_real_action? no longer implies.
803
+ unless Savepoint.has_real_action?(dir)
804
+ backfill_gaps << (File.directory?(File.join(dir, "nodes")) ? "nodes/" : "actions/")
805
+ end
789
806
  if backfill_gaps.any?
790
807
  suppressed_backfill = excluded_rules.include?("backfilled_complete")
791
808
  target = suppressed_backfill ? findings[:excluded_backfill] : findings[:unbackfilled]
@@ -1461,20 +1478,37 @@ end
1461
1478
  expected_pair = Savepoint.savepoint_milestone(intent_dir, File.basename(Savepoint.intent_file(intent_dir)))
1462
1479
 
1463
1480
  phantoms = Savepoint.savepoint_phantom_lines(intent_dir)
1481
+ # Intent 335 (spec C2/C5): a torn or unattributed node/Intent transition
1482
+ # line has no reader anywhere else in the tree, so this is where it
1483
+ # surfaces. NodeLedger.anomalies never touches a stage line (its own
1484
+ # transition-candidate gate excludes them), so this is purely additive
1485
+ # beside the phantom check above.
1486
+ transition_anomalies = NodeLedger.anomalies(savepoint)
1464
1487
  problems = []
1465
1488
  problems << "born line #{born_pair.inspect} does not match the expected #{expected_pair.inspect}" \
1466
1489
  if born_pair != expected_pair
1467
1490
  problems << "#{phantoms.size} phantom savepoint line(s): " \
1468
1491
  "#{phantoms.map { |l, r| "#{l} (#{r})" }.join("; ")}" if phantoms.any?
1492
+ problems << "#{transition_anomalies.size} torn/unattributed transition line(s): " \
1493
+ "#{transition_anomalies.map { |a| "#{a[:line]} (#{a[:reason]})" }.join("; ")}" \
1494
+ if transition_anomalies.any?
1469
1495
 
1470
1496
  if problems.empty?
1471
1497
  check(category: "intent_end", name: "intent_savepoint_truthful", status: "pass",
1472
1498
  message: "Savepoint born line and phantom-line state are truthful")
1473
1499
  else
1500
+ # A torn or unattributed transition line's CONTENT cannot be repaired by
1501
+ # a rebuild (rebuild_savepoint preserves it verbatim, spec D13), unlike a
1502
+ # born-line mismatch or a stage phantom, which rebuild does fix. Say so
1503
+ # explicitly rather than pointing at a fix that will not fix it.
1504
+ fix_hint = "Rebuild the ledger via Savepoint.rebuild_savepoint"
1505
+ fix_hint += "; note: rebuild preserves a torn or unattributed transition line " \
1506
+ "verbatim and does not repair one - hand-edit it or accept the finding" \
1507
+ if transition_anomalies.any?
1474
1508
  check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
1475
1509
  message: "#{problems.size} savepoint truthfulness issue(s) (advisory, never blocking)",
1476
1510
  details: problems,
1477
- fixable: true, fix_hint: "Rebuild the ledger via Savepoint.rebuild_savepoint")
1511
+ fixable: true, fix_hint: fix_hint)
1478
1512
  end
1479
1513
  end
1480
1514
 
@@ -1620,6 +1654,90 @@ end
1620
1654
  end
1621
1655
  end
1622
1656
 
1657
+ # ReadySet over every intent carrying a real graph.md (intent 336, n7):
1658
+ # dead ends, stale done nodes, and expired running leases. An intent with
1659
+ # no real graph.md is skipped entirely (never touches ReadySet); a
1660
+ # malformed or cyclic one is reported by name, never raised out of doctor.
1661
+ def node_graph_checks(intent_dirs)
1662
+ dead_ends = []
1663
+ stale = []
1664
+ expired = []
1665
+ malformed = []
1666
+
1667
+ intent_dirs.each do |d|
1668
+ graph_path = File.join(d[:path], "graph.md")
1669
+ next unless Savepoint.stage_file_present?(graph_path)
1670
+
1671
+ analysis = ReadySet.analyze(d[:path])
1672
+ unless analysis[:ok]
1673
+ malformed << "#{d[:name]}: #{analysis[:errors].join('; ')}"
1674
+ next
1675
+ end
1676
+
1677
+ savepoint_path = File.join(d[:path], "savepoint.md")
1678
+ analysis[:nodes].each do |id, view|
1679
+ dead_ends << "#{d[:name]}/#{id}" if view[:dead_end]
1680
+ stale << "#{d[:name]}/#{id}" if view[:stale]
1681
+ expired << "#{d[:name]}/#{id}" if view[:state] == "running" && expired_running_lease?(savepoint_path, id)
1682
+ end
1683
+ end
1684
+
1685
+ [
1686
+ node_graph_finding_check(
1687
+ "node_graph_dead_ends", dead_ends,
1688
+ "No node waits on a need that resolves to superseded or abandoned",
1689
+ "Re-plan or supersede the dead-end node's need chain, or abandon the node itself"
1690
+ ),
1691
+ node_graph_finding_check(
1692
+ "node_graph_stale_done", stale,
1693
+ "No done node rests on a need superseded after it finished",
1694
+ "Re-verify the stale node against the superseding work, or record the staleness in revisions.md"
1695
+ ),
1696
+ node_graph_finding_check(
1697
+ "node_graph_expired_running_lease", expired,
1698
+ "No running node's lease has expired unreclaimed",
1699
+ "Run `node-transition <intent_dir> --node <id> --state reclaimed --field holder=<h> " \
1700
+ "--field expired=<iso>` to sweep it"
1701
+ ),
1702
+ node_graph_finding_check(
1703
+ "node_graph_malformed", malformed,
1704
+ "Every intent's graph.md parses cleanly",
1705
+ "Fix the malformed graph.md or nodes/ file named in the details"
1706
+ ),
1707
+ ]
1708
+ end
1709
+
1710
+ # True iff `id`'s last running line in the ledger at `savepoint_path` carries
1711
+ # an `expires=` strictly in the past. An unparseable or absent expires=
1712
+ # never counts as expired (fail milder than the bug).
1713
+ def expired_running_lease?(savepoint_path, id, now: Time.now)
1714
+ entry = NodeLedger.last_running(savepoint_path, id)
1715
+ return false unless entry
1716
+
1717
+ expires_raw = (entry[:fields] || {})["expires"]
1718
+ expiry = begin
1719
+ expires_raw && Time.iso8601(expires_raw)
1720
+ rescue ArgumentError
1721
+ nil
1722
+ end
1723
+ !!(expiry && now > expiry)
1724
+ end
1725
+
1726
+ # One node-graph check: pass when `findings` is empty, otherwise warn
1727
+ # (never fail, matching graph_finding_check's own precedent - an existing
1728
+ # store never turns red on an advisory graph finding).
1729
+ def node_graph_finding_check(name, findings, pass_message, fix_hint)
1730
+ if findings.empty?
1731
+ check(category: "conventions", name: name, status: "pass", message: pass_message)
1732
+ else
1733
+ check(
1734
+ category: "conventions", name: name, status: "warn",
1735
+ message: "#{findings.size} #{name} violation(s)",
1736
+ details: findings, fixable: false, fix_hint: fix_hint
1737
+ )
1738
+ end
1739
+ end
1740
+
1623
1741
  # --- Check category 3: Agent registration ---
1624
1742
 
1625
1743
 
@@ -81,6 +81,7 @@ require_relative "lib/intent_validator"
81
81
  require_relative "lib/outcome_guard"
82
82
  require_relative "lib/report_screen"
83
83
  require_relative "lib/backfill_intent"
84
+ require_relative "lib/outcome_report"
84
85
 
85
86
  DISPOSITIONS = %w[delivered abandoned].freeze
86
87
 
@@ -176,6 +177,64 @@ def resolve_plastic_home_and_scope(store)
176
177
  end
177
178
  end
178
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
+
179
238
  # Backfill (intent 308): write the judgment documents that are missing or still the
180
239
  # placeholder from the record (BackfillIntent). Fail-open: a crash here warns and the
181
240
  # close proceeds, the same contract as the structure check below.
@@ -227,6 +286,48 @@ end
227
286
  # closed exactly this way and the owner read a screen full of "not recorded".
228
287
  # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
229
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
+
230
331
  def hollow_report_reason(intent_dir, disposition)
231
332
  return nil unless disposition == "delivered"
232
333
 
@@ -235,14 +336,34 @@ def hollow_report_reason(intent_dir, disposition)
235
336
  text = File.read(outcome)
236
337
  return nil if text.include?("<!-- backfilled from the record by end-intent on ")
237
338
 
238
- # The gate's evidence bar (A7/B7): it judges only records that CLAIM the
239
- # modern convention - at least one actions/*.md heading carrying an S-label.
240
- # A terse legacy close with no labeled matrix stays report-and-proceed.
241
- heading_labels = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.flat_map do |path|
242
- ReportScreen.split_by_headings(File.read(path)).filter_map do |heading, _body|
243
- heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).find { |tok| tok.match?(/\AS\d+\z/) }
244
- end
245
- end.uniq
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
246
367
  return nil if heading_labels.empty?
247
368
 
248
369
  rows = ReportScreen.delivered_rows(intent_dir)
@@ -718,6 +839,11 @@ def main(argv)
718
839
  end
719
840
  end
720
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
+
721
847
  # 1. Backfill from the record (intent 308), then the structure self-check and the
722
848
  # outcome guard, both report-only since 308.
723
849
  run_backfill(intent_dir, store: store, id: id, disposition: disposition,
@@ -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"
@@ -42,87 +41,6 @@ templates = File.expand_path("../templates", __dir__)
42
41
  sid = SessionLedger.short_session_id(nil, session_id)
43
42
  today = SessionLedger.day_id
44
43
 
45
- # --- Extracted matching logic, shared by both the global and project store
46
- # passes of job (f) below (mirrors hook-future-intent-check verbatim). ---
47
- def future_intent_matches(store_root, message)
48
- index_path = File.join(store_root, "INDEX.md")
49
- return [] unless File.exist?(index_path)
50
-
51
- lines = File.readlines(index_path)
52
- future_dirs = []
53
- section = nil
54
-
55
- lines.each do |line|
56
- section = :future if line.start_with?("## Future")
57
- section = nil if line.start_with?("## ") && !line.start_with?("## Future")
58
- next unless section == :future && line.strip.start_with?("- [")
59
-
60
- future_dirs << $1 if line =~ /store\/([\w-]+)\//
61
- end
62
- return [] if future_dirs.empty?
63
-
64
- matches = []
65
- message_words = message.split(/\W+/).reject { |w| w.length < 4 }
66
-
67
- future_dirs.each do |dir|
68
- intent_path = File.join(store_root, "store", dir, "#{dir}.md")
69
- next unless File.exist?(intent_path)
70
-
71
- content = File.read(intent_path)
72
-
73
- tags = []
74
- tags = $1.split(",").map(&:strip).map(&:downcase) if content =~ /^tags:\s*\[([^\]]+)\]/
75
-
76
- intent_name = ""
77
- intent_name = $1.downcase if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
78
-
79
- keywords = (tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }).map(&:downcase).uniq
80
-
81
- matched_keywords = keywords.select { |kw| message.include?(kw) }
82
- name_matches = message_words.select { |w| intent_name.include?(w) }
83
- matched_keywords = (matched_keywords + name_matches).uniq
84
-
85
- next if matched_keywords.empty?
86
-
87
- index_line = lines.find { |l| l.include?(dir) }&.strip || "#{dir} - #{intent_name}"
88
- matches << { "index_line" => index_line, "keywords" => matched_keywords }
89
- end
90
- matches
91
- end
92
-
93
- # The store_root that carries the current project's own store, resolved from
94
- # cwd through projects.yml, mirroring SessionLedger.project_slug's matching
95
- # but returning nil (not "global") when nothing matches, since there is then
96
- # no distinct second store to hint against.
97
- def resolve_project_store_root(cwd, plastic_home)
98
- projects_path = File.join(plastic_home, "projects.yml")
99
- return nil unless File.exist?(projects_path)
100
-
101
- data = begin
102
- YAML.safe_load(File.read(projects_path))
103
- rescue StandardError
104
- nil
105
- end
106
- data = {} unless data.is_a?(Hash)
107
- projects = data["projects"].is_a?(Hash) ? data["projects"] : {}
108
- expanded_cwd = File.expand_path(cwd)
109
-
110
- matches = projects.filter_map do |slug, info|
111
- next unless info.is_a?(Hash) && slug.is_a?(String)
112
-
113
- path = info["path"]
114
- next unless path
115
-
116
- root = File.expand_path(path)
117
- next unless expanded_cwd == root || expanded_cwd.start_with?("#{root}#{File::SEPARATOR}")
118
-
119
- [root.length, slug]
120
- end
121
-
122
- best = matches.max_by { |(length, _slug)| length }
123
- best ? File.join(plastic_home, "projects", best[1]) : nil
124
- end
125
-
126
44
  def truncate(text, max)
127
45
  return text if text.length <= max
128
46
 
@@ -183,7 +101,7 @@ system_message = nil
183
101
 
184
102
  # --- (d) "continue" cockpit -------------------------------------------------
185
103
  begin
186
- if prompt.match?(/\bcontinue\b/i)
104
+ if prompt.to_s.strip.downcase == "continue"
187
105
  dashboard = File.expand_path("dashboard.rb", __dir__)
188
106
  if File.exist?(dashboard)
189
107
  cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
@@ -224,25 +142,6 @@ rescue StandardError
224
142
  nil
225
143
  end
226
144
 
227
- # --- (f) Future-intent hint, global store then the project's own store -----
228
- begin
229
- message = prompt.to_s.downcase
230
- if message.strip.length >= 10 && message.strip != "continue"
231
- matches = future_intent_matches(plastic_home, message)
232
- project_root = resolve_project_store_root(cwd, plastic_home)
233
- matches += future_intent_matches(project_root, message) if project_root
234
-
235
- if matches.any?
236
- parts = ["PLASTIC - Future intents related to this message:\n"]
237
- matches.each { |m| parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})" }
238
- parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
239
- context_parts << parts.join("\n")
240
- end
241
- end
242
- rescue StandardError
243
- nil
244
- end
245
-
246
145
  exit 0 if context_parts.empty?
247
146
 
248
147
  payload_out = {