@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
@@ -76,8 +76,25 @@ class InstallerCore
76
76
  @agents = agents
77
77
  end
78
78
 
79
+ # Reads the package version from `package.json` when present (the source
80
+ # tree, and any dev checkout), falling back to the bare `VERSION` file the
81
+ # installer copies to `~/.plastic` (row B, spec 315b): an installed system
82
+ # carries VERSION but never package.json, so `rollback.rb` and `update.rb`
83
+ # constructing an InstallerCore with `package_root: ~/.plastic` crashed with
84
+ # a raw Errno::ENOENT before this fallback existed. Raises a named error,
85
+ # not a bare ENOENT, when neither file exists, so the caller is not
86
+ # misdirected toward the wrong missing file.
79
87
  def read_package_version(root)
80
- File.read(File.join(root, "package.json")).then { |s| JSON.parse(s)["version"] }
88
+ package_json = File.join(root, "package.json")
89
+ version_file = File.join(root, "VERSION")
90
+
91
+ if File.exist?(package_json)
92
+ JSON.parse(File.read(package_json))["version"]
93
+ elsif File.exist?(version_file)
94
+ File.read(version_file).strip
95
+ else
96
+ raise "cannot determine the package version: neither #{package_json} nor #{version_file} exists"
97
+ end
81
98
  end
82
99
 
83
100
  # --- Channel derivation (the channel is encoded in the version string) ---
@@ -314,12 +331,26 @@ class InstallerCore
314
331
  end
315
332
  end
316
333
 
334
+ # Intent 331a (D6/R7): a screen kind file (scripts/lib/screens/<kind>.rb)
335
+ # must reach an installed ~/.plastic the same way a new template or hook
336
+ # does - glob-derived, so "add a file, not a diff" is actually true for an
337
+ # installed Plastic, not just an in-repo one. This repo ships none yet;
338
+ # the glob answers {} until one exists.
339
+ def screen_files
340
+ Dir.glob(File.join(package_root, "scripts", "lib", "screens", "*.rb")).each_with_object({}) do |path, acc|
341
+ next unless File.file?(path)
342
+
343
+ rel = File.join("scripts", "lib", "screens", File.basename(path))
344
+ acc[rel] = rel
345
+ end
346
+ end
347
+
317
348
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
318
349
  # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
319
- # by install_sync_test). The templates half is glob-derived (template_files above); the
320
- # rest stays a hand-written literal.
350
+ # by install_sync_test). The templates and screen-kind halves are glob-derived
351
+ # (template_files, screen_files above); the rest stays a hand-written literal.
321
352
  def core_files
322
- hand_registered_files.merge(template_files).merge(hook_files)
353
+ hand_registered_files.merge(template_files).merge(hook_files).merge(screen_files)
323
354
  end
324
355
 
325
356
  def hand_registered_files
@@ -342,6 +373,9 @@ class InstallerCore
342
373
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
343
374
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
344
375
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
376
+ "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
377
+ "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
378
+ "scripts/node-transition" => "scripts/node-transition",
345
379
  "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
346
380
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
347
381
  "scripts/plastic-lock" => "scripts/plastic-lock",
@@ -383,6 +417,9 @@ class InstallerCore
383
417
  "scripts/lib/harness_text.rb" => "scripts/lib/harness_text.rb",
384
418
  "scripts/codex-hook" => "scripts/codex-hook",
385
419
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
420
+ "scripts/lib/report_screen.rb" => "scripts/lib/report_screen.rb",
421
+ "scripts/report-screen" => "scripts/report-screen",
422
+ "scripts/savepoint-note" => "scripts/savepoint-note",
386
423
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
387
424
  "scripts/provision-project-store" => "scripts/provision-project-store",
388
425
  "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
@@ -403,6 +440,7 @@ class InstallerCore
403
440
  "scripts/exec-worktree" => "scripts/exec-worktree",
404
441
  "scripts/doctor.rb" => "scripts/doctor.rb",
405
442
  "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
443
+ "scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
406
444
  "scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
407
445
  "scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
408
446
  "scripts/lib/doctor_session_ledger.rb" => "scripts/lib/doctor_session_ledger.rb",
@@ -423,7 +461,96 @@ class InstallerCore
423
461
  "scripts/lib/day_summary.rb" => "scripts/lib/day_summary.rb",
424
462
  "scripts/write-handoff" => "scripts/write-handoff",
