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

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 (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,31 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # AtomicWrite (intent 334, n2, D19r): a sibling-temp-plus-rename write, the
5
+ # same shape as Lock#write (scripts/lib/lock.rb) - a content write is never
6
+ # an in-place truncate, so a crash mid-write can never leave an empty or
7
+ # half-written target on disk. The temp file is a SIBLING in the same
8
+ # directory as the target, never a system tmpdir, because File.rename can
9
+ # raise EXDEV when the temp and the target live on different filesystems.
10
+ # The temp name ends in .lock (post-execution review, non-blocking 5), the
11
+ # same property Lock#write_temp_path documents: an orphan left by a crash
12
+ # between the write and the rename is covered by the store's existing
13
+ # *.lock gitignore rule rather than swept into its git add -A auto-commit.
14
+ #
15
+ # The renamer is injectable so the interrupted-write case (a rename that
16
+ # raises) is testable with dependency injection, never eval or a global
17
+ # (D19r).
18
+ module AtomicWrite
19
+ module_function
20
+
21
+ def write(path, content, renamer: File.method(:rename))
22
+ dir = File.dirname(path)
23
+ temp = File.join(dir, ".#{File.basename(path)}.tmp.#{Process.pid}.#{Time.now.to_f}.#{rand(0xFFFFFF)}.lock")
24
+ File.write(temp, content)
25
+ renamer.call(temp, path)
26
+ true
27
+ rescue StandardError
28
+ File.delete(temp) if temp && File.exist?(temp)
29
+ raise
30
+ end
31
+ end
@@ -19,9 +19,9 @@ require "digest"
19
19
  #
20
20
  # Library only: no CLI, no ENV, no I/O.
21
21
  module CompactInstructions
22
- # 35 and 50 percent of a 1M window.
23
- OFFER_TOKENS = 350_000
24
- INSIST_TOKENS = 500_000
22
+ # 15 and 25 percent of a 1M window (intent 355, n5, D7).
23
+ OFFER_TOKENS = 150_000
24
+ INSIST_TOKENS = 250_000
25
25
 
26
26
  # Static on purpose. A body rendered from the user's config would change its hash
27
27
  # every time they edited config.yml, and doctor would then report a correct install
@@ -32,10 +32,10 @@ module CompactInstructions
32
32
  Plastic watches this session's context. When the harness reports how much of the
33
33
  window is used:
34
34
 
35
- - At 350,000 tokens, offer to compact. Say that the hand-off in today's day ledger
35
+ - At 150,000 tokens, offer to compact. Say that the hand-off in today's day ledger
36
36
  is written and current, and take no for an answer: a task that is nearly done
37
37
  does not need the interruption.
38
- - At 500,000 tokens, insist. Take no new work, write the hand-off in today's day
38
+ - At 250,000 tokens, insist. Take no new work, write the hand-off in today's day
39
39
  ledger, and compact before continuing.
40
40
  - After a compaction, say continue. The day summary at boot and the hand-off carry
41
41
  the state; do not rebuild it by re-reading files.
@@ -0,0 +1,71 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "digest"
6
+
7
+ # CoreIntegrity (intent 340, G7, n1): re-hashes the files an installed
8
+ # ~/.plastic/manifest.json lists, so the runner's own "am I running trusted
9
+ # code" check has one implementation. scripts/doctor.rb's
10
+ # Doctor#check_install_integrity does this same arithmetic inline today,
11
+ # per-agent (Codex/Hermes/Claude), against each agent's own manifest; this
12
+ # module is not a refactor of that (out of scope for this node), it exists
13
+ # so the runner - and a later node that points doctor at it - never have to
14
+ # reimplement the same hashing loop a third time.
15
+ #
16
+ # Pure and side-effect-free: `check` takes plastic_home: and never raises
17
+ # across its boundary. A missing, unreadable, or non-JSON manifest.json is a
18
+ # named refusal (`reason:`), never an exception. A manifest-listed file that
19
+ # no longer exists is reported under `missing`, distinct from `drifted` (a
20
+ # file that exists but no longer hashes to the manifest's recorded value).
21
+ module CoreIntegrity
22
+ module_function
23
+
24
+ def check(plastic_home:)
25
+ manifest_path = File.join(plastic_home.to_s, "manifest.json")
26
+ return refusal("manifest.json not found at #{manifest_path}") unless File.exist?(manifest_path)
27
+
28
+ raw = begin
29
+ File.read(manifest_path)
30
+ rescue StandardError => e
31
+ return refusal("manifest.json is unreadable: #{e.message}")
32
+ end
33
+
34
+ data = begin
35
+ JSON.parse(raw)
36
+ rescue JSON::ParserError => e
37
+ return refusal("manifest.json is not valid JSON: #{e.message}")
38
+ end
39
+
40
+ files = data.is_a?(Hash) ? data["files"] : nil
41
+ return refusal("manifest.json's files: is not a mapping") unless files.is_a?(Hash)
42
+
43
+ drifted = []
44
+ missing = []
45
+ files.each do |path, expected_hash|
46
+ unless File.exist?(path)
47
+ missing << path
48
+ next
49
+ end
50
+
51
+ # v1 minor 6/row 11.16: a tracked file that exists but cannot be read
52
+ # (permissions changed under this process) must never raise out of a
53
+ # module whose whole contract is "never raises across its boundary" -
54
+ # unreadable is reported the same way a hash mismatch is: this
55
+ # process cannot confirm the file matches what the manifest expects,
56
+ # which is exactly what `drifted` already means.
57
+ actual_hash = begin
58
+ Digest::SHA256.file(path).hexdigest
59
+ rescue SystemCallError
60
+ nil
61
+ end
62
+ drifted << path if actual_hash.nil? || actual_hash != expected_hash
63
+ end
64
+
65
+ { ok: drifted.empty? && missing.empty?, drifted: drifted, missing: missing, reason: nil }
66
+ end
67
+
68
+ def refusal(reason)
69
+ { ok: false, drifted: [], missing: [], reason: reason }
70
+ end
71
+ end
@@ -0,0 +1,40 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "report_screen"
5
+
6
+ # DashboardScreen (intent 331d) - the presentation half of the dashboard
7
+ # screen. scripts/dashboard.rb's screen_fields sources every fact (Active, In
8
+ # delivery, Delivered, Roadmap, Sessions, Changed, the two capped row lists)
9
+ # through the same helpers report-screen and DaySummary already use, turning
10
+ # a missing source into "not recorded" or "none" before it ever reaches
11
+ # here; this module carries no data-sourcing logic of its own, only layout,
12
+ # exactly like ReportScreen.render_state and IntentScreen.render do for
13
+ # their own screens.
14
+ module DashboardScreen
15
+ module_function
16
+
17
+ TEMPLATE_PATH = File.expand_path("../../templates/dashboard-screen.md", __dir__)
18
+
19
+ def render(fields, template: nil)
20
+ out = (template || File.read(TEMPLATE_PATH)).dup
21
+ out = out.gsub("{{scope}}", fields.fetch(:scope).to_s)
22
+ out = out.gsub("{{active}}", fields.fetch(:active).to_s)
23
+ out = out.gsub("{{in_delivery}}", fields.fetch(:in_delivery).to_s)
24
+ out = out.gsub("{{delivered}}", fields.fetch(:delivered).to_s)
25
+ out = out.gsub("{{roadmap}}", fields.fetch(:roadmap).to_s)
26
+ out = out.gsub("{{sessions}}", fields.fetch(:sessions).to_s)
27
+ out = out.gsub("{{changed}}", fields.fetch(:changed).to_s)
28
+ out = out.gsub("{{where_we_are.rows}}", where_we_are_rows(fields.fetch(:where_we_are, [])))
29
+ out = out.gsub("{{where_we_go_next.rows}}", where_we_go_next_rows(fields.fetch(:where_we_go_next, [])))
30
+ ReportScreen.fit_screen(out.gsub(/\n{3,}/, "\n\n"))
31
+ end
32
+
33
+ def where_we_are_rows(rows)
34
+ rows.map { |r| "| #{r[:graph_id]} | #{r[:intent]} | #{r[:stage]} | #{r[:progress]} | #{r[:lead]} |" }.join("\n")
35
+ end
36
+
37
+ def where_we_go_next_rows(rows)
38
+ rows.map { |r| "| #{r[:rank]} | #{r[:graph_id]} | #{r[:intent]} | #{r[:reason]} |" }.join("\n")
39
+ end
40
+ end
@@ -12,6 +12,7 @@ require "time"
12
12
  require_relative "session_ledger"
13
13
  require_relative "handoff"
14
14
  require_relative "lock"
15
+ require_relative "report_screen"
15
16
 
16
17
  module DaySummary
17
18
  module_function
@@ -113,8 +114,12 @@ module DaySummary
113
114
  dir = File.join(store_dir, dirname)
114
115
  next unless File.directory?(dir) && Lock.fresh?(dir, now: now)
115
116
  # A guided session's lock is live but not autonomous; a lock with no
116
- # run_mode (a 1.14 auto team) counts as auto.
117
- next if (Lock.read(dir) || {})["run_mode"].to_s == "guided"
117
+ # run_mode (a 1.14 auto team) counts as auto. 317a (B9): when the lock
118
+ # carries no run_mode, the outcome frontmatter stamp (D5) is asked
119
+ # before defaulting, so a guided close never reads as auto.
120
+ run_mode = (Lock.read(dir) || {})["run_mode"].to_s
121
+ run_mode = ReportScreen.outcome_frontmatter(dir)["mode"].to_s if run_mode.empty?
122
+ next if run_mode == "guided"
118
123
 
119
124
  id, slug = dirname.split("--", 2)
120
125
  "- #{id} #{slug}: #{last_savepoint_line(dir)}"
@@ -27,9 +27,11 @@ class Doctor
27
27
  "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
28
28
  }.freeze
