@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -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-call-budget +222 -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 +45 -7
- 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/compact_instructions.rb +5 -5
- 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 +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -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 +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- 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/meter_watch.rb +179 -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 +908 -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 +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -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/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -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/read-config +3 -3
- 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 +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -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 +41 -34
- 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 +21 -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/config.yml +3 -3
- 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
|
@@ -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
|
-
#
|
|
23
|
-
OFFER_TOKENS =
|
|
24
|
-
INSIST_TOKENS =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
31
|
-
# cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301
|
|
32
|
-
|
|
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
|
-
|
|
94
|
-
JSON
|
|
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
|