@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,276 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "intent_screen_ansi"
|
|
5
|
+
|
|
6
|
+
# ScreenPaint (intent 317a, D1) - the paint seam 317's Needs-you named. Parses
|
|
7
|
+
# the plain Markdown screens our own renderers emit (intent, state, roster,
|
|
8
|
+
# delivered, delay) and re-lays them out in the shipped intent-screen ANSI
|
|
9
|
+
# vocabulary. A parser and RE-LAYOUTER, not a colorizer (A5): the plain
|
|
10
|
+
# screens are pipe tables whose scaffolding rows only disappear under a
|
|
11
|
+
# Markdown renderer, so the painter drops them and rebuilds the layout;
|
|
12
|
+
# content-survival is the contract - every value and note survives, nothing
|
|
13
|
+
# is invented, and text it does not recognize returns nil so every caller
|
|
14
|
+
# fails open to plain.
|
|
15
|
+
#
|
|
16
|
+
# Harness-agnostic core: no harness assumption lives here. No ENV, no TTY. Color, width, and
|
|
17
|
+
# markdown_safe are caller arguments, exactly like IntentScreenAnsi before it
|
|
18
|
+
# (316a1); the 318 ceiling holds - the palette is IntentScreenAnsi's, no new
|
|
19
|
+
# colors, no box borders.
|
|
20
|
+
module ScreenPaint
|
|
21
|
+
A = IntentScreenAnsi
|
|
22
|
+
|
|
23
|
+
# A screen's first line: "## ▶ id · name", "## ✔ id · name · delivered",
|
|
24
|
+
# "▶ In delivery · ...", "✔ id · name · delivered in ...".
|
|
25
|
+
OPENER_RE = /\A(?:## )?[▶✔] .+ · /.freeze
|
|
26
|
+
|
|
27
|
+
FIELD_LINE_RE = /\A(Stage|Next|Changed|Lead|Progress)(\s{2,})(.*)\z/.freeze
|
|
28
|
+
STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
|
|
29
|
+
TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
|
|
30
|
+
COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
|
|
31
|
+
BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
|
|
32
|
+
|
|
33
|
+
# Intent 317a1 (D3, D4, D5): the data-table palette. Kind and note columns
|
|
34
|
+
# are chosen by the table's own header, never by position; a cell whose
|
|
35
|
+
# text is exactly "not recorded" greys wherever it appears.
|
|
36
|
+
EVIDENCE_PROOF_KINDS = %w[suite red ship doctor deposits verdict].freeze
|
|
37
|
+
EVIDENCE_DEVIATION_KINDS = %w[deviates].freeze
|
|
38
|
+
NOTE_HEADERS = %w[Source Why].freeze
|
|
39
|
+
NOT_RECORDED = "not recorded"
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# The classifier both paint and region_end share. `idx`/`opener_idx` give
|
|
44
|
+
# the positional rule its footing: the line right after a title is the meta
|
|
45
|
+
# line (delivered/delay print one), recognizable by its " · " separators.
|
|
46
|
+
def classify(line, idx: nil, opener_idx: nil)
|
|
47
|
+
text = line.chomp
|
|
48
|
+
stripped = text.strip
|
|
49
|
+
return :blank if stripped.empty?
|
|
50
|
+
return :opener if OPENER_RE.match?(stripped) && text == stripped
|
|
51
|
+
return :table if text.lstrip.start_with?("|")
|
|
52
|
+
return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
|
|
53
|
+
return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
|
|
54
|
+
return :indented if text.start_with?(" ")
|
|
55
|
+
return :field if FIELD_LINE_RE.match?(text)
|
|
56
|
+
return :step if STEP_LINE_RE.match?(text)
|
|
57
|
+
return :timeline if TIMELINE_RE.match?(text)
|
|
58
|
+
return :count if COUNT_LINE_RE.match?(stripped)
|
|
59
|
+
return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
|
|
60
|
+
:unknown
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Where the screen region ends inside a larger message (B10): walk from the
|
|
64
|
+
# opener while every line classifies; the first unknown line - ordinary
|
|
65
|
+
# prose, a prose bullet - is the boundary. Never consumes past the screen.
|
|
66
|
+
def region_end(lines, start_idx)
|
|
67
|
+
i = start_idx + 1
|
|
68
|
+
while i < lines.length
|
|
69
|
+
kind = classify(lines[i], idx: i, opener_idx: start_idx)
|
|
70
|
+
break if kind == :unknown
|
|
71
|
+
# A bare "**Section**" head belongs to the screen only when what follows
|
|
72
|
+
# is still grammar; "**What this means**" over prose bullets is the
|
|
73
|
+
# model's own commentary and stays outside, unsplit (B10).
|
|
74
|
+
if kind == :bold && bare_bold?(lines[i]) && !grammar_follows?(lines, i, start_idx)
|
|
75
|
+
break
|
|
76
|
+
end
|
|
77
|
+
i += 1
|
|
78
|
+
end
|
|
79
|
+
# Trailing blanks belong to the message, not the screen.
|
|
80
|
+
i -= 1 while i > start_idx + 1 && lines[i - 1].strip.empty?
|
|
81
|
+
i
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def bare_bold?(line)
|
|
85
|
+
m = BOLD_LEAD_RE.match(line.strip)
|
|
86
|
+
m && m[2].to_s.strip.empty?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def grammar_follows?(lines, idx, opener_idx)
|
|
90
|
+
j = idx + 1
|
|
91
|
+
j += 1 while j < lines.length && lines[j].strip.empty?
|
|
92
|
+
return false if j >= lines.length
|
|
93
|
+
kind = classify(lines[j], idx: j, opener_idx: opener_idx)
|
|
94
|
+
kind != :unknown && kind != :opener
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The painter. Returns the ANSI (or plain re-laid, when color: false) text,
|
|
98
|
+
# or nil when the input does not open with a screen title or carries a line
|
|
99
|
+
# outside the grammar - the caller's cue to print the original untouched.
|
|
100
|
+
def paint(text, color: true, width: A::DEFAULT_WIDTH, markdown_safe: false)
|
|
101
|
+
lines = text.to_s.lines
|
|
102
|
+
first_idx = lines.index { |l| !l.strip.empty? }
|
|
103
|
+
return nil if first_idx.nil?
|
|
104
|
+
return nil unless classify(lines[first_idx]) == :opener
|
|
105
|
+
|
|
106
|
+
out = +""
|
|
107
|
+
table = []
|
|
108
|
+
ok = true
|
|
109
|
+
indent_run = 0
|
|
110
|
+
|
|
111
|
+
flush = lambda do
|
|
112
|
+
next if table.empty?
|
|
113
|
+
out << paint_table(table, color: color, width: width, markdown_safe: markdown_safe)
|
|
114
|
+
table.clear
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
lines.each_with_index do |line, idx|
|
|
118
|
+
kind = classify(line, idx: idx, opener_idx: first_idx)
|
|
119
|
+
# Intent 317a1 (O6, D8): the run counter tracks consecutive :indented
|
|
120
|
+
# lines so only the SECOND and later lines under a heading like
|
|
121
|
+
# "**Asked**" grey as a note; a :table line (which `next`s below) must
|
|
122
|
+
# reset it too, so a later unrelated indented block starts fresh.
|
|
123
|
+
indent_run = kind == :indented ? indent_run + 1 : 0
|
|
124
|
+
if kind == :table
|
|
125
|
+
table << line.strip
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
flush.call
|
|
129
|
+
case kind
|
|
130
|
+
when :opener
|
|
131
|
+
t = clean(line.strip.sub(/\A## /, ""), markdown_safe)
|
|
132
|
+
out << A.fit(t, width) { |s| A.styled(s, color, A::BOLD, A::NEARWHITE) } << "\n"
|
|
133
|
+
when :meta
|
|
134
|
+
out << A.fit(clean(line.strip, markdown_safe), width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
135
|
+
when :bold
|
|
136
|
+
m = BOLD_LEAD_RE.match(line.strip)
|
|
137
|
+
head = A.styled(clean(m[1], markdown_safe), color, A::BOLD, A::NEARWHITE)
|
|
138
|
+
out << head << clean(m[2], markdown_safe) << "\n"
|
|
139
|
+
when :indented
|
|
140
|
+
text = A.fit_plain(clean(line.chomp, markdown_safe), width)
|
|
141
|
+
out << (indent_run > 1 ? A.styled(text, color, A::MIDGREY) : text) << "\n"
|
|
142
|
+
when :field
|
|
143
|
+
m = FIELD_LINE_RE.match(line.chomp)
|
|
144
|
+
out << A.styled(m[1].ljust(8), color, A::BOLD) << " " << clean(m[3], markdown_safe) << "\n"
|
|
145
|
+
when :step
|
|
146
|
+
m = STEP_LINE_RE.match(line.chomp)
|
|
147
|
+
badge = A.status_cell(m[2] == "done", color)
|
|
148
|
+
out << "#{m[1].ljust(4)} [#{badge}] #{A.fit_plain(clean(m[3], markdown_safe), width - 12)}\n"
|
|
149
|
+
when :timeline
|
|
150
|
+
# Intent 317a1 (O5, D6, D7): the time carries no escape at all; the
|
|
151
|
+
# kind label carries the color - amber for the review turning point,
|
|
152
|
+
# teal for a landed commit, mid-grey (no bold) otherwise.
|
|
153
|
+
m = TIMELINE_RE.match(line.chomp)
|
|
154
|
+
codes = case m[2]
|
|
155
|
+
when "Review" then [A::BOLD, A::AMBER]
|
|
156
|
+
when "Commit" then [A::BOLD, A::TEAL]
|
|
157
|
+
else [A::MIDGREY]
|
|
158
|
+
end
|
|
159
|
+
out << m[1] << " " << A.styled(m[2].ljust(6), color, *codes) << " " << clean(m[3], markdown_safe) << "\n"
|
|
160
|
+
when :count
|
|
161
|
+
out << A.fit(line.strip, width) { |s| A.styled(s, color, A::MIDGREY) } << "\n"
|
|
162
|
+
when :closer
|
|
163
|
+
out << A.styled(line.strip, color, A::MIDGREY) << "\n"
|
|
164
|
+
when :blank
|
|
165
|
+
out << "\n"
|
|
166
|
+
else
|
|
167
|
+
ok = false
|
|
168
|
+
break
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
flush.call
|
|
172
|
+
return nil unless ok
|
|
173
|
+
|
|
174
|
+
paint_bars(out.gsub(/\n{3,}/, "\n\n"), color)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# --- tables -----------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
SEPARATOR_RE = /\A\|[\s:|-]+\|?\z/.freeze
|
|
180
|
+
|
|
181
|
+
def cells_of(row)
|
|
182
|
+
row.split("|", -1).map(&:strip)[1..-2].to_a
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def field_table?(rows)
|
|
186
|
+
rows.first&.gsub(/[\s|]/, "") == "" || cells_of(rows.first).first.to_s.start_with?("**")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A field table ("| | | |" scaffold, "| **Key** | value | note |" rows)
|
|
190
|
+
# re-lays as the intent screen's three-column field block: bold key,
|
|
191
|
+
# value, and a mid-grey note that shares the line when it fits, dropping
|
|
192
|
+
# to its own line only as a fallback (D9-D11). A data table re-lays as
|
|
193
|
+
# padded columns with a bold header, done/open cells colored, no pipes
|
|
194
|
+
# anywhere.
|
|
195
|
+
def paint_table(rows, color:, width:, markdown_safe:)
|
|
196
|
+
rows = rows.reject { |r| SEPARATOR_RE.match?(r) || r.gsub(/[\s|]/, "").empty? }
|
|
197
|
+
return "" if rows.empty?
|
|
198
|
+
|
|
199
|
+
if cells_of(rows.first).first.to_s.start_with?("**")
|
|
200
|
+
return paint_field_table(rows, color: color, width: width, markdown_safe: markdown_safe)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
paint_data_table(rows, color: color, markdown_safe: markdown_safe)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Intent 317a1 (O3, D9-D11, D14, D15): the same three-column geometry as
|
|
207
|
+
# IntentScreenAnsi.render's field-row loop, on the SAME implementation
|
|
208
|
+
# (D12) - `IntentScreenAnsi.field_table_lines` - so the two renderers
|
|
209
|
+
# cannot drift apart. Notes arrive as plain text here, so `visible_width`
|
|
210
|
+
# equals `length`, but the shared helper is used anyway so both renderers
|
|
211
|
+
# read identically.
|
|
212
|
+
def paint_field_table(rows, color:, width:, markdown_safe:)
|
|
213
|
+
key_w = rows.map { |r| cells_of(r).first.to_s.gsub("*", "").length }.max
|
|
214
|
+
field_rows = rows.map do |row|
|
|
215
|
+
key, value, note = cells_of(row)
|
|
216
|
+
key = key.to_s.gsub("*", "")
|
|
217
|
+
[key, clean(value.to_s, markdown_safe), clean(note.to_s, markdown_safe)]
|
|
218
|
+
end
|
|
219
|
+
A.field_table_lines(field_rows, width: width, color: color, key_width: key_w)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Intent 317a1 (O4, D3-D5): a data table's kind and note columns are chosen
|
|
223
|
+
# by the header, never by position; a cell whose text is exactly "not
|
|
224
|
+
# recorded" greys wherever it appears. `widths[ci] || 0` (rather than a
|
|
225
|
+
# bare `widths[ci]`) is the ragged-row guard: `ReportScreen.escape` writes
|
|
226
|
+
# `\|` while `cells_of` still splits on every `|`, so a row can carry more
|
|
227
|
+
# cells than its header without either side ever raising.
|
|
228
|
+
def paint_data_table(rows, color:, markdown_safe:)
|
|
229
|
+
grid = rows.map { |r| cells_of(r).map { |c| clean(c, markdown_safe) } }
|
|
230
|
+
widths = grid.first.each_index.map { |i| grid.map { |r| r[i].to_s.length }.max }
|
|
231
|
+
kind_col = grid.first.first == "Kind" ? 0 : nil
|
|
232
|
+
note_col = grid.first.index { |h| NOTE_HEADERS.include?(h) }
|
|
233
|
+
|
|
234
|
+
out = +""
|
|
235
|
+
grid.each_with_index do |cols, ri|
|
|
236
|
+
last_ci = cols.length - 1
|
|
237
|
+
cells = cols.each_with_index.map do |cell, ci|
|
|
238
|
+
padded = ci == last_ci ? cell.to_s : cell.to_s.ljust(widths[ci] || 0)
|
|
239
|
+
# An empty cell never gets styled (317a1 post-exec review, finding
|
|
240
|
+
# 1): `A.styled("", ...)` still emits a color-open/RESET pair around
|
|
241
|
+
# nothing visible, and that hides the join separator's own trailing
|
|
242
|
+
# spaces from `.rstrip` below, on the very line the reader sees.
|
|
243
|
+
next padded if cell.to_s.empty?
|
|
244
|
+
if ri.zero?
|
|
245
|
+
A.styled(padded, color, A::BOLD)
|
|
246
|
+
elsif ci == kind_col && EVIDENCE_PROOF_KINDS.include?(cell)
|
|
247
|
+
A.styled(padded, color, A::TEAL, A::BOLD)
|
|
248
|
+
elsif ci == kind_col && EVIDENCE_DEVIATION_KINDS.include?(cell)
|
|
249
|
+
A.styled(padded, color, A::AMBER, A::BOLD)
|
|
250
|
+
elsif ci == note_col
|
|
251
|
+
A.styled(padded, color, A::MIDGREY)
|
|
252
|
+
elsif cell == "done"
|
|
253
|
+
A.styled(padded, color, A::TEAL)
|
|
254
|
+
elsif cell == "open"
|
|
255
|
+
A.styled(padded, color, A::AMBER)
|
|
256
|
+
elsif cell == NOT_RECORDED
|
|
257
|
+
A.styled(padded, color, A::MIDGREY)
|
|
258
|
+
else
|
|
259
|
+
padded
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
out << " " << cells.join(" ").rstrip << "\n"
|
|
263
|
+
end
|
|
264
|
+
out
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def paint_bars(text, color)
|
|
268
|
+
return text unless color
|
|
269
|
+
text.gsub(/█+/) { |run| "#{A::TEAL}#{run}#{A::RESET}" }
|
|
270
|
+
.gsub(/░+/) { |run| "#{A::MIDGREY}#{run}#{A::RESET}" }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def clean(text, markdown_safe)
|
|
274
|
+
markdown_safe ? A.clean(text) : text
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SessionBackfill (intent 301): file a session day ledger. The four backfilled
|
|
5
|
+
# documents are a pure function of the day's checklist.md and savepoint.md, so
|
|
6
|
+
# every run regenerates them in full and a rerun is correct by construction.
|
|
7
|
+
# The carry step deduplicates against the target day before it appends and
|
|
8
|
+
# flips the source line after, so a crash at any point leaves a rerun correct.
|
|
9
|
+
# Every ledger write goes through SessionLedger. No environment reads here;
|
|
10
|
+
# the CLI passes every path in.
|
|
11
|
+
|
|
12
|
+
require "date"
|
|
13
|
+
require "fileutils"
|
|
14
|
+
require "time"
|
|
15
|
+
require_relative "session_ledger"
|
|
16
|
+
|
|
17
|
+
module SessionBackfill
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
DOCS = ["spec.md", "plan.md", "actions/ACTION_1.md", "outcome.md"].freeze
|
|
21
|
+
SAVEPOINT_RE = /\A(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s{2,}(\S+)(?:\s{2,}(.*))?\z/m.freeze
|
|
22
|
+
CARRIED_RE = / \(carried from \d{8}\)\z/.freeze
|
|
23
|
+
FILER_SESSION = "filer"
|
|
24
|
+
FILER_PROJECT = "global"
|
|
25
|
+
SUMMARY_CAP = 200
|
|
26
|
+
|
|
27
|
+
# --- reading ---------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def parse_savepoint_line(line)
|
|
30
|
+
match = SAVEPOINT_RE.match(line.to_s.chomp.scrub)
|
|
31
|
+
return nil unless match
|
|
32
|
+
|
|
33
|
+
{ timestamp: match[1], event: match[2], rest: match[3].to_s }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def read_items(dir)
|
|
37
|
+
path = File.join(dir, "checklist.md")
|
|
38
|
+
SessionLedger.read_locked(path).each_line.filter_map { |l| SessionLedger.parse_checklist_line(l) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read_savepoint_lines(dir)
|
|
42
|
+
path = File.join(dir, "savepoint.md")
|
|
43
|
+
SessionLedger.read_locked(path).each_line.map(&:chomp).reject(&:empty?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# --- rendering, pure ---------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def format_item(item)
|
|
49
|
+
marker = SessionLedger::STATES.fetch(item[:state])
|
|
50
|
+
"- [#{marker}] [#{item[:session]}] [#{item[:project]}] #{item[:summary]}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def section(title, lines)
|
|
54
|
+
body = lines.empty? ? "(none)" : lines.join("\n")
|
|
55
|
+
"## #{title}\n#{body}\n"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render(dir, day:)
|
|
59
|
+
items = read_items(dir)
|
|
60
|
+
steps = read_savepoint_lines(dir)
|
|
61
|
+
requests = items.reject { |i| i[:state] == :dropped }.map { |i| "- [#{i[:session]}] [#{i[:project]}] #{i[:summary]}" }
|
|
62
|
+
done = items.select { |i| i[:state] == :done }.map { |i| format_item(i) }
|
|
63
|
+
carried = items.select { |i| i[:state] == :moved }.map { |i| format_item(i) }
|
|
64
|
+
promoted = items.select { |i| i[:state] == :promoted }.map { |i| format_item(i) }
|
|
65
|
+
disposition = done.empty? ? "abandoned" : "delivered"
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
"spec.md" => "# Spec: session ledger #{day}\n\n#{section('Requests', requests)}",
|
|
69
|
+
"plan.md" => "# Plan: session ledger #{day}\n\n#{section('Steps', steps.map { |s| "- #{s}" })}",
|
|
70
|
+
"actions/ACTION_1.md" => "# ACTION_1: session ledger #{day}\n\n#{section('Items', items.map { |i| format_item(i) })}",
|
|
71
|
+
"outcome.md" => "---\ndisposition: #{disposition}\n---\n# Outcome: session ledger #{day}\n\n" \
|
|
72
|
+
"#{section('Delivered', done)}\n#{section('Carried', carried)}\n#{section('Promoted', promoted)}",
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# --- writing, atomic inside the day dir ----------------------------------------
|
|
77
|
+
|
|
78
|
+
def write_atomic(dir, relative, content)
|
|
79
|
+
target = File.join(dir, relative)
|
|
80
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
81
|
+
tmp = File.join(File.dirname(target), ".filing-#{File.basename(target)}")
|
|
82
|
+
File.write(tmp, content)
|
|
83
|
+
File.rename(tmp, target)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_docs(dir, docs)
|
|
87
|
+
docs.each { |relative, content| write_atomic(dir, relative, content) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# --- closed marker ---------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def closed_at(day_file)
|
|
93
|
+
return nil unless File.exist?(day_file)
|
|
94
|
+
|
|
95
|
+
frontmatter = File.read(day_file).split(/^---\s*$/, 3)[1].to_s
|
|
96
|
+
value = frontmatter[/^closed:\s*(\S+)/, 1]
|
|
97
|
+
value ? Time.iso8601(value) : nil
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stamp_closed(day_file, now:)
|
|
103
|
+
stamp = "closed: #{now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')}\n"
|
|
104
|
+
content = File.read(day_file)
|
|
105
|
+
updated =
|
|
106
|
+
if content =~ /^closed:.*\n/
|
|
107
|
+
content.sub(/^closed:.*\n/, stamp)
|
|
108
|
+
else
|
|
109
|
+
parts = content.split(/^(---\s*\n)/, 3)
|
|
110
|
+
# parts: ["", "---\n", frontmatter, "---\n", rest]
|
|
111
|
+
parts.length >= 4 ? parts[0] + parts[1] + parts[2] + stamp + parts[3] + parts[4].to_s : content + stamp
|
|
112
|
+
end
|
|
113
|
+
write_atomic(File.dirname(day_file), File.basename(day_file), updated)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A day is closed when it carries a closed: stamp and its checklist has not
|
|
117
|
+
# been written since (integer-second comparison; a line appended within the
|
|
118
|
+
# closing second waits for the next line).
|
|
119
|
+
def closed?(store, day)
|
|
120
|
+
closed = closed_at(SessionLedger.day_file(store, day))
|
|
121
|
+
return false unless closed
|
|
122
|
+
|
|
123
|
+
checklist = SessionLedger.checklist_path(store, day)
|
|
124
|
+
return true unless File.exist?(checklist)
|
|
125
|
+
|
|
126
|
+
File.mtime(checklist).to_i <= closed.to_i
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# --- carry -------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def carried_summary(summary, from)
|
|
132
|
+
suffix = " (carried from #{from})"
|
|
133
|
+
base = SessionLedger.sanitize_summary(summary.sub(CARRIED_RE, ""))
|
|
134
|
+
room = SUMMARY_CAP - suffix.length
|
|
135
|
+
base = "#{base[0, room - 3]}..." if base.length > room
|
|
136
|
+
base + suffix
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def note(store, day, text, now:)
|
|
140
|
+
SessionLedger.append_line(SessionLedger.savepoint_path(store, day),
|
|
141
|
+
SessionLedger.savepoint_line("Note", FILER_SESSION, FILER_PROJECT, text, now: now))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def carry(store:, from:, to:, templates:, author:, now: Time.now)
|
|
145
|
+
SessionLedger.open_day(store: store, day: to, templates: templates, author: author, now: now)
|
|
146
|
+
source = SessionLedger.checklist_path(store, from)
|
|
147
|
+
target = SessionLedger.checklist_path(store, to)
|
|
148
|
+
existing = read_items(SessionLedger.day_dir(store, to)).map { |i| [i[:session], i[:summary]] }
|
|
149
|
+
count = 0
|
|
150
|
+
read_items(SessionLedger.day_dir(store, from)).select { |i| i[:state] == :open }.each do |item|
|
|
151
|
+
summary = carried_summary(item[:summary], from)
|
|
152
|
+
unless existing.include?([item[:session], summary])
|
|
153
|
+
SessionLedger.append_line(target, SessionLedger.checklist_line(:open, item[:session], item[:project], summary),
|
|
154
|
+
header: SessionLedger.checklist_header(to))
|
|
155
|
+
existing << [item[:session], summary]
|
|
156
|
+
end
|
|
157
|
+
SessionLedger.set_state(source, from: :open, to: :moved, session: item[:session], match: item[:summary])
|
|
158
|
+
count += 1
|
|
159
|
+
end
|
|
160
|
+
return 0 if count.zero?
|
|
161
|
+
|
|
162
|
+
note(store, from, "carried #{count} items to #{to}", now: now)
|
|
163
|
+
note(store, to, "received #{count} items from #{from}", now: now)
|
|
164
|
+
count
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# --- the filer -----------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
# Returns :closed when the day is already filed and unchanged, else :filed.
|
|
170
|
+
def file_day(store:, day:, templates:, author:, carry_to: nil, now: Time.now)
|
|
171
|
+
SessionLedger.open_day(store: store, day: day, templates: templates, author: author, now: now)
|
|
172
|
+
return :closed if closed?(store, day)
|
|
173
|
+
|
|
174
|
+
dir = SessionLedger.day_dir(store, day)
|
|
175
|
+
checklist = SessionLedger.checklist_path(store, day)
|
|
176
|
+
dropped = SessionLedger.flip_all(checklist, from: :pending, to: :dropped)
|
|
177
|
+
note(store, day, "dropped #{dropped} pending lines at filing", now: now) if dropped.positive?
|
|
178
|
+
carry(store: store, from: day, to: carry_to, templates: templates, author: author, now: now) if carry_to
|
|
179
|
+
write_docs(dir, render(dir, day: day))
|
|
180
|
+
stamp = [Time.now, (File.exist?(checklist) ? File.mtime(checklist) : Time.at(0))].max
|
|
181
|
+
stamp_closed(SessionLedger.day_file(store, day), now: stamp)
|
|
182
|
+
:filed
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SessionClose (intent 301): the per-session close, run by hooks/close at
|
|
5
|
+
# SessionEnd. Cheap, fail-open, and a no-op for the reasons that do not end a
|
|
6
|
+
# session (`clear`, `resume`). Every job runs in its own rescue so one failure
|
|
7
|
+
# never stops the next. No environment reads; the hook script passes the
|
|
8
|
+
# store, today's day id, and the spawner in.
|
|
9
|
+
|
|
10
|
+
require "fileutils"
|
|
11
|
+
require_relative "session_ledger"
|
|
12
|
+
require_relative "handoff"
|
|
13
|
+
|
|
14
|
+
module SessionClose
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
NOOP_REASONS = %w[clear resume].freeze
|
|
18
|
+
|
|
19
|
+
# The default hand-off writer (intent 311, spec D6): renders this session's
|
|
20
|
+
# share of the pointer day into handoff--<session>.md before the tmp dir
|
|
21
|
+
# goes. The hook script builds it with the shipped templates dir.
|
|
22
|
+
def default_handoff(templates)
|
|
23
|
+
lambda do |store, day, session|
|
|
24
|
+
Handoff.write(store: store, day: day, session: session, trigger: "close", templates: templates)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The default spawner starts the day filer detached so a slow filing never
|
|
29
|
+
# blocks the harness shutdown (Codex kills a SessionEnd hook after 3 s).
|
|
30
|
+
def default_spawner(script)
|
|
31
|
+
lambda do |args|
|
|
32
|
+
pid = Process.spawn({ "RUBYOPT" => nil }, RbConfig.ruby, script, *args,
|
|
33
|
+
pgroup: true, in: File::NULL, out: File::NULL, err: File::NULL)
|
|
34
|
+
Process.detach(pid)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns a small report hash; never raises.
|
|
39
|
+
def run(payload:, store:, today:, spawner:, handoff: nil, now: Time.now)
|
|
40
|
+
report = { reason: nil, dropped: 0, removed_tmp: false, spawned: nil, handoff: false }
|
|
41
|
+
reason = payload.is_a?(Hash) ? payload["reason"].to_s : ""
|
|
42
|
+
report[:reason] = reason
|
|
43
|
+
return report if NOOP_REASONS.include?(reason)
|
|
44
|
+
|
|
45
|
+
session = SessionLedger.short_session_id(payload.is_a?(Hash) ? payload["session_id"] : nil, nil)
|
|
46
|
+
return report if session.empty?
|
|
47
|
+
|
|
48
|
+
pointer_day = safely { read_pointer_day(store, session) } || today
|
|
49
|
+
project = "global"
|
|
50
|
+
|
|
51
|
+
report[:dropped] = safely do
|
|
52
|
+
checklist = SessionLedger.checklist_path(store, pointer_day)
|
|
53
|
+
count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
|
|
54
|
+
if count.positive?
|
|
55
|
+
SessionLedger.append_line(SessionLedger.savepoint_path(store, pointer_day),
|
|
56
|
+
SessionLedger.savepoint_line("Note", session, project,
|
|
57
|
+
"dropped #{count} pending lines at close", now: now))
|
|
58
|
+
end
|
|
59
|
+
count
|
|
60
|
+
end || 0
|
|
61
|
+
|
|
62
|
+
# The hand-off (intent 311, spec D6) is written after the drop, so it
|
|
63
|
+
# reflects it, and before the tmp dir goes, since the pointer lives there.
|
|
64
|
+
# An intent pointer falls back to today, the same as the drop above.
|
|
65
|
+
if handoff
|
|
66
|
+
report[:handoff] = safely do
|
|
67
|
+
handoff.call(store, pointer_day, session)
|
|
68
|
+
true
|
|
69
|
+
end || false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
report[:removed_tmp] = safely do
|
|
73
|
+
dir = SessionLedger.session_tmp_dir(store, session)
|
|
74
|
+
FileUtils.rm_rf(dir) if Dir.exist?(dir)
|
|
75
|
+
true
|
|
76
|
+
end || false
|
|
77
|
+
|
|
78
|
+
if pointer_day < today
|
|
79
|
+
report[:spawned] = safely do
|
|
80
|
+
args = ["--day", pointer_day, "--carry-to", today, "--store", store]
|
|
81
|
+
spawner.call(args)
|
|
82
|
+
args
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
report
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The pointer names the intent this session records into; a day id means
|
|
89
|
+
# the day ledger. Anything else (an intent id) means an auto team owns the
|
|
90
|
+
# record and the close touches no day ledger.
|
|
91
|
+
def read_pointer_day(store, session)
|
|
92
|
+
path = SessionLedger.pointer_path(store, session)
|
|
93
|
+
return nil unless File.exist?(path)
|
|
94
|
+
|
|
95
|
+
value = File.read(path).strip
|
|
96
|
+
SessionLedger.valid_day_id?(value) ? value : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def safely
|
|
100
|
+
yield
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|