29
29
 
30
- # The Claude events hooks_registered expects in settings.json: the five-event map of
31
- # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301).
32
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd].freeze
30
+ # The Claude events hooks_registered expects in settings.json: the six-event map of
31
+ # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
32
+ # intent 316a added MessageDisplay, registered for message-display, Claude only).
33
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreToolUse PreCompact PostToolUse UserPromptSubmit SessionEnd
34
+ MessageDisplay].freeze
33
35
 
34
36
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
35
37
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -90,8 +92,19 @@ class Doctor
90
92
 
91
93
  JSON.parse(File.read(path))
92
94
  rescue JSON::ParserError
93
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
94
- JSON.parse(content)
95
+ # The comment/trailing-comma-stripped retry below can itself raise
96
+ # JSON::ParserError on genuinely malformed content (a truncated file, or
97
+ # plain garbage). A nested begin/rescue is required here because a
98
+ # method-level `rescue` clause never catches an exception raised from
99
+ # INSIDE a sibling rescue clause's own body (only from the main body).
100
+ # Without this nesting a malformed settings.json crashes doctor instead
101
+ # of reporting a clean fail (intent 331e, F5).
102
+ begin
103
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
104
+ JSON.parse(content)
105
+ rescue
106
+ nil
107
+ end
95
108
  rescue
