@zalom/plastic 1.14.1 → 2.0.0-alpha.10
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 +14 -42
- package/hooks/message-display +81 -0
- package/hooks/record +4 -0
- package/hooks/savepoint +5 -5
- package/package.json +3 -2
- package/scripts/agent-report +15 -10
- package/scripts/append-ledger +208 -0
- package/scripts/codex-hook +78 -90
- package/scripts/dashboard.rb +43 -14
- package/scripts/day-summary +53 -0
- package/scripts/doctor.rb +269 -43
- package/scripts/end-intent +170 -76
- package/scripts/exec-worktree +11 -20
- package/scripts/file-session-intent +61 -0
- package/scripts/hook-capture +255 -0
- package/scripts/hook-close +36 -0
- package/scripts/hook-message-display +74 -0
- package/scripts/hook-record +157 -0
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +128 -12
- package/scripts/install.rb +8 -6
- package/scripts/intent-screen +77 -0
- package/scripts/lib/agent_models.rb +1 -7
- package/scripts/lib/arm.rb +308 -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 +211 -0
- package/scripts/lib/doctor_core.rb +66 -88
- package/scripts/lib/doctor_session_ledger.rb +158 -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 +57 -106
- package/scripts/lib/insights.rb +3 -4
- package/scripts/lib/installer_core.rb +136 -41
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/lock.rb +6 -26
- package/scripts/lib/message_display.rb +290 -0
- package/scripts/lib/outcome_guard.rb +7 -5
- package/scripts/lib/project_validator.rb +30 -1
- package/scripts/lib/report_screen.rb +648 -0
- 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 +334 -0
- package/scripts/lib/scaffold_intent.rb +13 -179
- package/scripts/lib/screen_paint.rb +276 -0
- package/scripts/lib/session_backfill.rb +184 -0
- package/scripts/lib/session_close.rb +104 -0
- package/scripts/lib/session_git.rb +500 -0
- package/scripts/lib/session_ledger.rb +621 -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 +101 -41
- package/scripts/promote-session-item +150 -0
- package/scripts/read-config +3 -0
- package/scripts/rebuild-graph +1 -1
- package/scripts/report-screen +120 -0
- package/scripts/roadmap-next +2 -2
- package/scripts/rollback.rb +48 -3
- package/scripts/savepoint-note +67 -0
- package/scripts/scaffold-intent +37 -63
- package/scripts/session-commit +116 -0
- package/scripts/spawn-preamble +14 -7
- 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 +223 -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 +62 -56
- 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 +62 -58
- 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 +142 -91
- package/skills/intent-continuing/references/boarding-matrix.md +34 -0
- package/skills/intent-continuing/references/context-management.md +3 -3
- package/skills/intent-creating/SKILL.md +2 -2
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-ending/SKILL.md +34 -37
- package/skills/intent-ending/evals/evals.json +4 -4
- package/skills/intent-executing/SKILL.md +15 -46
- 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/intent-screen.md +17 -0
- package/templates/outcome.md +14 -1
- package/templates/project.yml +7 -0
- package/templates/report-state.md +11 -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,262 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "intent_screen"
|
|
5
|
+
|
|
6
|
+
# IntentScreenAnsi (intent 316a, O2) - renders one intent screen with raw
|
|
7
|
+
# truecolor ANSI escapes, productionizing 318's mockup--render.rb. Standard
|
|
8
|
+
# library only. Calls the SAME public IntentScreen.* field methods
|
|
9
|
+
# scripts/intent-screen calls (store_fields, index_fields, savepoint_fields,
|
|
10
|
+
# checklist_items, progress_fields, next_fields, insight_fields,
|
|
11
|
+
# items_present?, fallback_name, step_text) and re-derives nothing, so every
|
|
12
|
+
# field the ANSI block prints is the identical value the plain screen prints
|
|
13
|
+
# (D3), just carried through a different layout.
|
|
14
|
+
#
|
|
15
|
+
# `color:` is a constructor/call argument, never an environment read (D18):
|
|
16
|
+
# the plain path (`color: false`) is one call away and testable without
|
|
17
|
+
# touching NO_COLOR or a TTY. No ENV, no Dir.pwd, no Dir.home.
|
|
18
|
+
#
|
|
19
|
+
# Harness-agnostic core: no harness assumption lives here. `markdown_safe:`
|
|
20
|
+
# (intent 316a1, D3/D5) is the one choice a caller supplies rather than a
|
|
21
|
+
# choice this module makes for itself: a display surface that passes raw
|
|
22
|
+
# ANSI through untouched should not inherit a concession it never needed.
|
|
23
|
+
# See docs/reference/harness-adapters.md for which caller asks for it and
|
|
24
|
+
# why.
|
|
25
|
+
module IntentScreenAnsi
|
|
26
|
+
ESC = "\e"
|
|
27
|
+
RESET = "#{ESC}[0m".freeze
|
|
28
|
+
BOLD = "#{ESC}[1m".freeze
|
|
29
|
+
TEAL = "#{ESC}[38;2;45;212;191m".freeze
|
|
30
|
+
AMBER = "#{ESC}[38;2;245;158;11m".freeze
|
|
31
|
+
GRAPHITE_BG = "#{ESC}[48;2;31;41;55m".freeze
|
|
32
|
+
MIDGREY = "#{ESC}[38;2;148;163;184m".freeze
|
|
33
|
+
NEARWHITE = "#{ESC}[38;2;243;244;246m".freeze
|
|
34
|
+
|
|
35
|
+
BAR_CELLS = 24
|
|
36
|
+
EIGHTHS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"].freeze
|
|
37
|
+
|
|
38
|
+
# Intent 317a1 (D14): the approved design is a 115-column layout, measured
|
|
39
|
+
# from design--terminal-output.html:52-58 with its tags stripped.
|
|
40
|
+
DEFAULT_WIDTH = 115
|
|
41
|
+
|
|
42
|
+
ELLIPSIS = "…"
|
|
43
|
+
|
|
44
|
+
# Intent 317a1 (D9-D11, D15): the three-column field table's geometry.
|
|
45
|
+
# ANSI_RE strips escapes so padding measures what the terminal actually
|
|
46
|
+
# draws (D10); VALUE_COL_MAX is the design's own value-column figure and
|
|
47
|
+
# the owner's "50 chars" (mockup--report-screen.md:74); NOTE_FLOOR is the
|
|
48
|
+
# minimum note length that earns the own-line fallback when the note alone
|
|
49
|
+
# (not the value) is what overflows the same-line budget.
|
|
50
|
+
ANSI_RE = /\e\[[0-9;]*m/.freeze
|
|
51
|
+
VALUE_COL_MAX = 50
|
|
52
|
+
NOTE_FLOOR = 24
|
|
53
|
+
|
|
54
|
+
def self.visible_width(text)
|
|
55
|
+
text.to_s.gsub(ANSI_RE, "").length
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.render(intent_dir:, store_root:, color: true, width: DEFAULT_WIDTH, markdown_safe: false)
|
|
59
|
+
base = File.basename(intent_dir)
|
|
60
|
+
id = base.split("--", 2).first
|
|
61
|
+
intent_text = File.read(File.join(intent_dir, "#{base}.md"))
|
|
62
|
+
|
|
63
|
+
fields = {}
|
|
64
|
+
fields.merge!(IntentScreen.store_fields(store_root))
|
|
65
|
+
status, title = IntentScreen.index_fields(store_root, id)
|
|
66
|
+
fields["status"] = status
|
|
67
|
+
fields["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
|
|
68
|
+
fields["id"] = id
|
|
69
|
+
fields["name"] = title || IntentScreen.fallback_name(intent_text)
|
|
70
|
+
fields.merge!(IntentScreen.savepoint_fields(intent_dir, intent_text))
|
|
71
|
+
items = IntentScreen.checklist_items(intent_dir)
|
|
72
|
+
fields.merge!(IntentScreen.progress_fields(items))
|
|
73
|
+
fields.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir), escape_pipes: false))
|
|
74
|
+
fields.merge!(IntentScreen.insight_fields(intent_text, escape_pipes: false))
|
|
75
|
+
fields.transform_values! { |v| markdown_safe ? clean(v) : v }
|
|
76
|
+
|
|
77
|
+
done_n = fields["progress.done"].to_i
|
|
78
|
+
total_n = fields["progress.total"].to_i
|
|
79
|
+
|
|
80
|
+
out = +""
|
|
81
|
+
out << fit("▶ #{fields['id']} · #{fields['name']}", width) { |t| styled(t, color, BOLD, NEARWHITE) }
|
|
82
|
+
out << "\n\n"
|
|
83
|
+
|
|
84
|
+
# The 4th column marks a row whose value is already a finished, pre-fit
|
|
85
|
+
# string (the Progress bar, built above from styled glyphs plus a count)
|
|
86
|
+
# rather than raw field text still needing `fit_plain`. Naming that
|
|
87
|
+
# explicitly here reads better than testing the value for a leading ESC
|
|
88
|
+
# byte further down, which is really just asking "is this the Progress
|
|
89
|
+
# row?" through a type check.
|
|
90
|
+
field_rows = [
|
|
91
|
+
["Store", fields["store"], fields["store.note"], false],
|
|
92
|
+
["Status", fields["status"], fields["status.note"], false],
|
|
93
|
+
["Stage", fields["stage"], fields["stage.note"], false],
|
|
94
|
+
["Savepoint", fields["savepoint"], fields["savepoint.note"], false],
|
|
95
|
+
["Progress", "#{render_bar(done_n, total_n, color)} #{done_n} / #{total_n}", fields["progress.note"], true],
|
|
96
|
+
["Next", fields["next"], fields["next.note"], false],
|
|
97
|
+
["Insight", fields["insight"], fields["insight.note"], false],
|
|
98
|
+
]
|
|
99
|
+
key_width = field_rows.map { |k, _, _, _| k.length }.max
|
|
100
|
+
|
|
101
|
+
out << field_table_lines(field_rows, width: width, color: color, key_width: key_width)
|
|
102
|
+
|
|
103
|
+
out << "\n"
|
|
104
|
+
out << fit("Steps", width) { |t| styled(t, color, BOLD, NEARWHITE) }
|
|
105
|
+
out << "\n\n"
|
|
106
|
+
|
|
107
|
+
if items.empty?
|
|
108
|
+
out << " no steps yet\n"
|
|
109
|
+
else
|
|
110
|
+
# Padded to the widest label (matrix B2): at 10+ steps "S10" is one
|
|
111
|
+
# column wider than "S1..S9", and without padding every badge past S9
|
|
112
|
+
# drifts out of column with the rows above it.
|
|
113
|
+
label_width = "S#{items.size}".length
|
|
114
|
+
items.each_with_index do |item, i|
|
|
115
|
+
num = "S#{i + 1}".ljust(label_width)
|
|
116
|
+
badge = status_cell(item[:done], color)
|
|
117
|
+
prefix_plain = " #{num} [ #{item[:done] ? 'done' : 'open'} ] "
|
|
118
|
+
text_budget = [width - prefix_plain.length, 0].max
|
|
119
|
+
step = IntentScreen.step_text(item[:text])
|
|
120
|
+
text = fit_plain(markdown_safe ? clean(step) : step, text_budget)
|
|
121
|
+
out << " #{num} [#{badge}] #{text}\n"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
out
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- shared field-table geometry (D9-D12, D14, D15) ------------------------
|
|
129
|
+
|
|
130
|
+
# The field table's basis, cap, budget arithmetic, both fallbacks, and the
|
|
131
|
+
# right-strip, shared by `render` above and `ScreenPaint.paint_table`'s
|
|
132
|
+
# `**Key**` branch, so the two renderers can never drift apart (D12) - the
|
|
133
|
+
# duplication this replaced was character-for-character identical apart
|
|
134
|
+
# from the module prefix. `rows` is `[key, value, note]`, or `[key, value,
|
|
135
|
+
# note, true]` when `value` is already a finished, pre-fit string (the
|
|
136
|
+
# Progress bar) rather than raw text still needing `fit_plain`.
|
|
137
|
+
#
|
|
138
|
+
# Notes become a third column, padded to the widest RENDERED noted value
|
|
139
|
+
# (capped at VALUE_COL_MAX, D15) so every note starts at one raw-text
|
|
140
|
+
# position; a row whose value or note will not fit drops to the
|
|
141
|
+
# note-on-its-own-line form instead of squeezing anything invisibly. When
|
|
142
|
+
# the same-line budget has already collapsed to zero or less, the note
|
|
143
|
+
# falls straight to its own line rather than being fed to `fit` and
|
|
144
|
+
# silently returning "" - the note must be cut with a visible ellipsis or
|
|
145
|
+
# kept whole, never dropped outright (317a1 post-exec review, finding 4).
|
|
146
|
+
def self.field_table_lines(rows, width:, color:, key_width:)
|
|
147
|
+
prefix_width = key_width + 4 # " " + key.ljust + " "
|
|
148
|
+
|
|
149
|
+
rendered_rows = rows.map do |key, value, note, prebuilt|
|
|
150
|
+
value_budget = [width - prefix_width, 0].max
|
|
151
|
+
value_text = prebuilt ? value : fit_plain(value.to_s, value_budget)
|
|
152
|
+
[key.to_s, value_text, note.to_s]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
noted_widths = rendered_rows.filter_map { |_, value_text, note| visible_width(value_text) unless note.empty? }
|
|
156
|
+
value_col = [noted_widths.max.to_i, VALUE_COL_MAX].min
|
|
157
|
+
note_budget = width - prefix_width - value_col - 2
|
|
158
|
+
|
|
159
|
+
out = +""
|
|
160
|
+
rendered_rows.each do |key, value_text, note|
|
|
161
|
+
row = " #{styled(key.ljust(key_width), color, BOLD)} #{value_text}"
|
|
162
|
+
if note.empty?
|
|
163
|
+
out << row.rstrip << "\n"
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
value_fits = visible_width(value_text) <= value_col
|
|
168
|
+
note_width = visible_width(note)
|
|
169
|
+
note_overflows = note_width > note_budget
|
|
170
|
+
note_fits = !note_overflows || (note_budget.positive? && note_width <= NOTE_FLOOR)
|
|
171
|
+
if value_fits && note_fits
|
|
172
|
+
pad = value_col - visible_width(value_text)
|
|
173
|
+
out << row << (" " * pad) << " " << fit(note, note_budget) { |t| styled(t, color, MIDGREY) } << "\n"
|
|
174
|
+
else
|
|
175
|
+
out << row.rstrip << "\n"
|
|
176
|
+
own_budget = [width - prefix_width, 0].max
|
|
177
|
+
out << (" " * prefix_width) << fit(note, own_budget) { |t| styled(t, color, MIDGREY) } << "\n"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
out
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# --- markdown-noise stripping, adapter-optional (intent 316a1, D3/D5) ------
|
|
184
|
+
#
|
|
185
|
+
# Not every display surface passes text through a Markdown renderer, so
|
|
186
|
+
# stripping is not this module's call to make (see `markdown_safe:` on
|
|
187
|
+
# `render` above; the justification for WHY a caller would ever ask for
|
|
188
|
+
# this lives with that caller, in scripts/lib/message_display.rb). When
|
|
189
|
+
# asked, strips backticks and neutralises `*`/`_` runs from a value.
|
|
190
|
+
# Single underscores are left alone: they are common inside ordinary words
|
|
191
|
+
# (`intent_screen.rb`) and GFM does not treat an intraword underscore as
|
|
192
|
+
# emphasis; only a run of 2+ (the bold marker `__`) is markdown-active.
|
|
193
|
+
def self.clean(text)
|
|
194
|
+
text.to_s.delete("`*").gsub(/_{2,}/, "")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- width cap (D15, matrix 18) --------------------------------------------
|
|
198
|
+
|
|
199
|
+
# Truncates `text` to `max` visible columns with a trailing ellipsis when
|
|
200
|
+
# cut, then yields the truncated plain text to the block for coloring.
|
|
201
|
+
# Coloring never adds visible width. The cap itself is harness-neutral: a
|
|
202
|
+
# fixed width, not a re-flow, is what lets a column layout survive whatever
|
|
203
|
+
# display eventually shows it — no display's own wrapping is assumed here.
|
|
204
|
+
def self.fit(text, max)
|
|
205
|
+
plain = fit_plain(text, max)
|
|
206
|
+
block_given? ? yield(plain) : plain
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.fit_plain(text, max)
|
|
210
|
+
return "" if max <= 0
|
|
211
|
+
return text if text.length <= max
|
|
212
|
+
return ELLIPSIS[0, max] if max <= 1
|
|
213
|
+
|
|
214
|
+
"#{text[0, max - 1]}#{ELLIPSIS}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- palette ----------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def self.styled(text, color, *codes)
|
|
220
|
+
return text unless color
|
|
221
|
+
|
|
222
|
+
"#{codes.join}#{text}#{RESET}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.status_cell(done, color)
|
|
226
|
+
label = done ? " done " : " open "
|
|
227
|
+
return label unless color
|
|
228
|
+
|
|
229
|
+
hue = done ? TEAL : AMBER
|
|
230
|
+
"#{hue}#{BOLD}#{label}#{RESET}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# `.dup` matters, not just style (318's own note, carried forward): these
|
|
234
|
+
# constants are built via string interpolation, which frozen_string_literal
|
|
235
|
+
# does NOT freeze automatically — only static literals get that. `.freeze`
|
|
236
|
+
# above makes them immutable, but `bar << ...` below still needs its OWN
|
|
237
|
+
# mutable copy or it would raise (or, without the freeze, silently corrupt
|
|
238
|
+
# the shared constant for every later call in the same process — matrix 16).
|
|
239
|
+
def self.render_bar(done, total, color)
|
|
240
|
+
ratio = total.zero? ? 0.0 : done.to_f / total
|
|
241
|
+
|
|
242
|
+
unless color
|
|
243
|
+
on = total.zero? ? 0 : (done * BAR_CELLS) / total
|
|
244
|
+
return ("#" * on) + ("." * (BAR_CELLS - on))
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
units = (ratio * BAR_CELLS * 8).round.clamp(0, BAR_CELLS * 8)
|
|
248
|
+
full, rem = units.divmod(8)
|
|
249
|
+
full = [full, BAR_CELLS].min
|
|
250
|
+
|
|
251
|
+
bar = TEAL.dup
|
|
252
|
+
bar << ("█" * full)
|
|
253
|
+
if full < BAR_CELLS && rem.positive?
|
|
254
|
+
bar << EIGHTHS[rem]
|
|
255
|
+
full += 1
|
|
256
|
+
end
|
|
257
|
+
track = BAR_CELLS - full
|
|
258
|
+
bar << GRAPHITE_BG << (" " * track) if track.positive?
|
|
259
|
+
bar << RESET
|
|
260
|
+
bar
|
|
261
|
+
end
|
|
262
|
+
end
|
package/scripts/lib/lock.rb
CHANGED
|
@@ -49,11 +49,11 @@ module Lock
|
|
|
49
49
|
DELEGATE_ACTIVITY_LIMIT = 20
|
|
50
50
|
DELEGATE_STATUSES = %w[active finished failed].freeze
|
|
51
51
|
|
|
52
|
-
# Lease TTL. Heartbeats fire from the
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
52
|
+
# Lease TTL. Heartbeats fire from the PostToolUse record hook on every write
|
|
53
|
+
# inside the intent dir (intent 302), so a delivering session refreshes
|
|
54
|
+
# constantly; 30 minutes tolerates long read-only stretches without opening
|
|
55
|
+
# a takeover window mid-delivery. Reclaim is explicit either way (takeover),
|
|
56
|
+
# so the TTL only bounds WHEN takeover is allowed.
|
|
57
57
|
TTL_SECONDS = 1800
|
|
58
58
|
|
|
59
59
|
# The write guard is a mutex, not a lock in the Plastic sense: it carries no
|
|
@@ -122,7 +122,7 @@ module Lock
|
|
|
122
122
|
Array(data["delegates"]).map(&:to_s).include?(session.to_s)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
# The one question
|
|
125
|
+
# The one question the write path asks: does session hold this intent's lock?
|
|
126
126
|
# Owner/delegate on an EXISTING lock counts even when stale (a stale lock is
|
|
127
127
|
# still theirs until an explicit takeover replaces it); freshness only
|
|
128
128
|
# guards AGAINST other sessions.
|
|
@@ -676,24 +676,4 @@ module Claim
|
|
|
676
676
|
end
|
|
677
677
|
end
|
|
678
678
|
|
|
679
|
-
# Second, independent write gate at the artifact grain (intent 111 D7). Returns a
|
|
680
|
-
# deny reason String to BLOCK, or nil to ALLOW. Composes UNDER the delivery-lock
|
|
681
|
-
# gate: only reached after the session already holds the intent's delivery lock.
|
|
682
|
-
# ENGAGES only when a claim file exists (dormant otherwise, so single-owner flows
|
|
683
|
-
# and the existing suite stay green, AC7). Fails open on stale/corrupt via
|
|
684
|
-
# fail_open?, the named contract.
|
|
685
|
-
def claim_gate_reason(intent_dir, artifact, session:, ttl: Lock::TTL_SECONDS, now: Time.now,
|
|
686
|
-
harness: :claude)
|
|
687
|
-
return nil if Lock.blank?(artifact)
|
|
688
|
-
return nil unless File.exist?(path(intent_dir, artifact)) # dormant: no claim
|
|
689
|
-
return nil if holds_claim?(intent_dir, artifact, session: session) # you hold it
|
|
690
|
-
return nil if fail_open?(intent_dir, artifact, ttl: ttl, now: now) # stale/corrupt: yield
|
|
691
|
-
data = read(intent_dir, artifact)
|
|
692
|
-
holder = data && data["owner_session"]
|
|
693
|
-
since = data && data["acquired_at"]
|
|
694
|
-
"artifact #{artifact} is claimed by #{holder} since #{since}; another writer holds " \
|
|
695
|
-
"it. Back off or run #{Lock.skill_ref('plastic-doctor', harness: harness)} check the " \
|
|
696
|
-
"lock status. If you are a distinct delegate, the owner must register you: " \
|
|
697
|
-
"plastic-lock delegate --intent-dir #{intent_dir} --session <your-session-id>"
|
|
698
|
-
end
|
|
699
679
|
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "screen_paint"
|
|
6
|
+
|
|
7
|
+
# MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
|
|
8
|
+
# Code MessageDisplay hook handler. One process per streamed chunk of every
|
|
9
|
+
# assistant message (D11), so it must be cheap and decide fast. Pure: every
|
|
10
|
+
# dependency (tmp_root, plastic_home, color, now, wait_ms, poll_ms, sleeper)
|
|
11
|
+
# is a constructor argument, never an ENV read, a Dir.pwd/Dir.home read, or
|
|
12
|
+
# the real Time.now/Kernel#sleep — the thin CLI (scripts/hook-message-display)
|
|
13
|
+
# is the one place allowed to read any of those.
|
|
14
|
+
#
|
|
15
|
+
# Claude adapter: Claude Code only; the core is harness-agnostic. (intent
|
|
16
|
+
# 316a1, D3 supersedes 316a's D6.) This is the sole caller that asks
|
|
17
|
+
# IntentScreenAnsi.render for `markdown_safe: true` (scripts/lib/
|
|
18
|
+
# intent_screen_ansi.rb) — see `finalize` below for why.
|
|
19
|
+
#
|
|
20
|
+
# A live run under a real pty (round 3) found that Claude Code fires the
|
|
21
|
+
# per-chunk hook processes CONCURRENTLY, not strictly in order. Chunk 0 is
|
|
22
|
+
# the one that recognizes the screen and creates the buffer (D13), and it can
|
|
23
|
+
# lose the race to chunks with a higher index: they would find no buffer yet
|
|
24
|
+
# and pass their raw Markdown straight through, producing a half plain /
|
|
25
|
+
# half styled screen. This class now survives that:
|
|
26
|
+
#
|
|
27
|
+
# - One file per chunk (index-named), written atomically (temp name in the
|
|
28
|
+
# same directory, then File.rename), so reassembly never depends on
|
|
29
|
+
# arrival order — only on the index each chunk already carries.
|
|
30
|
+
# - A decision file written BEFORE anything slow: chunk 0 writes SCREEN
|
|
31
|
+
# (the resolved intent dir + store root) the moment it engages, or
|
|
32
|
+
# NOSCREEN the moment it does not, so later chunks can decide without
|
|
33
|
+
# redoing any of chunk 0's work.
|
|
34
|
+
# - A later chunk asks a cheap, local question before ever waiting: could
|
|
35
|
+
# this delta plausibly be part of a screen (leading "|", "**Steps**", or
|
|
36
|
+
# blank)? An ordinary prose chunk arriving before SCREEN/NOSCREEN exists
|
|
37
|
+
# passes through at once, at zero cost. A chunk shaped like part of a
|
|
38
|
+
# screen polls for the decision, bounded (wait_ms/poll_ms), then fails
|
|
39
|
+
# open. The final chunk always waits for the decision, whatever its own
|
|
40
|
+
# shape, since it is the one that must not race — and it additionally
|
|
41
|
+
# waits (same budget) for every earlier chunk file to exist before it
|
|
42
|
+
# splices, returning whatever it does have rather than nothing when the
|
|
43
|
+
# budget runs out.
|
|
44
|
+
#
|
|
45
|
+
# Protocol (D13, preserved): chunk 0 still decides, once, before anything is
|
|
46
|
+
# buffered or blanked. D10 (any failure while finalizing returns the
|
|
47
|
+
# buffered original, never nil, never "") and D12 (color: false never
|
|
48
|
+
# buffers or blanks anything) are unchanged.
|
|
49
|
+
class MessageDisplay
|
|
50
|
+
# 317a (A4): engagement is grammar, not identity - any screen-family
|
|
51
|
+
# opener engages, with NO intent-id resolution (the roster and delay
|
|
52
|
+
# screens have none to resolve). ScreenPaint owns the full grammar.
|
|
53
|
+
ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
|
|
54
|
+
BUFFER_DIR_NAME = "plastic-message-display"
|
|
55
|
+
BUFFER_MAX_AGE_SECONDS = 3600
|
|
56
|
+
SCREEN_FILE = "SCREEN"
|
|
57
|
+
NOSCREEN_FILE = "NOSCREEN"
|
|
58
|
+
|
|
59
|
+
def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
|
|
60
|
+
sleeper: ->(seconds) { sleep(seconds) })
|
|
61
|
+
@tmp_root = tmp_root
|
|
62
|
+
@plastic_home = plastic_home
|
|
63
|
+
@color = color
|
|
64
|
+
@now = now
|
|
65
|
+
@wait_ms = wait_ms
|
|
66
|
+
@poll_ms = poll_ms
|
|
67
|
+
@sleeper = sleeper
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle(payload)
|
|
71
|
+
return nil unless @color
|
|
72
|
+
return nil unless payload.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
prune_old_buffers
|
|
75
|
+
|
|
76
|
+
message_id = payload["message_id"].to_s
|
|
77
|
+
session_id = payload["session_id"].to_s
|
|
78
|
+
delta = payload["delta"].to_s
|
|
79
|
+
final = payload["final"] == true
|
|
80
|
+
index = payload["index"]
|
|
81
|
+
cwd = payload["cwd"].to_s
|
|
82
|
+
|
|
83
|
+
return nil if message_id.empty? || session_id.empty?
|
|
84
|
+
|
|
85
|
+
dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
|
|
86
|
+
|
|
87
|
+
if index == 0
|
|
88
|
+
handle_chunk_zero(dir, delta, cwd, final)
|
|
89
|
+
else
|
|
90
|
+
handle_later_chunk(dir, index, delta, final)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The message directory both this class and the bash launcher (hooks/
|
|
95
|
+
# message-display) must agree on byte for byte (matrix 40): the launcher
|
|
96
|
+
# checks this exact path's existence to decide whether chunk > 0 of an
|
|
97
|
+
# engaged message gets handed to Ruby at all.
|
|
98
|
+
def self.buffer_path(tmp_root:, session_id:, message_id:)
|
|
99
|
+
File.join(tmp_root, BUFFER_DIR_NAME, session_id, message_id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.chunk_path(tmp_root:, session_id:, message_id:, index:)
|
|
103
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), index.to_s)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.screen_path(tmp_root:, session_id:, message_id:)
|
|
107
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.noscreen_path(tmp_root:, session_id:, message_id:)
|
|
111
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# Chunk 0 decides, synchronously, before anything else touches this
|
|
117
|
+
# message: recognize the marker (after leading whitespace only) AND
|
|
118
|
+
# resolve the id, both before anything is buffered or blanked (F4). Either
|
|
119
|
+
# failure writes NOSCREEN so every later chunk can decide instantly rather
|
|
120
|
+
# than waiting out its own budget for a decision that will never arrive.
|
|
121
|
+
def handle_chunk_zero(dir, delta, _cwd, final)
|
|
122
|
+
stripped = delta.sub(/\A[ \t]+/, "")
|
|
123
|
+
unless ENGAGE_RE.match?(stripped)
|
|
124
|
+
write_noscreen(dir)
|
|
125
|
+
return nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
write_screen(dir)
|
|
129
|
+
write_chunk(dir, 0, delta)
|
|
130
|
+
final ? finalize_final(dir, 0) : ""
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# A later chunk (index > 0) never redoes chunk 0's work: it only asks
|
|
134
|
+
# whether a decision already exists, waiting for one (bounded) when it
|
|
135
|
+
# does not and the chunk looks like it could matter. The final chunk
|
|
136
|
+
# always waits for the decision regardless of its own shape.
|
|
137
|
+
def handle_later_chunk(dir, index, delta, final)
|
|
138
|
+
decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
|
|
139
|
+
|
|
140
|
+
return nil unless decision == :screen
|
|
141
|
+
|
|
142
|
+
write_chunk(dir, index, delta)
|
|
143
|
+
final ? finalize_final(dir, index) : ""
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Checks for an existing decision first (free) and only pays the cheap
|
|
147
|
+
# shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
|
|
148
|
+
# there yet. `gate_delta: nil` (the final chunk) skips the shape test
|
|
149
|
+
# entirely and always polls for the decision.
|
|
150
|
+
def wait_for_decision(dir, gate_delta:)
|
|
151
|
+
decision = read_decision_now(dir)
|
|
152
|
+
return decision if decision
|
|
153
|
+
|
|
154
|
+
return :timeout if gate_delta && !maybe_screen?(gate_delta)
|
|
155
|
+
|
|
156
|
+
max_polls_for_budget.times do
|
|
157
|
+
@sleeper.call(@poll_ms / 1000.0)
|
|
158
|
+
decision = read_decision_now(dir)
|
|
159
|
+
return decision if decision
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
:timeout
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def read_decision_now(dir)
|
|
166
|
+
return :screen if File.exist?(File.join(dir, SCREEN_FILE))
|
|
167
|
+
return :noscreen if File.exist?(File.join(dir, NOSCREEN_FILE))
|
|
168
|
+
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Cheap, local, no file I/O: could this chunk's own delta plausibly be
|
|
173
|
+
# part of an intent screen (ignoring leading whitespace)? Every chunk of
|
|
174
|
+
# every ordinary prose message answers no, at zero cost.
|
|
175
|
+
def maybe_screen?(delta)
|
|
176
|
+
stripped = delta.lstrip
|
|
177
|
+
stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The final chunk additionally waits (same budget) for every earlier chunk
|
|
181
|
+
# file to exist before it reassembles and splices. On timeout it proceeds
|
|
182
|
+
# anyway with whatever is there (matrix, lead's guard): never nil, never
|
|
183
|
+
# swallowed.
|
|
184
|
+
def finalize_final(dir, index)
|
|
185
|
+
wait_for_chunk_files(dir, index)
|
|
186
|
+
|
|
187
|
+
buffered = nil
|
|
188
|
+
begin
|
|
189
|
+
buffered = read_buffered_chunks(dir, index)
|
|
190
|
+
finalize(buffered, nil)
|
|
191
|
+
rescue StandardError
|
|
192
|
+
buffered
|
|
193
|
+
ensure
|
|
194
|
+
FileUtils.rm_rf(dir)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def wait_for_chunk_files(dir, index)
|
|
199
|
+
return if index <= 0
|
|
200
|
+
|
|
201
|
+
needed = (0...index).map(&:to_s)
|
|
202
|
+
max_polls_for_budget.times do
|
|
203
|
+
return if needed.all? { |n| File.exist?(File.join(dir, n)) }
|
|
204
|
+
|
|
205
|
+
@sleeper.call(@poll_ms / 1000.0)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def max_polls_for_budget
|
|
210
|
+
return 0 unless @poll_ms.to_f.positive?
|
|
211
|
+
|
|
212
|
+
(@wait_ms / @poll_ms.to_f).ceil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Whatever chunk files exist, in index order, concatenated -- gaps (a
|
|
216
|
+
# chunk that never arrived, or arrived too late) are skipped rather than
|
|
217
|
+
# blocking reassembly (lead's guard: never return nothing).
|
|
218
|
+
def read_buffered_chunks(dir, index)
|
|
219
|
+
(0..index).filter_map do |i|
|
|
220
|
+
path = File.join(dir, i.to_s)
|
|
221
|
+
File.exist?(path) ? File.read(path) : nil
|
|
222
|
+
end.join
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# 317a (D1/B10): paint what was printed. The buffered message's screen
|
|
226
|
+
# region - located and bounded by ScreenPaint's own grammar - is re-laid in
|
|
227
|
+
# the ANSI vocabulary; prose before and after survives verbatim. A region
|
|
228
|
+
# the painter cannot parse returns the buffered original (A3: chunks were
|
|
229
|
+
# already blanked, so nil here would truncate the message to its final
|
|
230
|
+
# delta; nil is only for the never-engaged path in handle).
|
|
231
|
+
#
|
|
232
|
+
# markdown_safe: true (intent 316a1, D5) - Claude Code still Markdown-
|
|
233
|
+
# processes displayContent even inside a raw ANSI block, so the Claude
|
|
234
|
+
# adapter asks the harness-agnostic core to strip markdown noise. A harness
|
|
235
|
+
# whose display surface passes raw ANSI through untouched would ask for
|
|
236
|
+
# false instead.
|
|
237
|
+
def finalize(buffered, _decision)
|
|
238
|
+
lines = buffered.lines
|
|
239
|
+
start = lines.index { |l| ScreenPaint.classify(l) == :opener }
|
|
240
|
+
return buffered unless start
|
|
241
|
+
|
|
242
|
+
stop = ScreenPaint.region_end(lines, start)
|
|
243
|
+
painted = ScreenPaint.paint(lines[start...stop].join, color: true, markdown_safe: true)
|
|
244
|
+
return buffered unless painted
|
|
245
|
+
|
|
246
|
+
suffix = lines[stop..].to_a.join.sub(/\A\n+/, "")
|
|
247
|
+
out = +"#{lines[0...start].join}#{painted.rstrip}\n"
|
|
248
|
+
out << "\n#{suffix}" unless suffix.empty?
|
|
249
|
+
out
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def write_chunk(dir, index, delta)
|
|
253
|
+
atomic_write(File.join(dir, index.to_s), delta)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
|
|
257
|
+
# store/ — e.g. .../projects/<slug> or plastic_home itself), never the
|
|
258
|
+
# store/ directory itself; resolve_intent_dir's `root:` is already that.
|
|
259
|
+
def write_screen(dir)
|
|
260
|
+
atomic_write(File.join(dir, SCREEN_FILE), "")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def write_noscreen(dir)
|
|
264
|
+
atomic_write(File.join(dir, NOSCREEN_FILE), "")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def atomic_write(path, content)
|
|
268
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
269
|
+
tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
|
|
270
|
+
File.write(tmp_path, content)
|
|
271
|
+
File.rename(tmp_path, path)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def prune_old_buffers
|
|
277
|
+
root = File.join(@tmp_root, BUFFER_DIR_NAME)
|
|
278
|
+
return unless File.directory?(root)
|
|
279
|
+
|
|
280
|
+
Dir.children(root).each do |session_dir|
|
|
281
|
+
full = File.join(root, session_dir)
|
|
282
|
+
next unless File.directory?(full)
|
|
283
|
+
|
|
284
|
+
age = @now.to_i - File.mtime(full).to_i
|
|
285
|
+
FileUtils.rm_rf(full) if age > BUFFER_MAX_AGE_SECONDS
|
|
286
|
+
end
|
|
287
|
+
rescue StandardError
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# encoding: UTF-8
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require_relative "
|
|
4
|
+
require_relative "savepoint"
|
|
5
5
|
require_relative "intent_validator"
|
|
6
6
|
|
|
7
7
|
# OutcomeGuard - the single definition of "is outcome.md real and disposition-matched for
|
|
8
8
|
# THIS close" (intent 222). Extracted verbatim from scripts/end-intent's own
|
|
9
|
-
# outcome_guard_reason (intent 161/188) so
|
|
10
|
-
#
|
|
11
|
-
# as a valid outcome.md.
|
|
9
|
+
# outcome_guard_reason (intent 161/188) so its two callers, doctor's per-intent check
|
|
10
|
+
# (intent_lifecycle_artifacts) and end-intent's post-backfill self-check, can never drift on
|
|
11
|
+
# what counts as a valid outcome.md. Since intent 308 nothing blocks on it: end-intent
|
|
12
|
+
# backfills a missing or placeholder outcome.md first, prints a non-nil reason, and proceeds.
|
|
13
|
+
# Pure, read-only: authors nothing regardless of outcome.
|
|
12
14
|
module OutcomeGuard
|
|
13
15
|
module_function
|
|
14
16
|
|
|
@@ -23,7 +25,7 @@ module OutcomeGuard
|
|
|
23
25
|
|
|
24
26
|
content = File.read(path)
|
|
25
27
|
first_line = content.each_line.first.to_s.chomp
|
|
26
|
-
if first_line ==
|
|
28
|
+
if first_line == Savepoint::PLACEHOLDER_SENTINEL
|
|
27
29
|
return "outcome.md is still the scaffold placeholder (first line is the sentinel)"
|
|
28
30
|
end
|
|
29
31
|
|
|
@@ -24,6 +24,15 @@ require "yaml"
|
|
|
24
24
|
module ProjectValidator
|
|
25
25
|
module_function
|
|
26
26
|
|
|
27
|
+
# The two enumerated flow: knobs (intent 300, spec D2). Kept as a plain
|
|
28
|
+
# literal here rather than a require on SessionGit: this validator only
|
|
29
|
+
# reports a bad value in `errors` (project.yml is still "accepted", spec
|
|
30
|
+
# D2's matrix row), it never blocks spawn completeness, since a bad value
|
|
31
|
+
# degrades gracefully at commit time (SessionGit falls back with a Note)
|
|
32
|
+
# rather than blocking the spawn.
|
|
33
|
+
FLOW_MODES = %w[direct pull_request].freeze
|
|
34
|
+
FLOW_WORKSPACES = %w[checkout worktree].freeze
|
|
35
|
+
|
|
27
36
|
def validate(slug, plastic_home: File.join(Dir.home, ".plastic"))
|
|
28
37
|
missing = []
|
|
29
38
|
errors = []
|
|
@@ -53,7 +62,9 @@ module ProjectValidator
|
|
|
53
62
|
rescue StandardError
|
|
54
63
|
nil
|
|
55
64
|
end
|
|
56
|
-
|
|
65
|
+
if parsed.is_a?(Hash)
|
|
66
|
+
validate_flow_block(parsed, errors)
|
|
67
|
+
else
|
|
57
68
|
missing << "project.yml (valid YAML)"
|
|
58
69
|
errors << "project.yml exists at #{project_yml_path} but does not parse as YAML"
|
|
59
70
|
end
|
|
@@ -88,6 +99,24 @@ module ProjectValidator
|
|
|
88
99
|
{ ok: missing.empty?, missing: missing, errors: errors }
|
|
89
100
|
end
|
|
90
101
|
|
|
102
|
+
# Non-blocking: an unknown `mode` or `workspace` value in project.yml's
|
|
103
|
+
# `flow:` block, if present, is reported in `errors` but never added to
|
|
104
|
+
# `missing`, so it never flips `ok`.
|
|
105
|
+
def validate_flow_block(parsed, errors)
|
|
106
|
+
flow = parsed["flow"]
|
|
107
|
+
return unless flow.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
validate_flow_knob(flow, "mode", FLOW_MODES, errors)
|
|
110
|
+
validate_flow_knob(flow, "workspace", FLOW_WORKSPACES, errors)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_flow_knob(flow, key, allowed, errors)
|
|
114
|
+
value = flow[key]
|
|
115
|
+
return if value.nil? || allowed.include?(value.to_s)
|
|
116
|
+
|
|
117
|
+
errors << "project.yml flow.#{key} is #{value.inspect}, must be one of #{allowed.join(", ")}"
|
|
118
|
+
end
|
|
119
|
+
|
|
91
120
|
# Invariant 1: registered in projects.yml with a 'path'. Returns the
|
|
92
121
|
# project's entry Hash, or nil when unregistered or the entry has no path.
|
|
93
122
|
def registration_for(slug, plastic_home)
|