425
463
  "scripts/day-summary" => "scripts/day-summary",
464
+ "scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
465
+ "scripts/intent-screen" => "scripts/intent-screen",
426
466
  "scripts/hook-savepoint" => "scripts/hook-savepoint",
467
+ # Intent 316a: hooks/* only glob-copies scripts/*, never scripts/lib/*
468
+ # (see hook_files above), so the two lib files a require_relative
469
+ # between themselves are unguarded there — these three literal
470
+ # entries are their only protection (test/install_sync_test.rb:23-29
471
+ # greps installer_core.rb's own source text for "scripts/<name>").
472
+ "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
473
+ "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
474
+ "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
475
+ # Intent 331d (A1): scripts/dashboard.rb require_relatives this lib
476
+ # directly; templates/dashboard-screen.md and scripts/lib/screens/
477
+ # dashboard.rb are glob-derived (template_files, screen_files above)
478
+ # and need no entry here.
479
+ "scripts/lib/dashboard_screen.rb" => "scripts/lib/dashboard_screen.rb",
480
+ "scripts/hook-message-display" => "scripts/hook-message-display",
481
+ # Intent 334 (G1): the node file and graph.md library, plus its
482
+ # validator and CLI (327 D40/D41).
483
+ "scripts/lib/graph_edges.rb" => "scripts/lib/graph_edges.rb",
484
+ "scripts/lib/node_file.rb" => "scripts/lib/node_file.rb",
485
+ "scripts/lib/atomic_write.rb" => "scripts/lib/atomic_write.rb",
486
+ "scripts/lib/graph_file.rb" => "scripts/lib/graph_file.rb",
487
+ "scripts/lib/work_graph_validator.rb" => "scripts/lib/work_graph_validator.rb",
488
+ "scripts/validate-work-graph" => "scripts/validate-work-graph",
489
+ # Intent 335a: every id an intent has ever seen, so a deleted node's id
490
+ # is never reissued to a new node with the dead one's ledger history.
491
+ "scripts/lib/node_ids.rb" => "scripts/lib/node_ids.rb",
492
+ # Intent 339 (G6): the generated outcome.md library and its CLI.
493
+ "scripts/lib/outcome_report.rb" => "scripts/lib/outcome_report.rb",
494
+ "scripts/outcome-report" => "scripts/outcome-report",
495
+ # Intent 336 (G3): ReadySet, the one function that says what may run
496
+ # next, and its CLI. node-transition requires the lib as of n5;
497
+ # registered here (rather than waiting for n8's own CLI/doc unit) so
498
+ # test/install_sync_test.rb stays green across every intermediate unit.
499
+ "scripts/lib/ready_set.rb" => "scripts/lib/ready_set.rb",
500
+ "scripts/ready-set" => "scripts/ready-set",
501
+ # Intent 337 (G4): the roadmap graph model and its tree renderer,
502
+ # registered as they land (test/installer_core_test.rb requires every
503
+ # scripts/lib file to be manifest-covered as soon as it exists, not
504
+ # only once the CLI that ships it arrives at n4).
505
+ "scripts/lib/roadmap_graph.rb" => "scripts/lib/roadmap_graph.rb",
506
+ "scripts/lib/graph_tree.rb" => "scripts/lib/graph_tree.rb",
507
+ "scripts/lib/index_projection.rb" => "scripts/lib/index_projection.rb",
508
+ "scripts/lib/roadmap_render.rb" => "scripts/lib/roadmap_render.rb",
509
+ "scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
510
+ "scripts/roadmap-graph" => "scripts/roadmap-graph",
511
+ "scripts/index-projection" => "scripts/index-projection",
512
+ # Intent 338 (G5): the node packet command - the trust-boundary wrapper,
513
+ # the five-block gatherer/assembler, and the CLI 340's runner calls.
514
+ "scripts/lib/packet_wrapper.rb" => "scripts/lib/packet_wrapper.rb",
515
+ "scripts/lib/node_packet.rb" => "scripts/lib/node_packet.rb",
516
+ "scripts/node-packet" => "scripts/node-packet",
517
+ # Intent 342 (G9): the backward shim that presents actions/*.md as a
518
+ # node graph for any legacy intent, so WorkGraphValidator can require
519
+ # it without going red on contact with install_sync_test.
520
+ "scripts/lib/action_graph_shim.rb" => "scripts/lib/action_graph_shim.rb",
521
+ # Intent 340 (G7, n1): the runner command - the subcommand table over
522
+ # the declared node graph, its shared context (RunnerCore), and the
523
+ # installed-core integrity check (CoreIntegrity) the runner's trust
524
+ # boundary and doctor will both call.
525
+ "scripts/runner" => "scripts/runner",
526
+ "scripts/lib/runner_core.rb" => "scripts/lib/runner_core.rb",
527
+ "scripts/lib/core_integrity.rb" => "scripts/lib/core_integrity.rb",
528
+ # Intent 340 (G7, n2): the merge abort, the reclaim, and the extension -
529
+ # the first thing every `step` does, routed from scripts/runner's `sweep`
530
+ # verb.
531
+ "scripts/lib/runner_sweep.rb" => "scripts/lib/runner_sweep.rb",
532
+ # Intent 340 (G7, n3): a work node's own git worktree, cut from the
533
+ # intent branch tip, merged back into the intent branch, and swept once
534
+ # its node is terminal.
535
+ "scripts/lib/node_worktree.rb" => "scripts/lib/node_worktree.rb",
536
+ # Intent 340 (G7, n4): the return schema (NodeReturn) and the six-check
537
+ # gate (RunnerAbsorb) that turns one executor return into exactly one
538
+ # node ledger transition, required lazily by scripts/runner's `step`.
539
+ "scripts/lib/node_return.rb" => "scripts/lib/node_return.rb",
540
+ "scripts/lib/runner_absorb.rb" => "scripts/lib/runner_absorb.rb",
541
+ # Intent 340 (G7, n5): validation, policy, leases and the dispatch plan
542
+ # - RunnerPolicy (the kind table) and RunnerDispatch, required lazily
543
+ # by scripts/runner's `step`.
544
+ "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
545
+ "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
546
+ # Intent 340 (G7, n6): answer (closes a decision node or unparks a
547
+ # work node parked at needs_decision), proposals (mints ids for what
548
+ # an executor proposed), and rewind (resets the intent branch to a
549
+ # node's own commit and respins it) - routed from scripts/runner's
550
+ # `answer` and `rewind` verbs.
551
+ "scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
552
+ "scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
553
+ "scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
427
554
  }