96
109
  nil
97
110
  end
@@ -1244,10 +1257,94 @@ class Doctor
1244
1257
  all_checks += check_manifest_sync(agent_key)
1245
1258
  all_checks += check_registered_project_paths
1246
1259
  all_checks += check_global_store_available
1260
+ all_checks += check_display_registration(agent_key)
1247
1261
 
1248
1262
  summarize(all_checks, agent_key, binary: true)
1249
1263
  end
1250
1264
 
1265
+ # The single `hooks/<name>` launcher basename for the MessageDisplay event,
1266
+ # derived from HookRegistry rather than hand-kept (intent 331e), so a
1267
+ # future rename of the hook stays in one place. Shared by
1268
+ # check_display_registration (below, boot path) and scripts/doctor.rb's
1269
+ # check_display_paints (full run), which resolves the SAME name under the
1270
+ # agent_dir it was given.
1271
+ def display_hook_launcher_name
1272
+ group = HookRegistry.events["MessageDisplay"].first
1273
+ "plastic-#{group['hooks'].first['name']}"
1274
+ end
1275
+
1276
+ DISPLAY_HOOK_FIX_HINT = "Re-run the Plastic installer to repair the hook registration: " \
1277
+ "npx @zalom/plastic@<channel> install --reinstall --claude " \
1278
+ "(plastic-install --repair)".freeze
1279
+
1280
+ # display_hook_registered (intent 331e, D1, category "display"): the Claude
1281
+ # settings carry the plastic-message-display command, on-disk, executable.
1282
+ # Boot-path safe: resolves everything from the injected `agents` hash and
1283
+ # `plastic_home`, never Dir.home or a real ~/.claude (E18). This is the same
1284
+ # discipline check_claude_registration already follows.
1285
+ #
1286
+ # D3: a harness Doctor knows carries no display hook (Codex, Hermes) is a
1287
+ # pass, not a fail, worded "plain by contract" like the paint check's own
1288
+ # skip (scripts/doctor.rb's check_display_paints).
1289
+ def check_display_registration(agent_key)
1290
+ config = agents[agent_key]
1291
+ unless agent_key == "claude"
1292
+ return [check(
1293
+ category: "display", name: "display_hook_registered", status: "pass",
1294
+ message: "#{config[:name]} is plain by contract; no MessageDisplay hook to register"
1295
+ )]
1296
+ end
1297
+
1298
+ agent_dir = config[:dir]
1299
+ settings_path = File.join(agent_dir, "settings.json")
1300
+ settings = read_json_safe(settings_path)
1301
+
1302
+ if settings.nil?
1303
+ return [check(
1304
+ category: "display", name: "display_hook_registered", status: "fail",
1305
+ message: "Cannot read #{tilde(settings_path)}: file missing or invalid",
1306
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1307
+ )]
1308
+ end
1309
+
1310
+ hooks = settings["hooks"].is_a?(Hash) ? settings["hooks"] : {}
1311
+ commands = event_commands(hooks["MessageDisplay"])
1312
+ launcher_name = display_hook_launcher_name
1313
+
1314
+ registered = commands.any? { |cmd| HookRegistry.command_basenames(cmd).include?(launcher_name) }
1315
+
1316
+ unless registered
1317
+ return [check(
1318
+ category: "display", name: "display_hook_registered", status: "fail",
1319
+ message: "No MessageDisplay hook registered in #{tilde(settings_path)}",
1320
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1321
+ )]
1322
+ end
1323
+
1324
+ launcher_path = File.join(agent_dir, "hooks", launcher_name)
1325
+
1326
+ unless File.exist?(launcher_path)
1327
+ return [check(
1328
+ category: "display", name: "display_hook_registered", status: "fail",
1329
+ message: "MessageDisplay is registered but #{tilde(launcher_path)} does not exist",
1330
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1331
+ )]
1332
+ end
1333
+
1334
+ unless File.executable?(launcher_path)
1335
+ return [check(
1336
+ category: "display", name: "display_hook_registered", status: "fail",
1337
+ message: "#{tilde(launcher_path)} exists but is not executable",
1338
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1339
+ )]
1340
+ end
1341
+
1342
+ [check(
1343
+ category: "display", name: "display_hook_registered", status: "pass",
1344
+ message: "MessageDisplay hook registered and #{tilde(launcher_path)} is executable"
1345
+ )]
1346
+ end
1347
+
1251
1348
  def check_registered_project_paths
