@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +886 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +392 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +24 -17
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +17 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -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.
|
|
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
|
|
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
|