@zalom/plastic 1.14.1 → 2.0.0-alpha.2
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/PLASTIC.md +101 -294
- package/README.md +5 -6
- package/agents/plastic-advisor.md +18 -17
- package/agents/plastic-enforcer.md +57 -69
- package/agents/plastic-executor.md +34 -20
- package/agents/plastic-faux-advisor.md +18 -18
- package/bin/lib/context_budget.rb +453 -0
- package/bin/plastic-bench +78 -0
- package/config_asks.yml +38 -0
- package/hooks/capture +4 -0
- package/hooks/close +5 -0
- package/hooks/hooks.json +9 -49
- package/hooks/record +4 -0
- package/hooks/savepoint +5 -5
- package/package.json +3 -2
- package/scripts/agent-report +8 -9
- package/scripts/append-ledger +208 -0
- package/scripts/codex-hook +78 -90
- package/scripts/dashboard.rb +4 -4
- package/scripts/day-summary +53 -0
- package/scripts/doctor.rb +106 -43
- package/scripts/end-intent +77 -76
- package/scripts/exec-worktree +11 -20
- package/scripts/file-session-intent +61 -0
- package/scripts/hook-capture +242 -0
- package/scripts/hook-close +36 -0
- package/scripts/hook-record +149 -0
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +106 -12
- package/scripts/install.rb +8 -6
- package/scripts/lib/agent_models.rb +1 -7
- package/scripts/lib/arm.rb +283 -0
- package/scripts/lib/backfill_intent.rb +316 -0
- package/scripts/lib/bridge.rb +24 -1597
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +206 -0
- package/scripts/lib/doctor_core.rb +65 -88
- package/scripts/lib/doctor_session_ledger.rb +106 -0
- package/scripts/lib/exec_worktree.rb +14 -60
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/harness_text.rb +5 -4
- package/scripts/lib/hook_registry.rb +44 -107
- package/scripts/lib/insights.rb +3 -4
- package/scripts/lib/installer_core.rb +104 -40
- package/scripts/lib/lock.rb +6 -26
- package/scripts/lib/outcome_guard.rb +7 -5
- package/scripts/lib/project_validator.rb +30 -1
- package/scripts/lib/roadmap_queue.rb +3 -3
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/rule_catalog.rb +6 -3
- package/scripts/lib/savepoint.rb +320 -0
- package/scripts/lib/scaffold_intent.rb +13 -179
- package/scripts/lib/session_backfill.rb +184 -0
- package/scripts/lib/session_close.rb +104 -0
- package/scripts/lib/session_git.rb +469 -0
- package/scripts/lib/session_ledger.rb +497 -0
- package/scripts/lib/store_discovery.rb +1 -1
- package/scripts/lib/store_provisioning.rb +1 -1
- package/scripts/maintenance-run +7 -7
- package/scripts/new-intent +63 -4
- package/scripts/plastic-lock +94 -41
- package/scripts/promote-session-item +150 -0
- package/scripts/read-config +3 -0
- package/scripts/rebuild-graph +1 -1
- package/scripts/roadmap-next +2 -2
- package/scripts/rollback.rb +48 -3
- package/scripts/scaffold-intent +37 -63
- package/scripts/session-commit +116 -0
- package/scripts/spawn-preamble +5 -5
- package/scripts/update.rb +7 -3
- package/scripts/write-handoff +60 -0
- package/skills/agent-advisor/SKILL.md +3 -3
- package/skills/agent-advisor/references/advisor-protocol.md +20 -20
- package/skills/auto/SKILL.md +218 -317
- package/skills/auto/evals/evals.json +10 -10
- package/skills/auto/references/agent-architecture.md +102 -90
- package/skills/auto/references/agent-report-contract.md +4 -21
- package/skills/auto/references/end-tail.md +47 -53
- package/skills/auto/references/human-report-contract.md +8 -8
- package/skills/conventions/SKILL.md +1 -3
- package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
- package/skills/conventions/references/locks-and-worktrees.md +47 -55
- package/skills/conventions/references/maintenance-and-revisions.md +12 -12
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/direct/SKILL.md +66 -0
- package/skills/direct/references/request-signals.md +59 -0
- package/skills/doctor/SKILL.md +58 -11
- package/skills/doctor/report.md +3 -1
- package/skills/install/SKILL.md +11 -9
- package/skills/intent-continuing/SKILL.md +132 -89
- package/skills/intent-continuing/references/boarding-matrix.md +34 -0
- package/skills/intent-continuing/references/context-management.md +2 -2
- package/skills/intent-creating/SKILL.md +2 -2
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-ending/SKILL.md +28 -37
- package/skills/intent-ending/evals/evals.json +4 -4
- package/skills/intent-executing/SKILL.md +12 -49
- package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
- package/skills/intent-speccing/SKILL.md +108 -51
- package/skills/intent-speccing/evals/evals.json +5 -5
- package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
- package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
- package/skills/project-creating/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +7 -8
- package/skills/tutorial/SKILL.md +2 -2
- package/skills/tutorial/references/track-1-guided.md +16 -18
- package/skills/tutorial/references/track-2-auto.md +12 -13
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
- package/templates/agents.md +4 -7
- package/templates/config.yml +5 -0
- package/templates/project.yml +7 -0
- package/templates/roadmap.md +1 -1
- package/templates/savepoint.md +2 -2
- package/templates/session-intent.md +29 -0
- package/agents/plastic-brainstorming.md +0 -48
- package/agents/plastic-future-intent-researcher.md +0 -35
- package/agents/plastic-intent-curator.md +0 -47
- package/agents/plastic-intent-discovery.md +0 -43
- package/agents/plastic-planner.md +0 -56
- package/agents/plastic-spec-specialist.md +0 -53
- package/hooks/auto-arm +0 -5
- package/hooks/bash-gate +0 -3
- package/hooks/continue +0 -31
- package/hooks/edit-gates +0 -3
- package/hooks/future-intent-check +0 -25
- package/hooks/gate-check +0 -12
- package/hooks/power-tools +0 -8
- package/scripts/hook-auto-arm +0 -51
- package/scripts/hook-bash-gate +0 -78
- package/scripts/hook-code-gate +0 -38
- package/scripts/hook-continue +0 -48
- package/scripts/hook-create-gate +0 -51
- package/scripts/hook-edit-gates +0 -58
- package/scripts/hook-future-intent-check +0 -90
- package/scripts/hook-gate-check +0 -169
- package/scripts/hook-links-gate +0 -54
- package/scripts/hook-lock-gate +0 -47
- package/scripts/hook-power-tools +0 -38
- package/scripts/hook-savepoint-pre +0 -32
- package/scripts/lib/codex_edit_gates.rb +0 -138
- package/scripts/lib/edit_gates.rb +0 -398
- package/scripts/lib/links_gate.rb +0 -140
- package/scripts/lib/qmd_hook.rb +0 -41
- package/scripts/lib/spec_header.rb +0 -83
- package/scripts/lib/start_intent.rb +0 -296
- package/scripts/start-intent +0 -89
- package/skills/_active-intent-gate.md +0 -26
- package/skills/auto/references/tiers.md +0 -100
- package/skills/continuing/SKILL.md +0 -34
- package/skills/continuing/evals/evals.json +0 -91
- package/skills/conventions/references/gates-and-enforcement.md +0 -53
- package/skills/conventions/references/tiers-and-dispatch.md +0 -135
- package/skills/doctor/references/gates-stuck-detection.md +0 -43
- package/skills/intent-brainstorming/SKILL.md +0 -118
- package/skills/intent-brainstorming/evals/evals.json +0 -67
- package/skills/intent-continuing/evals/evals.json +0 -145
- package/skills/intent-discovering/SKILL.md +0 -56
- package/skills/intent-grilling/SKILL.md +0 -108
- package/skills/intent-linking/SKILL.md +0 -128
- package/skills/intent-linking/evals/evals.json +0 -22
- package/skills/intent-linking/references/zettelkasten.md +0 -45
- package/skills/intent-locking/SKILL.md +0 -64
- package/skills/intent-planning/SKILL.md +0 -145
- package/skills/intent-planning/evals/evals.json +0 -81
- package/skills/intent-planning/references/plan-format.md +0 -130
- package/skills/intent-researching/SKILL.md +0 -123
- package/skills/intent-researching/evals/evals.json +0 -22
- package/skills/intent-savepoint/SKILL.md +0 -83
- package/skills/intent-savepoint/references/context-management.md +0 -32
- package/skills/intent-starting/SKILL.md +0 -151
- package/skills/intent-starting/evals/evals.json +0 -117
- package/skills/intent-starting/references/boarding-matrix.md +0 -35
- package/skills/project-continuing/SKILL.md +0 -119
- package/skills/project-continuing/evals/evals.json +0 -100
- package/skills/roadmap-continuing/SKILL.md +0 -89
- package/skills/roadmap-continuing/evals/evals.json +0 -82
- package/skills/skill-creating/SKILL.md +0 -75
- package/skills/skill-creating/evals/evals.json +0 -108
- package/skills/skill-creating/references/agents.md +0 -168
- package/skills/skill-creating/references/defaults-first.md +0 -23
- package/skills/skill-creating/references/evals.md +0 -41
- package/skills/skill-creating/references/hooks.md +0 -251
- package/skills/skill-creating/references/progressive-disclosure.md +0 -176
- package/skills/skill-creating/references/scripts.md +0 -166
- package/skills/skill-creating/references/skills.md +0 -169
- package/skills/skill-creating/scripts/scaffold.rb +0 -313
- package/skills/skill-evaluating/SKILL.md +0 -141
- package/skills/skill-evaluating/assets/eval-template.json +0 -12
- package/skills/skill-evaluating/evals/evals.json +0 -75
- package/skills/skill-evaluating/references/convention-checks.md +0 -76
- package/skills/skill-evaluating/references/eval-methodology.md +0 -154
- package/skills/store-curating/SKILL.md +0 -64
- package/skills/store-curating/evals/evals.json +0 -38
- package/skills/store-indexing/SKILL.md +0 -93
- package/skills/store-indexing/evals/evals.json +0 -22
- package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
- package/skills/store-provisioning/SKILL.md +0 -55
- /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
- /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
- /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
# The compaction thresholds and the text Plastic installs into ~/.claude/CLAUDE.md
|
|
7
|
+
# (intent 312; intent 296 D35 and D38).
|
|
8
|
+
#
|
|
9
|
+
# One home for the body, because two halves need it: installer_core.rb installs the
|
|
10
|
+
# marked section, doctor_core.rb verifies the installed one is current. Same reason
|
|
11
|
+
# hook_registry.rb is a shared lib rather than a literal duplicated on both sides.
|
|
12
|
+
#
|
|
13
|
+
# The thresholds are absolute token counts, not percentages. From
|
|
14
|
+
# research--context-thresholds.md: models are reliable only to roughly 50 to 65 percent
|
|
15
|
+
# of advertised context, and the mechanisms behind that (lost-in-the-middle, attention
|
|
16
|
+
# dilution, distractor interference) are architectural, so a bigger window does not
|
|
17
|
+
# repeal them. A percentage that is right at 200k, carried to 1M, would let five times
|
|
18
|
+
# as many raw tokens accumulate before it fired.
|
|
19
|
+
#
|
|
20
|
+
# Library only: no CLI, no ENV, no I/O.
|
|
21
|
+
module CompactInstructions
|
|
22
|
+
# 35 and 50 percent of a 1M window.
|
|
23
|
+
OFFER_TOKENS = 350_000
|
|
24
|
+
INSIST_TOKENS = 500_000
|
|
25
|
+
|
|
26
|
+
# Static on purpose. A body rendered from the user's config would change its hash
|
|
27
|
+
# every time they edited config.yml, and doctor would then report a correct install
|
|
28
|
+
# as stale, so the block states the shipped numbers and names the two keys as the
|
|
29
|
+
# override instead. It names the hand-off in words and never by path, so it reads
|
|
30
|
+
# correctly whether or not the hand-off writer is installed.
|
|
31
|
+
BODY = <<~MD.freeze
|
|
32
|
+
Plastic watches this session's context. When the harness reports how much of the
|
|
33
|
+
window is used:
|
|
34
|
+
|
|
35
|
+
- At 350,000 tokens, offer to compact. Say that the hand-off in today's day ledger
|
|
36
|
+
is written and current, and take no for an answer: a task that is nearly done
|
|
37
|
+
does not need the interruption.
|
|
38
|
+
- At 500,000 tokens, insist. Take no new work, write the hand-off in today's day
|
|
39
|
+
ledger, and compact before continuing.
|
|
40
|
+
- After a compaction, say continue. The day summary at boot and the hand-off carry
|
|
41
|
+
the state; do not rebuild it by re-reading files.
|
|
42
|
+
|
|
43
|
+
Both numbers are absolute token counts for a 1M window. `context_offer_tokens` and
|
|
44
|
+
`context_insist_tokens` in `~/.plastic/config.yml` override them.
|
|
45
|
+
|
|
46
|
+
This section is managed by the Plastic installer. It is replaced on update and
|
|
47
|
+
removed on uninstall. Do not edit anything between the BEGIN and END markers.
|
|
48
|
+
MD
|
|
49
|
+
|
|
50
|
+
# The freshness hash the installer stamps into the BEGIN marker, so doctor can tell
|
|
51
|
+
# a current block from one an older version left behind. Same arithmetic as
|
|
52
|
+
# InstallerCore#marked_section, pinned equal by compact_instructions_test.
|
|
53
|
+
def self.body_hash
|
|
54
|
+
Digest::SHA256.hexdigest(BODY)[0, 12]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# DaySummary (intent 311): the block SessionStart injects at boot, a bounded
|
|
5
|
+
# rendering of the day ledger (open items, the last five done), the live
|
|
6
|
+
# auto intents (an Active intent with a fresh delivery lock, across the
|
|
7
|
+
# global and every project store), and the other sessions alive by their
|
|
8
|
+
# heartbeat. Never the raw ledger (296 D36). No environment reads; every
|
|
9
|
+
# path is injected.
|
|
10
|
+
|
|
11
|
+
require "time"
|
|
12
|
+
require_relative "session_ledger"
|
|
13
|
+
require_relative "handoff"
|
|
14
|
+
require_relative "lock"
|
|
15
|
+
|
|
16
|
+
module DaySummary
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Every part at its cap with 80-character summaries is about 2.8 KB; the
|
|
20
|
+
# budget is the safety net above that, not the working limit.
|
|
21
|
+
BUDGET = 3072
|
|
22
|
+
HEARTBEAT_TTL = 3600
|
|
23
|
+
OPEN_CAP = 10
|
|
24
|
+
DONE_CAP = 5
|
|
25
|
+
LIVE_CAP = 5
|
|
26
|
+
SESSIONS_CAP = 10
|
|
27
|
+
LINE_MAX = 100
|
|
28
|
+
# Trimmed first when the budget is exceeded; Open is the last to shrink.
|
|
29
|
+
TRIM_ORDER = %i[others live done open].freeze
|
|
30
|
+
TITLES = {
|
|
31
|
+
open: "Open:",
|
|
32
|
+
done: "Done, last five:",
|
|
33
|
+
live: "Live auto intents:",
|
|
34
|
+
others: "Other active sessions:",
|
|
35
|
+
}.freeze
|
|
36
|
+
ISO8601_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\b/
|
|
37
|
+
INDEX_DIR_RE = %r{store/([\w][\w.-]*?)(?:/|\))}
|
|
38
|
+
private_constant :ISO8601_RE, :INDEX_DIR_RE
|
|
39
|
+
|
|
40
|
+
def build(store:, day:, session:, home:, now: Time.now, heartbeat_ttl: HEARTBEAT_TTL)
|
|
41
|
+
lists = {
|
|
42
|
+
open: open_items(store, day),
|
|
43
|
+
done: last_done(store, day),
|
|
44
|
+
live: live_intents(home, now: now),
|
|
45
|
+
others: active_sessions(store, session, now: now, ttl: heartbeat_ttl),
|
|
46
|
+
}
|
|
47
|
+
hidden = Hash.new(0)
|
|
48
|
+
cap!(lists, hidden, :open, OPEN_CAP, keep: :newest)
|
|
49
|
+
cap!(lists, hidden, :done, DONE_CAP, keep: :newest)
|
|
50
|
+
cap!(lists, hidden, :live, LIVE_CAP, keep: :first)
|
|
51
|
+
cap!(lists, hidden, :others, SESSIONS_CAP, keep: :first)
|
|
52
|
+
return "" if lists.values.all?(&:empty?)
|
|
53
|
+
|
|
54
|
+
loop do
|
|
55
|
+
text = compose(day, lists, hidden)
|
|
56
|
+
return text if text.bytesize <= BUDGET
|
|
57
|
+
|
|
58
|
+
key = TRIM_ORDER.find { |k| !lists[k].empty? }
|
|
59
|
+
return text unless key
|
|
60
|
+
|
|
61
|
+
%i[open done].include?(key) ? lists[key].shift : lists[key].pop
|
|
62
|
+
hidden[key] += 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# --- parts -------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def open_items(store, day)
|
|
69
|
+
Handoff.read_items(store, day)
|
|
70
|
+
.select { |i| Handoff::OPEN_STATES.include?(i[:state]) }
|
|
71
|
+
.map { |i| "- [#{i[:session]}] [#{i[:project]}] #{Handoff.clip(i[:summary])}" }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def last_done(store, day)
|
|
75
|
+
Handoff.read_savepoint(store, day)
|
|
76
|
+
.select { |e| e[:event] == "Done" }
|
|
77
|
+
.map { |e| "- [#{e[:session]}] [#{e[:project]}] #{Handoff.clip(e[:summary])}" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The global store plus every projects/<slug>/store, each with its INDEX
|
|
81
|
+
# one level up, the way doctor enumerates them.
|
|
82
|
+
def stores(home)
|
|
83
|
+
list = [[File.join(home, "INDEX.md"), File.join(home, "store")]]
|
|
84
|
+
projects_root = File.join(home, "projects")
|
|
85
|
+
if File.directory?(projects_root)
|
|
86
|
+
Dir.children(projects_root).sort.each do |slug|
|
|
87
|
+
list << [File.join(projects_root, slug, "INDEX.md"), File.join(projects_root, slug, "store")]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
list
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def active_dirs(index_path)
|
|
94
|
+
return [] unless File.exist?(index_path)
|
|
95
|
+
|
|
96
|
+
dirs = []
|
|
97
|
+
current = nil
|
|
98
|
+
File.foreach(index_path) do |line|
|
|
99
|
+
if (m = line.match(/^##\s+(.+?)\s*$/))
|
|
100
|
+
current = m[1]
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
next unless current == "Active"
|
|
104
|
+
|
|
105
|
+
line.scan(INDEX_DIR_RE) { |(dirname)| dirs << dirname unless dirs.include?(dirname) }
|
|
106
|
+
end
|
|
107
|
+
dirs
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def live_intents(home, now:)
|
|
111
|
+
stores(home).flat_map do |index_path, store_dir|
|
|
112
|
+
active_dirs(index_path).filter_map do |dirname|
|
|
113
|
+
dir = File.join(store_dir, dirname)
|
|
114
|
+
next unless File.directory?(dir) && Lock.fresh?(dir, now: now)
|
|
115
|
+
# 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"
|
|
118
|
+
|
|
119
|
+
id, slug = dirname.split("--", 2)
|
|
120
|
+
"- #{id} #{slug}: #{last_savepoint_line(dir)}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
rescue SystemCallError
|
|
124
|
+
[]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def last_savepoint_line(dir)
|
|
128
|
+
path = File.join(dir, "savepoint.md")
|
|
129
|
+
if File.exist?(path)
|
|
130
|
+
File.readlines(path).reverse_each do |line|
|
|
131
|
+
text = line.strip
|
|
132
|
+
return text[0, LINE_MAX] if text.match?(ISO8601_RE)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
"(no savepoint yet)"
|
|
136
|
+
rescue SystemCallError
|
|
137
|
+
"(no savepoint yet)"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def active_sessions(store, session, now:, ttl:)
|
|
141
|
+
tmp_root = SessionLedger.tmp_root(store)
|
|
142
|
+
return [] unless File.directory?(tmp_root)
|
|
143
|
+
|
|
144
|
+
Dir.children(tmp_root).sort.filter_map do |sid|
|
|
145
|
+
next if sid == session || sid.start_with?(".")
|
|
146
|
+
|
|
147
|
+
dir = File.join(tmp_root, sid)
|
|
148
|
+
next unless File.directory?(dir)
|
|
149
|
+
|
|
150
|
+
age = heartbeat_age(dir, now)
|
|
151
|
+
next if age.nil? || age > ttl
|
|
152
|
+
|
|
153
|
+
"- #{sid} (#{(age / 60).floor}m ago, on #{pointer_of(dir)})"
|
|
154
|
+
end
|
|
155
|
+
rescue SystemCallError
|
|
156
|
+
[]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Seconds since the session's last heartbeat: the ISO-8601 content of
|
|
160
|
+
# `heartbeat`, else that file's mtime, else the directory's mtime. The
|
|
161
|
+
# same reading doctor's orphan check uses, inverted here for liveness.
|
|
162
|
+
def heartbeat_age(dir, now)
|
|
163
|
+
heartbeat = File.join(dir, "heartbeat")
|
|
164
|
+
if File.file?(heartbeat)
|
|
165
|
+
begin
|
|
166
|
+
return now - Time.iso8601(File.read(heartbeat).strip)
|
|
167
|
+
rescue ArgumentError, IOError, SystemCallError
|
|
168
|
+
return now - File.mtime(heartbeat)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
now - File.mtime(dir)
|
|
172
|
+
rescue SystemCallError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def pointer_of(dir)
|
|
177
|
+
path = File.join(dir, "current")
|
|
178
|
+
return "?" unless File.file?(path)
|
|
179
|
+
|
|
180
|
+
value = File.read(path).strip
|
|
181
|
+
value.empty? ? "?" : value[0, 80]
|
|
182
|
+
rescue SystemCallError
|
|
183
|
+
"?"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# --- rendering, pure -------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def cap!(lists, hidden, key, cap, keep:)
|
|
189
|
+
return unless lists[key].size > cap
|
|
190
|
+
|
|
191
|
+
hidden[key] += lists[key].size - cap
|
|
192
|
+
lists[key] = keep == :newest ? lists[key].last(cap) : lists[key].first(cap)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def compose(day, lists, hidden)
|
|
196
|
+
out = ["Day summary #{day}:"]
|
|
197
|
+
TITLES.each do |key, title|
|
|
198
|
+
next if lists[key].empty? && hidden[key].zero?
|
|
199
|
+
|
|
200
|
+
out << title
|
|
201
|
+
out.concat(lists[key])
|
|
202
|
+
out << "(+#{hidden[key]} more)" if hidden[key].positive?
|
|
203
|
+
end
|
|
204
|
+
out.join("\n")
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -15,6 +15,7 @@ require "digest"
|
|
|
15
15
|
require "rubygems"
|
|
16
16
|
|
|
17
17
|
require_relative "hook_registry"
|
|
18
|
+
require_relative "compact_instructions"
|
|
18
19
|
|
|
19
20
|
class Doctor
|
|
20
21
|
DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
|
|
@@ -26,7 +27,9 @@ class Doctor
|
|
|
26
27
|
"hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
|
|
27
28
|
}.freeze
|
|
28
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
33
|
|
|
31
34
|
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
32
35
|
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|
|
@@ -40,9 +43,8 @@ class Doctor
|
|
|
40
43
|
folgezettel-id
|
|
41
44
|
read-config
|
|
42
45
|
hook-session-start
|
|
43
|
-
hook-
|
|
44
|
-
hook-
|
|
45
|
-
hook-gate-check
|
|
46
|
+
hook-capture
|
|
47
|
+
hook-record
|
|
46
48
|
validate-intent
|
|
47
49
|
doctor.rb
|
|
48
50
|
].freeze
|
|
@@ -329,7 +331,7 @@ class Doctor
|
|
|
329
331
|
hooks_dir = File.join(agent_dir, "hooks")
|
|
330
332
|
|
|
331
333
|
# Derived from HookRegistry.events (intent 204), not a hand-kept list, so
|
|
332
|
-
# every registered hook
|
|
334
|
+
# every registered hook is checked.
|
|
333
335
|
expected_launchers = HookRegistry.claude_launcher_names
|
|
334
336
|
|
|
335
337
|
# hooks_exist
|
|
@@ -396,12 +398,6 @@ class Doctor
|
|
|
396
398
|
)
|
|
397
399
|
end
|
|
398
400
|
|
|
399
|
-
# claude_hooks_implemented (intent 244): the edit-path gates now live as
|
|
400
|
-
# branches inside one dispatcher (scripts/hook-edit-gates), which is
|
|
401
|
-
# exactly the shape that let links-gate ship registered and dead on
|
|
402
|
-
# Codex (intent 200's failure class, one level up).
|
|
403
|
-
checks << claude_hooks_implemented_check
|
|
404
|
-
|
|
405
401
|
# hooks_registered — settings.json has Plastic hooks for required events
|
|
406
402
|
settings_path = File.join(agent_dir, "settings.json")
|
|
407
403
|
settings = read_json_safe(settings_path)
|
|
@@ -456,7 +452,7 @@ class Doctor
|
|
|
456
452
|
|
|
457
453
|
# hooks_match_registry (intent 108, D7): the live settings must carry
|
|
458
454
|
# EXACTLY the registrations HookRegistry defines; any drift (a missing
|
|
459
|
-
#
|
|
455
|
+
# hook, a stray plastic hook, a stale matcher) is how a hook shipped
|
|
460
456
|
# dead once already.
|
|
461
457
|
expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
|
|
462
458
|
diffs = []
|
|
@@ -513,9 +509,56 @@ class Doctor
|
|
|
513
509
|
# agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
|
|
514
510
|
checks << flat_agents_check(agent_dir, "--claude")
|
|
515
511
|
|
|
512
|
+
# compact-instructions block in CLAUDE.md (intent 312)
|
|
513
|
+
checks << claude_compact_instructions_check(agent_dir)
|
|
514
|
+
|
|
516
515
|
checks
|
|
517
516
|
end
|
|
518
517
|
|
|
518
|
+
# Claude CLAUDE.md marker literals. Keep in sync with
|
|
519
|
+
# InstallerCore::CLAUDE_SECTION_BEGIN_PREFIX / CLAUDE_SECTION_END (doctor does not
|
|
520
|
+
# require installer_core, so the literals are duplicated, exactly as for Codex). The
|
|
521
|
+
# BODY and its hash are NOT duplicated: they come from the shared CompactInstructions.
|
|
522
|
+
CLAUDE_COMPACT_BEGIN_PREFIX = "<!-- BEGIN PLASTIC COMPACT"
|
|
523
|
+
CLAUDE_COMPACT_END = "<!-- END PLASTIC COMPACT -->"
|
|
524
|
+
|
|
525
|
+
# Present, well formed, and current. The Codex AGENTS.md check stops at well formed;
|
|
526
|
+
# this one also compares the hash in the BEGIN marker against the shipped body, so a
|
|
527
|
+
# block an older version left behind is reported rather than trusted.
|
|
528
|
+
def claude_compact_instructions_check(agent_dir)
|
|
529
|
+
claude_md = File.join(agent_dir, "CLAUDE.md")
|
|
530
|
+
name = "claude_compact_instructions"
|
|
531
|
+
hint = "Re-run the Plastic installer with --claude"
|
|
532
|
+
|
|
533
|
+
unless File.exist?(claude_md)
|
|
534
|
+
return check(category: "agent_registration", name: name, status: "fail",
|
|
535
|
+
message: "CLAUDE.md not found at #{tilde(claude_md)}, so the compaction instructions are not installed",
|
|
536
|
+
fixable: true, fix_hint: hint)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
content = File.read(claude_md)
|
|
540
|
+
b = content.index(CLAUDE_COMPACT_BEGIN_PREFIX)
|
|
541
|
+
e = content.index(CLAUDE_COMPACT_END)
|
|
542
|
+
well_formed = b && e && e > b && content[b...e].include?("-->")
|
|
543
|
+
|
|
544
|
+
unless well_formed
|
|
545
|
+
return check(category: "agent_registration", name: name, status: "fail",
|
|
546
|
+
message: "CLAUDE.md is missing the compact-instructions block or its section is malformed",
|
|
547
|
+
fixable: true, fix_hint: hint)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
installed_hash = content[b..][/hash:(\w+)/, 1]
|
|
551
|
+
if installed_hash != CompactInstructions.body_hash
|
|
552
|
+
return check(category: "agent_registration", name: name, status: "fail",
|
|
553
|
+
message: "the compact-instructions block in CLAUDE.md is stale " \
|
|
554
|
+
"(hash:#{installed_hash}, current is hash:#{CompactInstructions.body_hash})",
|
|
555
|
+
fixable: true, fix_hint: hint)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
check(category: "agent_registration", name: name, status: "pass",
|
|
559
|
+
message: "CLAUDE.md carries the current compact-instructions block")
|
|
560
|
+
end
|
|
561
|
+
|
|
519
562
|
# Unfiltered classification (intent 276, spec Approach table): mode (a)
|
|
520
563
|
# unowned warns, mode (b) current-but-missing fails, a retired/non-hook
|
|
521
564
|
# launcher is skipped, a third-party hook stays silent.
|
|
@@ -569,71 +612,6 @@ class Doctor
|
|
|
569
612
|
)
|
|
570
613
|
end
|
|
571
614
|
|
|
572
|
-
def claude_dispatcher_gate_names(source)
|
|
573
|
-
case_start = source.index(/^\s*case gate\b/)
|
|
574
|
-
return nil unless case_start
|
|
575
|
-
|
|
576
|
-
case_body = source[case_start..-1]
|
|
577
|
-
end_idx = case_body.index(/^\s*end\b/)
|
|
578
|
-
scanned = end_idx ? case_body[0...end_idx] : case_body
|
|
579
|
-
names = scanned.scan(/^\s*when\s+"([^"]+)"/).flatten.uniq
|
|
580
|
-
names.empty? ? nil : names
|
|
581
|
-
end
|
|
582
|
-
|
|
583
|
-
def claude_hooks_implemented_check
|
|
584
|
-
dispatcher_path = File.join(plastic_home, "scripts", "hook-edit-gates")
|
|
585
|
-
|
|
586
|
-
unless File.exist?(dispatcher_path)
|
|
587
|
-
return check(
|
|
588
|
-
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
589
|
-
message: "scripts/hook-edit-gates not found at #{tilde(dispatcher_path)}; cannot verify " \
|
|
590
|
-
"the edit-path gate registry and dispatcher agree",
|
|
591
|
-
fixable: true, fix_hint: "Re-run the Plastic installer"
|
|
592
|
-
)
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
dispatcher_names = claude_dispatcher_gate_names(File.read(dispatcher_path))
|
|
596
|
-
|
|
597
|
-
if dispatcher_names.nil?
|
|
598
|
-
return check(
|
|
599
|
-
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
600
|
-
message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the `case gate` " \
|
|
601
|
-
"statement no longer matches the shape this check expects, so the registry was " \
|
|
602
|
-
"never actually checked against the real dispatcher. Update " \
|
|
603
|
-
"claude_dispatcher_gate_names in doctor.rb to the file's new shape.",
|
|
604
|
-
fixable: false
|
|
605
|
-
)
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
registry_names = HookRegistry::GATE_TOOLS.keys
|
|
609
|
-
missing_branch = registry_names - dispatcher_names
|
|
610
|
-
dead_branch = dispatcher_names - registry_names
|
|
611
|
-
|
|
612
|
-
if missing_branch.empty? && dead_branch.empty?
|
|
613
|
-
return check(
|
|
614
|
-
category: "agent_registration", name: "claude_hooks_implemented", status: "pass",
|
|
615
|
-
message: "Every registered edit-path gate has a scripts/hook-edit-gates branch, and every " \
|
|
616
|
-
"dispatcher branch is registered"
|
|
617
|
-
)
|
|
618
|
-
end
|
|
619
|
-
|
|
620
|
-
details = missing_branch.map do |name|
|
|
621
|
-
"#{name} is in HookRegistry::GATE_TOOLS but scripts/hook-edit-gates has no branch for it, " \
|
|
622
|
-
"so the dispatcher skips it on every edit and it never blocks anything"
|
|
623
|
-
end
|
|
624
|
-
details += dead_branch.map do |name|
|
|
625
|
-
"#{name} has a branch in scripts/hook-edit-gates but is not in HookRegistry::GATE_TOOLS, so " \
|
|
626
|
-
"the dispatcher never reaches it and it is dead code"
|
|
627
|
-
end
|
|
628
|
-
|
|
629
|
-
check(
|
|
630
|
-
category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
|
|
631
|
-
message: "The edit-path gate registry and scripts/hook-edit-gates disagree on " \
|
|
632
|
-
"#{details.size} gate(s)",
|
|
633
|
-
details: details, fixable: false
|
|
634
|
-
)
|
|
635
|
-
end
|
|
636
|
-
|
|
637
615
|
def flat_skills_check(agent_dir, installer_flag)
|
|
638
616
|
skills_root = File.join(agent_dir, "skills")
|
|
639
617
|
found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
|
|
@@ -907,22 +885,21 @@ class Doctor
|
|
|
907
885
|
# by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
|
|
908
886
|
# 203, in the opposite direction). This check closes both directions at once.
|
|
909
887
|
|
|
910
|
-
# The Codex
|
|
911
|
-
#
|
|
912
|
-
# (
|
|
913
|
-
#
|
|
914
|
-
# own Ruby constants.
|
|
888
|
+
# The Codex hook names HookRegistry actually registers: the apply_patch record
|
|
889
|
+
# hook (CODEX_POST_HOOKS) and the live-state hook names Codex inherits whole from
|
|
890
|
+
# `events` (CODEX_LIVE_STATE_EVENTS), plus the SessionEnd close hook (intent 309,
|
|
891
|
+
# CODEX_SESSION_END_HOOKS). The PreToolUse gate names left in 2.0 (intent 302). No
|
|
892
|
+
# parsing needed: these are HookRegistry's own Ruby constants.
|
|
915
893
|
def codex_registry_gate_names
|
|
916
894
|
live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
|
|
917
895
|
HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
918
896
|
end
|
|
919
|
-
(HookRegistry::
|
|
920
|
-
HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
|
|
897
|
+
(HookRegistry::CODEX_POST_HOOKS + live_state + HookRegistry::CODEX_SESSION_END_HOOKS).uniq
|
|
921
898
|
end
|
|
922
899
|
|
|
923
900
|
def codex_dispatcher_gate_names(source)
|
|
924
901
|
names = []
|
|
925
|
-
%w[STATE_HOOKS
|
|
902
|
+
%w[STATE_HOOKS].each do |const|
|
|
926
903
|
m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
|
|
927
904
|
names.concat(m[1].split(/\s+/)) if m
|
|
928
905
|
end
|
|
@@ -960,7 +937,7 @@ class Doctor
|
|
|
960
937
|
return check(
|
|
961
938
|
category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
|
|
962
939
|
message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
|
|
963
|
-
"STATE_HOOKS
|
|
940
|
+
"STATE_HOOKS constant and the `case gate` statement no longer " \
|
|
964
941
|
"match the shape this check expects, so the registry could not be checked " \
|
|
965
942
|
"against the real dispatcher. This is exactly the silent-pass failure this " \
|
|
966
943
|
"check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
|
|
@@ -1005,8 +982,8 @@ class Doctor
|
|
|
1005
982
|
toml = File.read(config_toml) rescue ""
|
|
1006
983
|
warns = []
|
|
1007
984
|
# guide Part 3: `codex_hooks` is a deprecated alias for `[features] hooks`; catch both.
|
|
1008
|
-
warns << "hooks are disabled ([features] hooks = false); Plastic
|
|
1009
|
-
warns << "sandbox_mode = \"read-only\"; apply_patch writes (and
|
|
985
|
+
warns << "hooks are disabled ([features] hooks = false); Plastic hooks will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
|
|
986
|
+
warns << "sandbox_mode = \"read-only\"; apply_patch writes (and the record hook) cannot run" if toml.match?(/^\s*sandbox_mode\s*=\s*["']read-only["']/)
|
|
1010
987
|
return nil if warns.empty?
|
|
1011
988
|
|
|
1012
989
|
check(
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "session_ledger"
|
|
6
|
+
|
|
7
|
+
# DoctorSessionLedger (intent 308) - the `session_ledger` doctor category, mixed into
|
|
8
|
+
# Doctor. Two checks over the batch-1 store areas that Doctor's intent walker never
|
|
9
|
+
# visits (both are dot-prefixed, and the walker stays ignorant of them by design: see
|
|
10
|
+
# test/store_walker_compat_test.rb). They live in the GLOBAL store only: every writer of
|
|
11
|
+
# .tmp/ and .sessions/ (hook-capture, hook-record, hook-session-start, Arm, SessionClose)
|
|
12
|
+
# targets the global store, so a project scope gets no finding and no check.
|
|
13
|
+
#
|
|
14
|
+
# Read-only: the orphan check names directories and never removes one, because a
|
|
15
|
+
# heartbeat is only refreshed on prompts and edits, so an old heartbeat is evidence, not
|
|
16
|
+
# proof, that the session is gone. Fail-open: an unreadable directory is skipped, never
|
|
17
|
+
# raised. Depends on the including class for `plastic_home` and `check`.
|
|
18
|
+
module DoctorSessionLedger
|
|
19
|
+
ORPHAN_TTL_SECONDS = 24 * 60 * 60
|
|
20
|
+
|
|
21
|
+
# Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
|
|
22
|
+
# else that file's mtime, else the directory's mtime.
|
|
23
|
+
def heartbeat_age(dir, now)
|
|
24
|
+
heartbeat = File.join(dir, "heartbeat")
|
|
25
|
+
if File.file?(heartbeat)
|
|
26
|
+
begin
|
|
27
|
+
return now - Time.iso8601(File.read(heartbeat).strip)
|
|
28
|
+
rescue ArgumentError, IOError, SystemCallError
|
|
29
|
+
return now - File.mtime(heartbeat)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
now - File.mtime(dir)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def check_session_ledger(scopes: nil, now: Time.now)
|
|
36
|
+
return [] unless scopes.nil? || scopes.include?("global")
|
|
37
|
+
|
|
38
|
+
store_dir = File.join(plastic_home, "store")
|
|
39
|
+
orphans = orphaned_session_dirs(store_dir, now)
|
|
40
|
+
shape = day_ledger_shape_problems(store_dir)
|
|
41
|
+
|
|
42
|
+
checks = []
|
|
43
|
+
checks << if orphans.empty?
|
|
44
|
+
check(category: "session_ledger", name: "orphaned_session_tmp", status: "pass",
|
|
45
|
+
message: "No .tmp/<session>/ directory has a heartbeat older than " \
|
|
46
|
+
"#{ORPHAN_TTL_SECONDS / 3600} hours")
|
|
47
|
+
else
|
|
48
|
+
check(category: "session_ledger", name: "orphaned_session_tmp", status: "warn",
|
|
49
|
+
message: "#{orphans.size} .tmp/<session>/ director#{orphans.size == 1 ? "y" : "ies"} " \
|
|
50
|
+
"with a heartbeat older than #{ORPHAN_TTL_SECONDS / 3600} hours " \
|
|
51
|
+
"(the session close hook never ran)",
|
|
52
|
+
details: orphans, fixable: true,
|
|
53
|
+
fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
|
|
54
|
+
"session is gone: a live session rewrites its heartbeat on every " \
|
|
55
|
+
"prompt and edit, so only a listed directory may be removed, by hand.")
|
|
56
|
+
end
|
|
57
|
+
checks << if shape.empty?
|
|
58
|
+
check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
|
|
59
|
+
message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
|
|
60
|
+
else
|
|
61
|
+
check(category: "session_ledger", name: "day_ledger_shape", status: "warn",
|
|
62
|
+
message: "#{shape.size} .sessions/ shape problem#{shape.size == 1 ? "" : "s"}",
|
|
63
|
+
details: shape, fixable: true,
|
|
64
|
+
fix_hint: "For a day directory missing its <day>.md, run " \
|
|
65
|
+
"`ruby ~/.plastic/scripts/file-session-intent --day <day> --carry-to " \
|
|
66
|
+
"<today> --store <store>`; rename or remove an entry that is not a " \
|
|
67
|
+
"YYYYMMDD day directory.")
|
|
68
|
+
end
|
|
69
|
+
checks
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def orphaned_session_dirs(store_dir, now)
|
|
73
|
+
tmp_root = SessionLedger.tmp_root(store_dir)
|
|
74
|
+
return [] unless File.directory?(tmp_root)
|
|
75
|
+
|
|
76
|
+
Dir.children(tmp_root).sort.filter_map do |name|
|
|
77
|
+
dir = File.join(tmp_root, name)
|
|
78
|
+
next unless File.directory?(dir)
|
|
79
|
+
|
|
80
|
+
age = heartbeat_age(dir, now)
|
|
81
|
+
next if age <= ORPHAN_TTL_SECONDS
|
|
82
|
+
|
|
83
|
+
"global: #{dir} (session #{name}, last heartbeat #{(age / 3600).floor}h ago)"
|
|
84
|
+
end
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
[] # unreadable .tmp/: nothing to report, never a crash
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def day_ledger_shape_problems(store_dir)
|
|
90
|
+
root = SessionLedger.sessions_root(store_dir)
|
|
91
|
+
return [] unless File.directory?(root)
|
|
92
|
+
|
|
93
|
+
Dir.children(root).sort.filter_map do |name|
|
|
94
|
+
path = File.join(root, name)
|
|
95
|
+
if !File.directory?(path)
|
|
96
|
+
"global: .sessions/#{name} is not a directory (only day directories belong here)"
|
|
97
|
+
elsif !SessionLedger.valid_day_id?(name)
|
|
98
|
+
"global: .sessions/#{name} is not a YYYYMMDD day id"
|
|
99
|
+
elsif !File.file?(SessionLedger.day_file(store_dir, name))
|
|
100
|
+
"global: .sessions/#{name} has no #{name}.md day file"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
rescue SystemCallError
|
|
104
|
+
[] # unreadable .sessions/: nothing to report, never a crash
|
|
105
|
+
end
|
|
106
|
+
end
|