1252
1349
  checks = []
1253
1350
 
@@ -18,6 +18,18 @@ require_relative "session_ledger"
18
18
  module DoctorSessionLedger
19
19
  ORPHAN_TTL_SECONDS = 24 * 60 * 60
20
20
 
21
+ # Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
22
+ # DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
23
+ # session start never ran at all (a `-p` print session, a resumed
24
+ # background job, an unregistered SessionStart hook), not a session that
25
+ # ran and then never closed. No choice of session id ever creates a
26
+ # pointer for that class, so it would otherwise stay invisible for the
27
+ # full 24 hours (or forever, once hook-capture/hook-record stop creating
28
+ # it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
29
+ # between its own start and its first pointer write is normal and must not
30
+ # be flagged, but that window is seconds, not hours.
31
+ NO_POINTER_TTL_SECONDS = 5 * 60
32
+
21
33
  # Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
22
34
  # else that file's mtime, else the directory's mtime.
23
35
  def heartbeat_age(dir, now)
@@ -37,6 +49,7 @@ module DoctorSessionLedger
37
49
 
38
50
  store_dir = File.join(plastic_home, "store")
39
51
  orphans = orphaned_session_dirs(store_dir, now)
52
+ no_pointer = no_pointer_session_dirs(store_dir, now)
40
53
  shape = day_ledger_shape_problems(store_dir)