428
555
  end
429
556
 
@@ -0,0 +1,309 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+ # IntentScreen (intent 316) - fills templates/intent-screen.md from one intent's
4
+ # record: the intent file, the tier's INDEX.md, savepoint.md and checklist.md.
5
+ # Every number on the screen comes from here so the session never writes one by
6
+ # eye. Pure: explicit paths in, a Markdown string out; no ENV, no Dir.pwd.
7
+ #
8
+ # Intent 316a fixed three defects the field code inherited into both the plain
9
+ # renderer and the ANSI renderer (scripts/lib/intent_screen_ansi.rb): the
10
+ # Insight row dumping a multi-clause remainder into the note column, an empty
11
+ # "What this means" heading rendering bold with nothing under it, and step
12
+ # text cut mid-sentence. `step_text`, `insight_fields` and `next_fields` are
13
+ # public so the ANSI renderer reuses the exact same trims (D3) rather than
14
+ # re-deriving them and drifting.
15
+ #
16
+ # Harness-agnostic core: no harness assumption lives here.
17
+ module IntentScreen
18
+ BAR_WIDTH = 20
19
+ ON = "█"
20
+ OFF = "░"
21
+ PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
22
+ SECTIONS = %w[Active Future Completed Abandoned].freeze
23
+ ITEM_RE = /^\s*- \[([ xX])\]\s+(.*)$/
24
+ # Em dash and en dash added (intent 316a O1e): a checklist item written
25
+ # "S1 — text" (the em dash every checklist this intent writes, and the one a
26
+ # reviewer reads, uses) kept its prefix under the old character class and
27
+ # rendered "S1 [ open ] S1 — text" on screen. Intent 317a (A1) made the
28
+ # separator optional with required whitespace: "S1 text" (the shape real
29
+ # checklists use) kept its label and doubled in next_fields; a bare "S1"
30
+ # has no following text and stays prose.
31
+ STEP_PREFIX_RE = /\A(?:Step|S)\s*\d+\s*(?:[-:·—–]\s*|\s+)(?=\S)/i
32
+ INSIGHT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s+·\s+\S+\s+·\s+.+?\s+—\s+(.+)\z/
33
+ SAVEPOINT_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}(\S+)\s{2,}(.+?)\s*\z/
34
+ # Intent 317, D6: field-2 tokens that are genuine lifecycle stages. A ledger
35
+ # can also carry non-lifecycle lines (`Lock takeover: ...`, and 317's own
36
+ # `Review`/`Commit`); those must never be mistaken for the current stage.
37
+ # Placed here, clear of STEP_PREFIX_RE above (316a edits that one).
38
+ LIFECYCLE_STAGES = %w[What Why How Exec Done].freeze
39
+
40
+ # True iff a raw savepoint ledger line's field 2 is a genuine lifecycle
41
+ # stage (post-execution-review finding 5: centralizes what spawn-preamble
42
+ # and agent-report used to each copy-paste beside the constant above).
43
+ def self.lifecycle_line?(line)
44
+ parts = line.to_s.split(/\s{2,}/)
45
+ parts.length >= 2 && LIFECYCLE_STAGES.include?(parts[1])
46
+ end
47
+
48
+ # Word-boundary truncation caps (intent 316a D3/O1a/O1c). Never a clause
49
+ # trim: a clause trim on step text destroys a pinned `OPEN:` row
50
+ # (test/intent_screen_test.rb:171-178) that a mid-sentence cut would eat.
51
+ INSIGHT_VALUE_MAX = 72
52
+ INSIGHT_NOTE_MAX = 96
53
+ NEXT_VALUE_MAX = 72
54
+ STEP_TEXT_MAX = 110
55
+
56
+ # Where a resume lands, from the ledger's last line (the boarding matrix).
57
+ def self.landing_stage(stage, milestone)
58
+ case stage
59
+ when "Done" then "Done"
60
+ when "What" then "Why"
61
+ when "Why" then milestone.to_s.include?("spec.md") ? "How" : "Why"
62
+ when "How" then milestone.to_s.include?("checklist.md") ? "Exec" : "How"
63
+ when "Exec" then milestone.to_s.include?("outcome.md") ? "ready to complete" : "Exec"
64
+ else "Why"
65
+ end
66
+ end
67
+
68
+ def self.intent_dir?(dir)
69
+ return false unless dir && File.directory?(dir)
70
+
71
+ base = File.basename(dir)
72
+ return false unless base.match?(/\A[0-9][0-9a-z]*--[\w-]+\z/)
73
+
74
+ File.exist?(File.join(dir, "#{base}.md"))
75
+ end
76
+
77
+ def self.render(intent_dir:, store_root:, template:)
78
+ base = File.basename(intent_dir)
79
+ id = base.split("--", 2).first
80
+ intent_text = File.read(File.join(intent_dir, "#{base}.md"))
81
+
82
+ fields = {}
83
+ fields.merge!(store_fields(store_root))
84
+ status, title = index_fields(store_root, id)
85
+ fields["status"] = status
86
+ fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
87
+ fields["id"] = id
88
+ fields["name"] = title || fallback_name(intent_text)
89
+ fields.merge!(savepoint_fields(intent_dir, intent_text))
90
+ items = checklist_items(intent_dir)
91
+ fields.merge!(progress_fields(items))
92
+ fields.merge!(next_fields(items, status, checklist_present: items_present?(intent_dir)))
93
+ fields.merge!(insight_fields(intent_text))
94
+ fields["steps.rows"] = steps_rows(items)
95
+
96
+ out = template.dup
97
+ fields.each { |k, v| out = out.gsub("{{#{k}}}", v.to_s) }
98
+ out.gsub(/\n{3,}/, "\n\n")
99
+ end
100
+
101
+ # --- store ---------------------------------------------------------------------
102
+
103
+ def self.store_fields(store_root)
104
+ parent = File.basename(File.dirname(store_root))
105
+ if parent == "projects"
106
+ slug = File.basename(store_root)
107
+ { "store" => "project:#{slug}", "store.note" => "the #{slug} project store" }
108
+ else
109
+ { "store" => "global", "store.note" => "the global store" }
110
+ end
111
+ end
112
+
113
+ # --- INDEX.md -----------------------------------------------------------------
114
+
115
+ def self.index_fields(store_root, id)
116
+ path = File.join(store_root, "INDEX.md")
117
+ return ["unlisted", nil] unless File.exist?(path)
118
+
119
+ section = nil
120
+ File.foreach(path) do |line|
121
+ if line.start_with?("## ")
122
+ name = line[3..].strip
123
+ section = SECTIONS.include?(name) ? name : nil
124
+ next
125
+ end
126
+ next unless section && line.strip.start_with?("- [")
127
+
128
+ m = line.match(/\A\s*- \[#{Regexp.escape(id)}\s+[-—]\s+(.+?)\]\(/)
129
+ return [section, m[1].strip] if m
130
+ end
131
+ ["unlisted", nil]
132
+ end
133
+
134
+ def self.fallback_name(intent_text)
135
+ m = intent_text.match(/^intent:\s*["']?(.+?)["']?\s*$/)
136
+ text = m ? m[1] : ""
137
+ text.length > 60 ? "#{text[0, 57]}..." : text
138
+ end
139
+
140
+ # --- savepoint.md ---------------------------------------------------------------
141
+
142
+ def self.savepoint_fields(intent_dir, intent_text)
143
+ path = File.join(intent_dir, "savepoint.md")
144
+ lines = File.exist?(path) ? File.readlines(path).map(&:strip).reject(&:empty?) : []
145
+ matched = lines.reverse.map { |l| l.match(SAVEPOINT_RE) }.compact
146
+ last = matched.first
147
+ unless last
148
+ return { "stage" => "Why", "stage.note" => "no savepoint line yet",
149
+ "savepoint" => "none", "savepoint.note" => "" }
150
+ end
151
+
152
+ # D6: the STAGE PICK is guarded to the last LIFECYCLE line (What/Why/How/
153
+ # Exec/Done), so a trailing Lock/Review/Commit line cannot be mistaken for
154
+ # the stage. The Savepoint field below still shows the TRUE last line,
155
+ # whatever its kind - that is what a savepoint is.
156
+ lifecycle_last = matched.find { |m| LIFECYCLE_STAGES.include?(m[2]) }
157
+ stage_source = lifecycle_last || last
158
+
159
+ ts, stage, milestone = last[1], last[2], last[3]
160
+ landing = landing_stage(stage_source[2], stage_source[3])
161
+ delivered = lines.map { |l| l.match(SAVEPOINT_RE) }.compact.map { |m| m[2] }.uniq
162
+ delivered &= %w[What Why How Exec]
163
+ note = if landing == "Done"
164
+ "delivered; the record is immutable"
165
+ elsif landing == "ready to complete"
166
+ "outcome.md is real; run the ending procedure"
167
+ else
168
+ "#{delivered.join(', ')} delivered; the work is open"
169
+ end
170
+ { "stage" => landing, "stage.note" => note,
171
+ "savepoint" => "#{stage} · #{milestone}", "savepoint.note" => human_time(ts) }
172
+ end
173
+
174
+ def self.human_time(ts)
175
+ m = ts.match(/\A(\d{4}-\d\d-\d\d)T(\d\d:\d\d)/)
176
+ m ? "#{m[1]} #{m[2]} UTC" : ts
177
+ end
178
+
179
+ # --- checklist.md --------------------------------------------------------------
180
+
181
+ def self.items_present?(intent_dir)
182
+ path = File.join(intent_dir, "checklist.md")
183
+ return false unless File.exist?(path)
184
+
185
+ !File.read(path).lstrip.start_with?(PLACEHOLDER_SENTINEL)
186
+ end
187
+
188
+ def self.checklist_items(intent_dir)
189
+ return [] unless items_present?(intent_dir)
190
+
191
+ File.readlines(File.join(intent_dir, "checklist.md")).filter_map do |line|
192
+ m = line.match(ITEM_RE)
193
+ next unless m
194
+
195
+ text = m[2].strip
196
+ next if text == "..."
197
+
198
+ { done: m[1] != " ", text: text.sub(STEP_PREFIX_RE, "") }
199
+ end
200
+ end
201
+
202
+ def self.progress_fields(items)
203
+ total = items.length
204
+ done = items.count { |i| i[:done] }
205
+ on = total.zero? ? 0 : (done * BAR_WIDTH) / total
206
+ bar = (ON * on) + (OFF * (BAR_WIDTH - on))
207
+ note = if total.zero?
208
+ "no checklist yet"
209
+ elsif done == total
210
+ "all steps done"
211
+ else
212
+ "#{total - done} steps open"
213
+ end
214
+ { "progress.bar" => bar, "progress.done" => done.to_s, "progress.total" => total.to_s,
215
+ "progress.note" => note }
216
+ end
217
+
218
+ # `escape_pipes:` (intent 316a O1d, default true) keeps the plain Markdown
219
+ # table's pipe-escaping; the ANSI renderer, which never emits a table,
220
+ # passes `escape_pipes: false` to get the raw value instead of a literal
221
+ # `\|`. Named to not shadow the module's own `escape` method.
222
+ def self.next_fields(items, status, checklist_present:, escape_pipes: true)
223
+ return { "next" => "", "next.note" => "" } if %w[Completed Abandoned].include?(status)
224
+ return { "next" => "write checklist.md", "next.note" => "How" } unless checklist_present
225
+
226
+ idx = items.index { |i| !i[:done] }
227
+ return { "next" => "", "next.note" => "all steps done" } unless idx
228
+
229
+ head, = split_first_clause(items[idx][:text])
230
+ head = truncate_words(head, NEXT_VALUE_MAX)
231
+ head = escape(head) if escape_pipes
232
+ { "next" => "S#{idx + 1} · #{head}", "next.note" => "first open step" }
233
+ end
234
+
235
+ def self.steps_rows(items)
236
+ return "| | | no steps yet |" if items.empty?
237
+
238
+ items.each_with_index.map do |item, i|
239
+ "| S#{i + 1} | #{item[:done] ? 'done' : 'open'} | #{escape(step_text(item[:text]))} |"
240
+ end.join("\n")
241
+ end
242
+
243
+ # Public (intent 316a O1c) so the ANSI renderer trims step text identically:
244
+ # word-boundary truncation only, never a clause trim, at STEP_TEXT_MAX.
245
+ def self.step_text(text)
246
+ truncate_words(text, STEP_TEXT_MAX)
247
+ end
248
+
249
+ def self.escape(text)
250
+ text.gsub("|", "\\|")
251
+ end
252
+
253
+ # --- ## Insights ----------------------------------------------------------------
254
+
255
+ def self.insight_fields(intent_text, escape_pipes: true)
256
+ section = intent_text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
257
+ entry = section.lines.map(&:strip).reverse.map { |l| l.match(INSIGHT_RE) }.compact.first
258
+ return { "insight" => "none yet", "insight.note" => "" } unless entry
259
+
260
+ ts, text = entry[1], entry[2].strip
261
+ head, tail = split_first_clause(text)
262
+ value = truncate_words(head, INSIGHT_VALUE_MAX)
263
+ tail = tail.empty? ? "" : truncate_words(tail, INSIGHT_NOTE_MAX)
264
+ note = tail.empty? ? human_time(ts) : "#{human_time(ts)} · #{tail}"
265
+ if escape_pipes
266
+ { "insight" => escape(value), "insight.note" => escape(note) }
267
+ else
268
+ { "insight" => value, "insight.note" => note }
269
+ end
270
+ end
271
+
272
+ # First clause of `text`, and at most one following clause as the tail.
273
+ # Anything past the second clause is discarded (intent 316a O1a): the old
274
+ # behavior dumped the ENTIRE remainder into the note (an 800-character
275
+ # real-world tail starting mid-list). Boundary is a `.` or `;` immediately
276
+ # followed by whitespace-then-more or end of string, so "alpha.2" and "2.0"
277
+ # are never mistaken for clause ends.
278
+ def self.split_first_clause(text)
279
+ head, rest = clause_and_rest(text)
280
+ return [head, ""] unless rest
281
+
282
+ second, more = clause_and_rest(rest)
283
+ tail = more ? second : rest
284
+ [head, tail]
285
+ end
286
+
287
+ # Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
288
+ # the remainder means either no boundary exists at all, or the boundary
289
+ # sits at the absolute end of `text` (a single trailing clause with nothing
290
+ # after it) — both cases where there is no SECOND clause to fold in.
291
+ def self.clause_and_rest(text)
292
+ m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
293
+ return [text, nil] unless m
294
+
295
+ remainder = m[2].to_s.strip
296
+ remainder.empty? ? [m[1], nil] : [m[1], remainder]
297
+ end
298
+
299
+ # Word-boundary truncation with a trailing "…" when cut, never mid-word and
300
+ # never a clause trim (intent 316a D3).
301
+ def self.truncate_words(text, max)
302
+ return text if text.length <= max
303
+ return "…" if max <= 1
304
+
305
+ cut = text[0, max - 1].rindex(" ")
306
+ cut = max - 1 if cut.nil? || cut.zero?
307
+ "#{text[0, cut].rstrip}…"
308
+ end
309
+ end