41
54
 
42
55
  checks = []
@@ -54,6 +67,20 @@ module DoctorSessionLedger
54
67
  "session is gone: a live session rewrites its heartbeat on every " \
55
68
  "prompt and edit, so only a listed directory may be removed, by hand.")
56
69
  end
70
+ checks << if no_pointer.empty?
71
+ check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
72
+ message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
73
+ "longer than #{NO_POINTER_TTL_SECONDS} seconds")
74
+ else
75
+ check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
76
+ message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
77
+ "with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
78
+ "(session start never ran for this session)",
79
+ details: no_pointer, fixable: true,
80
+ fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
81
+ "session never started: no `current` pointer means session start never " \
82
+ "ran for it, so this is not a live session missing a checklist entry.")
83
+ end
57
84
  checks << if shape.empty?
58
85
  check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
59
86
  message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
@@ -86,6 +113,31 @@ module DoctorSessionLedger
86
113
  [] # unreadable .tmp/: nothing to report, never a crash
87
114
  end
88
115
 
116
+ # Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
117
+ # NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
118
+ # that one is age-only and blind to whether a pointer exists at all, so a
119
+ # young no-pointer dir (a session between its start and its first pointer
120
+ # write) must not appear here even though it may well appear there once it
121
+ # ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
122
+ # and a dir can legitimately show up in neither, either, or both.
123
+ def no_pointer_session_dirs(store_dir, now)
124
+ tmp_root = SessionLedger.tmp_root(store_dir)
125
+ return [] unless File.directory?(tmp_root)
126
+
127
+ Dir.children(tmp_root).sort.filter_map do |name|
128
+ dir = File.join(tmp_root, name)
129
+ next unless File.directory?(dir)
130
+ next if File.exist?(File.join(dir, "current"))
131
+
132
+ age = heartbeat_age(dir, now)
133
+ next if age <= NO_POINTER_TTL_SECONDS
134
+
135
+ "global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
136
+ end
137
+ rescue SystemCallError
138
+ [] # unreadable .tmp/: nothing to report, never a crash
139
+ end
140
+
89
141
  def day_ledger_shape_problems(store_dir)
90
142
  root = SessionLedger.sessions_root(store_dir)
91
143
  return [] unless File.directory?(root)
@@ -0,0 +1,137 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # GraphEdges (intent 334, n1): the shared `needs` syntax and cycle-path check
5
+ # (327 D2r-D4r, D12r). Parses a "## Graph" section's edge lines into a node
6
+ # set and an edge map, the one parser an intent's graph.md and a roadmap's
7
+ # own ## Graph section both use unchanged (C1, fold A5): the same list-item
8
+ # grammar, the same root keyword, the same cycle walk. Deliberately loose
9
+ # about what an id looks like - a roadmap id is numeric ("334", "340a"), a
10
+ # node id carries a kind prefix ("n1") - the kind-prefix rule belongs to
11
+ # NodeFile and WorkGraphValidator, never here (D12r, fold A6).
12
+ #
13
+ # Grammar (D2r/D3r): a list item shaped "- <id> needs <target> [<target>
14
+ # ...]", ending at end of line. The single literal target "nothing" declares
15
+ # a root and must be the ONLY target on the line. Any line that does not
16
+ # start with "- <token> needs" is prose and is skipped in silence. A line
17
+ # that DOES look like an edge is held to the grammar strictly: no target at
18
+ # all, "nothing" alongside other targets, or a target token carrying
19
+ # sentence punctuation (a period, a colon, ...) rather than the loose
20
+ # id-token charset, is an error naming the line, never a silently
21
+ # mis-parsed edge or an invented node (fold A7).
22
+ #
23
+ # Pure and side-effect-free; never raises across the boundary, matching the
24
+ # Result-hash convention every library in scripts/lib/ follows.
25
+ module GraphEdges
26
+ module_function
27
+
28
+ EDGE_LEAD_RE = /\A-\s*(\S+)\s+needs\b(.*)\z/.freeze
29
+ TOKEN_RE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
30
+ ROOT_TARGET = "nothing"
31
+
32
+ # {nodes:, edges:, errors:} - nodes is the declared ids together with every
33
+ # id any edge targets (fold A5), in first-seen order; edges maps a declared
34
+ # id to its target ids (empty for a root); errors names each malformed
35
+ # edge-shaped line. Prose lines contribute nothing and raise nothing.
36
+ def parse(section_text)
37
+ declared_order = []
38
+ edges = {}
39
+ targeted = []
40
+ errors = []
41
+
42
+ section_text.to_s.each_line do |raw_line|
43
+ line = raw_line.chomp("\n").chomp("\r")
44
+ stripped = line.strip
45
+ next if stripped.empty?
46
+
47
+ m = stripped.match(EDGE_LEAD_RE)
48
+ next unless m
49
+
50
+ id = m[1]
51
+ raw_targets = m[2].to_s.strip.split(/\s+/)
52
+
53
+ if raw_targets.empty?
54
+ errors << "malformed edge line, no target: #{stripped.inspect}"
55
+ next
56
+ end
57
+
58
+ if raw_targets.include?(ROOT_TARGET) && raw_targets.length > 1
59
+ errors << "root target #{ROOT_TARGET.inspect} mixed with a real target, root must be the only target: #{stripped.inspect}"
60
+ next
61
+ end
62
+
63
+ bad_token = raw_targets.find { |t| t != ROOT_TARGET && !t.match?(TOKEN_RE) }
64
+ if bad_token
65
+ errors << "prose tail after targets: #{stripped.inspect}"
66
+ next
67
+ end
68
+
69
+ if edges.key?(id)
70
+ errors << "duplicate node declaration for #{id.inspect}: #{stripped.inspect}"
71
+ next
72
+ end
73
+
74
+ targets = raw_targets == [ROOT_TARGET] ? [] : raw_targets
75
+ declared_order << id
76
+ edges[id] = targets
77
+ targeted.concat(targets)
78
+ end
79
+
80
+ nodes = declared_order.dup
81
+ targeted.each { |t| nodes << t unless nodes.include?(t) }
82
+
83
+ { nodes: nodes, edges: edges, errors: errors }
84
+ end
85
+
86
+ # The cycle as a path with the entry id repeated (D4r), or nil when the
87
+ # graph is acyclic. A node reachable by two distinct paths (a diamond) is
88
+ # never mistaken for a cycle: once a node's whole subtree is walked clean
89
+ # it is marked visited and never re-examined.
90
+ def cycle(edges)
91
+ visiting = {}
92
+ visited = {}
93
+ path = []
94
+
95
+ edges.each_key do |node|
96
+ next if visited[node]
97
+ found = walk(node, edges, visiting, visited, path)
98
+ return found if found
99
+ end
100
+ nil
101
+ end
102
+
103
+ def walk(node, edges, visiting, visited, path)
104
+ if visiting[node]
105
+ idx = path.index(node)
106
+ return path[idx..] + [node]
107
+ end
108
+ return nil if visited[node]
109
+
110
+ visiting[node] = true
111
+ path.push(node)
112
+ (edges[node] || []).each do |target|
113
+ found = walk(target, edges, visiting, visited, path)
114
+ return found if found
115
+ end
116
+ path.pop
117
+ visiting.delete(node)
118
+ visited[node] = true
119
+ nil
120
+ end
121
+
122
+ # The declared work ids whose `needs` reach a verify node without passing
123
+ # through another verify node (343 D7), in declaration order. `kinds` maps
124
+ # an id to its node kind, so this module stays blind to where a kind lives.
125
+ def review_fixes(edges, kinds)
126
+ edges.keys.select { |id| kinds[id] == "work" && reaches_verify?(id, edges, kinds, {}) }
127
+ end
128
+
129
+ def reaches_verify?(node, edges, kinds, seen)
130
+ (edges[node] || []).any? do |target|
131
+ next false if seen[target]
132
+
133
+ seen[target] = true
134
+ kinds[target] == "verify" || reaches_verify?(target, edges, kinds, seen)
135
+ end
136
+ end
137
+ end