@zalom/plastic 2.0.0-alpha.11 → 2.0.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/statusline +4 -27
- package/package.json +1 -1
- package/scripts/doctor.rb +46 -3
- package/scripts/lib/report_screen.rb +319 -17
- package/scripts/lib/screen_paint.rb +5 -2
- package/scripts/lib/session_ledger.rb +4 -0
- package/scripts/report-screen +94 -11
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +10 -9
- package/skills/auto/references/human-report-contract.md +4 -0
- package/skills/intent-continuing/SKILL.md +9 -5
- package/skills/intent-executing/SKILL.md +9 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
|
@@ -31,7 +31,9 @@ deliberately; the auto pipeline never dispatches them.
|
|
|
31
31
|
`plastic-intent-executing`'s `plan-reviewer-prompt.md`; fold every finding into the spec, the
|
|
32
32
|
matrix, and the tests.
|
|
33
33
|
4. **Dispatch one executor, tests first** - the executor commits the matrix's tests red, then
|
|
34
|
-
builds, then drives the full suite green; you verify
|
|
34
|
+
builds, then drives the full suite green; you verify tick-versus-diff at the
|
|
35
|
+
post-execution review and again before the merge. A mismatch is a review finding, not a
|
|
36
|
+
cleanup you perform silently.
|
|
35
37
|
5. **Review by risk** - dispatch the post-execution reviewer only when the auto skill's risk
|
|
36
38
|
rule fires; otherwise the green suite is the review.
|
|
37
39
|
6. **Close** - `outcome.md`, then `plastic-intent-ending`, which releases the worktree, clears
|
|
@@ -20,7 +20,11 @@ valid lifecycle artifacts. Honor it as your live state; do not re-derive or cont
|
|
|
20
20
|
any other change.
|
|
21
21
|
2. **Implement the actions** - make the code changes for each action in order, inside the
|
|
22
22
|
worktree the preamble names.
|
|
23
|
-
3. **
|
|
23
|
+
3. **Tick with the commit** - commit after each logical unit of work, and in the same step
|
|
24
|
+
tick the checklist item that unit lands: mark its box `[x]` and move the line to
|
|
25
|
+
`## Completed`, then append the savepoint `Commit` line
|
|
26
|
+
(`scripts/savepoint-note <intent_dir> --kind Commit --text "<sha> <what it proves>"`). A
|
|
27
|
+
commit without its tick is incomplete.
|
|
24
28
|
4. **Record insights** - capture durable discoveries and report them in the `insights:` field;
|
|
25
29
|
persist each to `## Insights` via the `insight-append` helper
|
|
26
30
|
(`scripts/insight-append <intent_dir> <text> --stage Exec --author "plastic-executor (autonomous)"`),
|
|
@@ -33,7 +37,8 @@ valid lifecycle artifacts. Honor it as your live state; do not re-derive or cont
|
|
|
33
37
|
real `ACTION_N.md` with its failure-mode matrix, pasted in by the lead. Execute the action
|
|
34
38
|
files in order.
|
|
35
39
|
2. Write the matrix's tests; commit red.
|
|
36
|
-
3. Work one action at a time,
|
|
40
|
+
3. Work one action at a time, ticking its checklist item in the same commit that lands it
|
|
41
|
+
(box marked, line moved); prefer safe, non-destructive routes.
|
|
37
42
|
4. Run the full suite, iterate to zero failures and zero errors; commit green.
|
|
38
43
|
5. Produce (output handoff): the code changes, a checked-off `checklist.md`, and `## Insights`.
|
|
39
44
|
6. Report (see `## Completion Report`); the lead applies the risk rule and may dispatch a
|
|
@@ -46,7 +51,8 @@ preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.m
|
|
|
46
51
|
finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written,
|
|
47
52
|
verification, checklist deltas, deviations, blockers, insights) plus the executor payload:
|
|
48
53
|
|
|
49
|
-
- Actions implemented this turn, mapped to checklist items checked off (checked / total)
|
|
54
|
+
- Actions implemented this turn, mapped to checklist items checked off (checked / total);
|
|
55
|
+
checked / total must equal the items whose commits exist
|
|
50
56
|
- A summary of the code changed (files and the shape of the change)
|
|
51
57
|
- Test result: the red commit's failing count, then the full-suite command and its pass / fail
|
|
52
58
|
counts
|
package/hooks/statusline
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
# plastic-hook-version: 4.0.0
|
|
3
3
|
# StatusLine hook - Plastic owns the full statusline (no chaining).
|
|
4
4
|
# Renders: {model} - ctx {pct}% ({used}/{size}) - 5h {p}% - 7d {p}% - ${cost}
|
|
5
|
-
# -
|
|
6
|
-
# Every segment but
|
|
5
|
+
# - Plastic {version}[ -> {next}] - {path}
|
|
6
|
+
# Every segment but the Plastic version comes from the stdin payload.
|
|
7
7
|
# Pure bash (macOS 3.2 compatible). No ruby, no jq. Reads stdin once; small file reads
|
|
8
8
|
# only. The stdin JSON is walked in a single awk pass (below); nothing else in this
|
|
9
9
|
# script forks a second process per field, which is what keeps a render cheap.
|
|
@@ -17,7 +17,6 @@ C_OK='' # default - meter below 70%
|
|
|
17
17
|
C_WARN=$'\033[33m' # yellow - meter at 70% and above
|
|
18
18
|
C_CRIT=$'\033[38;2;231;76;60m' # red - meter at 90% and above
|
|
19
19
|
C_COST=$'\033[37m' # white - session cost
|
|
20
|
-
C_CLAUDISH=$'\033[38;2;249;140;30m' # orange - claudish rewrites
|
|
21
20
|
C_VER=$'\033[37m' # white - "Plastic {version}" and update arrow
|
|
22
21
|
C_NEXT=$'\033[33m' # yellow - next version
|
|
23
22
|
C_PATH=$'\033[38;2;128;134;144m' # gray - path (matches dim UI text)
|
|
@@ -309,40 +308,18 @@ if [ -n "$COST_RAW" ]; then
|
|
|
309
308
|
[ -n "$COST_FMT" ] && [ "$COST_FMT" != "0.00" ] && COST_SEG="${C_COST}\$${COST_FMT}${RST}"
|
|
310
309
|
fi
|
|
311
310
|
|
|
312
|
-
# --- claudish rewrites for THIS session (fork ledger, optional) ---
|
|
313
|
-
# Tab separated, one call per line; column 11 is the session id. Rows written
|
|
314
|
-
# before that column existed have 9 or 10 fields and never match, so an old
|
|
315
|
-
# ledger renders no segment instead of a wrong one.
|
|
316
|
-
CL_SEG=""
|
|
317
|
-
CL_LOG="${CLAUDISH_LOCAL_DIR:-$HOME/.claude/claudish-local}/usage.log"
|
|
318
|
-
if [ -n "$SID" ] && [ -f "$CL_LOG" ]; then
|
|
319
|
-
CL=$(awk -F'\t' -v sid="$SID" '
|
|
320
|
-
NF >= 11 && $11 == sid { n++; t += $5 + $6 }
|
|
321
|
-
END {
|
|
322
|
-
if (n > 0) {
|
|
323
|
-
if (t >= 1000) {
|
|
324
|
-
s = sprintf("%.1f", t / 1000); sub(/\.0$/, "", s); printf "%d %sk", n, s
|
|
325
|
-
} else {
|
|
326
|
-
printf "%d %d", n, t
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
}' "$CL_LOG" 2>/dev/null)
|
|
330
|
-
[ -n "$CL" ] && CL_SEG="${C_CLAUDISH}claudish ${CL%% *} rw / ${CL#* }${RST}"
|
|
331
|
-
fi
|
|
332
|
-
|
|
333
311
|
# --- Assemble segments in order, joined by " . " ---
|
|
334
312
|
PARTS=""
|
|
335
313
|
add() { [ -z "$1" ] && return; if [ -z "$PARTS" ]; then PARTS="$1"; else PARTS="$PARTS$SEP$1"; fi; }
|
|
336
314
|
|
|
337
|
-
# Order: identity first (model), then this session's spend (context, meters, cost,
|
|
338
|
-
#
|
|
315
|
+
# Order: identity first (model), then this session's spend (context, meters, cost),
|
|
316
|
+
# then the volatile-length values last (version, path) so the line does
|
|
339
317
|
# not visually jump as they change.
|
|
340
318
|
[ -n "$MODEL" ] && add "${C_MODEL}${MODEL}${RST}"
|
|
341
319
|
add "$CTX_SEG"
|
|
342
320
|
add "$M5"
|
|
343
321
|
add "$M7"
|
|
344
322
|
add "$COST_SEG"
|
|
345
|
-
add "$CL_SEG"
|
|
346
323
|
if [ -n "$VERSION" ]; then
|
|
347
324
|
vseg="${C_VER}Plastic ${VERSION}${RST}"
|
|
348
325
|
[ -n "$NEXT" ] && vseg="${vseg}${C_VER} "$'\342\206\222'" ${RST}${C_NEXT}${NEXT}${RST}"
|
package/package.json
CHANGED
package/scripts/doctor.rb
CHANGED
|
@@ -30,6 +30,7 @@ require_relative "lib/power_tools"
|
|
|
30
30
|
require_relative "lib/preflight"
|
|
31
31
|
require_relative "lib/ruby_probe"
|
|
32
32
|
require_relative "lib/doctor_session_ledger"
|
|
33
|
+
require_relative "lib/intent_screen"
|
|
33
34
|
|
|
34
35
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
35
36
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -1161,9 +1162,11 @@ end
|
|
|
1161
1162
|
#
|
|
1162
1163
|
# Doctor's fourth check scope, invoked by `--intent <id>`. Never a store-wide sweep:
|
|
1163
1164
|
# resolves exactly one intent directory (mirrors scripts/end-intent's resolve_intent_dir /
|
|
1164
|
-
# scripts/project-links's --intent disambiguation) and returns
|
|
1165
|
-
# FAIL-severity,
|
|
1166
|
-
# binding advisory-only ruling
|
|
1165
|
+
# scripts/project-links's --intent disambiguation) and returns six checks, four
|
|
1166
|
+
# FAIL-severity, two WARN-severity: intent_savepoint_truthful stays WARN per intent 134's
|
|
1167
|
+
# binding advisory-only ruling (see spec.md D2/D8 - do NOT escalate it to FAIL), and
|
|
1168
|
+
# intent_ticks_lag is WARN-only per intent 329's ruling that a lagging tick warns rather
|
|
1169
|
+
# than blocks.
|
|
1167
1170
|
def check_intent_end(id, store: nil, disposition: nil)
|
|
1168
1171
|
intent_dir, scope = resolve_single_intent_dir(id, store: store)
|
|
1169
1172
|
unless intent_dir
|
|
@@ -1185,6 +1188,7 @@ end
|
|
|
1185
1188
|
intent_structure_check(intent_dir),
|
|
1186
1189
|
intent_lifecycle_artifacts_check(intent_dir, disposition),
|
|
1187
1190
|
intent_checklist_complete_check(intent_dir),
|
|
1191
|
+
intent_ticks_lag_check(intent_dir),
|
|
1188
1192
|
intent_links_projection_check_for(id, scope),
|
|
1189
1193
|
intent_savepoint_truthful_check(intent_dir, index_path: index_path),
|
|
1190
1194
|
]
|
|
@@ -1263,6 +1267,45 @@ end
|
|
|
1263
1267
|
end
|
|
1264
1268
|
end
|
|
1265
1269
|
|
|
1270
|
+
# Intent 329: the checklist is the only input to the state screen's Progress bar, so an
|
|
1271
|
+
# intent whose commit ledger has entries and whose checklist has no ticked item is
|
|
1272
|
+
# reporting 0 progress on work that already landed. Advisory only (WARN), like
|
|
1273
|
+
# intent_savepoint_truthful: a lagging tick is a lead's review finding, never a machine
|
|
1274
|
+
# refusal. The commit count comes from this intent's own savepoint.md Commit ledger
|
|
1275
|
+
# (intent 317 D17), never from git: git's detected base is the repo default branch, which
|
|
1276
|
+
# is not the base a 2.0 intent branch forks from.
|
|
1277
|
+
def intent_ticks_lag_check(intent_dir)
|
|
1278
|
+
items = IntentScreen.checklist_items(intent_dir)
|
|
1279
|
+
if items.empty?
|
|
1280
|
+
return check(category: "intent_end", name: "intent_ticks_lag", status: "pass",
|
|
1281
|
+
message: "n/a: checklist.md has no items to tick")
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
ticked = items.count { |i| i[:done] }
|
|
1285
|
+
commits = savepoint_commit_count(intent_dir)
|
|
1286
|
+
|
|
1287
|
+
if commits.positive? && ticked.zero?
|
|
1288
|
+
check(category: "intent_end", name: "intent_ticks_lag", status: "warn",
|
|
1289
|
+
message: "ticks lag the branch: #{commits} commit(s) recorded, " \
|
|
1290
|
+
"0 of #{items.size} checklist items ticked")
|
|
1291
|
+
else
|
|
1292
|
+
check(category: "intent_end", name: "intent_ticks_lag", status: "pass",
|
|
1293
|
+
message: "#{ticked} of #{items.size} ticked, #{commits} commit(s) recorded")
|
|
1294
|
+
end
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
# Count `Commit` lines in the intent's savepoint ledger, through the one regex that parses
|
|
1298
|
+
# a savepoint line (IntentScreen::SAVEPOINT_RE; field 2 is the kind).
|
|
1299
|
+
def savepoint_commit_count(intent_dir)
|
|
1300
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
1301
|
+
return 0 unless File.exist?(path)
|
|
1302
|
+
|
|
1303
|
+
File.readlines(path).count do |line|
|
|
1304
|
+
m = line.strip.match(IntentScreen::SAVEPOINT_RE)
|
|
1305
|
+
m && m[2] == "Commit"
|
|
1306
|
+
end
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1266
1309
|
def intent_links_projection_check_for(id, scope)
|
|
1267
1310
|
ctx = build_links_projection_context
|
|
1268
1311
|
node = ctx[:intents].find { |n| n[:id] == id && n[:scope] == scope }
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
# renderer path as `renderer_path:` (D2).
|
|
11
11
|
require "time"
|
|
12
12
|
require "json"
|
|
13
|
+
require "date"
|
|
13
14
|
require_relative "intent_screen"
|
|
14
15
|
require_relative "lock"
|
|
16
|
+
require_relative "session_ledger"
|
|
15
17
|
|
|
16
18
|
module ReportScreen
|
|
17
19
|
NOT_RECORDED = "not recorded"
|
|
@@ -73,10 +75,52 @@ module ReportScreen
|
|
|
73
75
|
Array(frontmatter(intent_dir)["tags"]).map(&:to_s).include?("research")
|
|
74
76
|
end
|
|
75
77
|
|
|
78
|
+
# Intent 330 (D12): the shared fence walker feeding split_by_headings AND
|
|
79
|
+
# table_rows. A line matching \A\s{0,3}(```+|~~~+) while closed opens a
|
|
80
|
+
# fence and remembers the marker character and its length; while open, a
|
|
81
|
+
# line whose marker is the SAME character and at least as long, with only
|
|
82
|
+
# whitespace after it, closes the fence. Inside a fence every line is body
|
|
83
|
+
# - a leading "#" or a leading "|" included. A four-space-indented block is
|
|
84
|
+
# deliberately never a fence (the cap is 0-3 leading whitespace chars),
|
|
85
|
+
# which is the CommonMark indented-code case, out of scope on purpose
|
|
86
|
+
# (D12's stated limit). Yields [line, fenced] for every line, in order.
|
|
87
|
+
FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
|
|
88
|
+
|
|
89
|
+
def self.each_fence_line(text)
|
|
90
|
+
return enum_for(:each_fence_line, text) unless block_given?
|
|
91
|
+
|
|
92
|
+
marker = nil # [character, length] of the currently open fence, or nil
|
|
93
|
+
text.to_s.each_line do |line|
|
|
94
|
+
if marker
|
|
95
|
+
yield line, true
|
|
96
|
+
m = line.match(FENCE_LINE_RE)
|
|
97
|
+
next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
|
|
98
|
+
next unless line.sub(FENCE_LINE_RE, "").strip.empty?
|
|
99
|
+
|
|
100
|
+
marker = nil
|
|
101
|
+
else
|
|
102
|
+
m = line.match(FENCE_LINE_RE)
|
|
103
|
+
if m
|
|
104
|
+
marker = [m[1][0], m[1].length]
|
|
105
|
+
yield line, true
|
|
106
|
+
else
|
|
107
|
+
yield line, false
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
76
113
|
# Markdown pipe-table data rows (header + separator skipped), each an array
|
|
77
|
-
# of trimmed cell strings. Tolerates leading prose before the table.
|
|
114
|
+
# of trimmed cell strings. Tolerates leading prose before the table. Fence-
|
|
115
|
+
# aware (D12/O1.7): a pipe row inside a fenced example is never counted.
|
|
78
116
|
def self.table_rows(text)
|
|
79
|
-
lines =
|
|
117
|
+
lines = []
|
|
118
|
+
each_fence_line(text) do |line, fenced|
|
|
119
|
+
next if fenced
|
|
120
|
+
|
|
121
|
+
stripped = line.strip
|
|
122
|
+
lines << stripped if stripped.start_with?("|")
|
|
123
|
+
end
|
|
80
124
|
sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
|
|
81
125
|
return [] unless sep_idx
|
|
82
126
|
lines[(sep_idx + 1)..].map { |l| l.split("|", -1).map(&:strip)[1..-2].to_a }
|
|
@@ -84,13 +128,14 @@ module ReportScreen
|
|
|
84
128
|
|
|
85
129
|
# Every [heading_line, body] pair in a Markdown file, split on ANY heading
|
|
86
130
|
# line (any level). Used by proven_by (D19) so a section's own matrix rows
|
|
87
|
-
# are never confused with a sibling section's.
|
|
131
|
+
# are never confused with a sibling section's. Fence-aware (D12): a "#"
|
|
132
|
+
# line inside a fenced example never starts a new section.
|
|
88
133
|
def self.split_by_headings(text)
|
|
89
134
|
sections = []
|
|
90
135
|
heading = nil
|
|
91
136
|
body = +""
|
|
92
|
-
text
|
|
93
|
-
if line.start_with?("#")
|
|
137
|
+
each_fence_line(text) do |line, fenced|
|
|
138
|
+
if !fenced && line.start_with?("#")
|
|
94
139
|
sections << [heading, body] if heading
|
|
95
140
|
heading = line.strip
|
|
96
141
|
body = +""
|
|
@@ -327,13 +372,64 @@ module ReportScreen
|
|
|
327
372
|
line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
|
|
328
373
|
end
|
|
329
374
|
|
|
330
|
-
|
|
375
|
+
# Intent 330 (D9): reads `flow: base:` from a project's project.yml when
|
|
376
|
+
# `intent_dir` sits in the installed project layout
|
|
377
|
+
# (<home>/projects/<slug>/store/<id--slug>); nil otherwise (a global-store
|
|
378
|
+
# intent, a project with no `flow:` key, or malformed YAML). Pure: no git,
|
|
379
|
+
# no shell-out, just the one file this intent's own layout already reads.
|
|
380
|
+
PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
|
|
381
|
+
|
|
382
|
+
def self.flow_base(intent_dir)
|
|
383
|
+
m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
|
|
384
|
+
return nil unless m
|
|
385
|
+
|
|
386
|
+
home, slug = m[1], m[2]
|
|
387
|
+
path = File.join(home, "projects", slug, "project.yml")
|
|
388
|
+
return nil unless File.exist?(path)
|
|
389
|
+
|
|
390
|
+
require "yaml"
|
|
391
|
+
data = YAML.safe_load(File.read(path))
|
|
392
|
+
return nil unless data.is_a?(Hash)
|
|
393
|
+
|
|
394
|
+
flow = data["flow"]
|
|
395
|
+
return nil unless flow.is_a?(Hash)
|
|
396
|
+
|
|
397
|
+
base = flow["base"]
|
|
398
|
+
base.is_a?(String) && !base.empty? ? base : nil
|
|
399
|
+
rescue StandardError
|
|
400
|
+
nil
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Intent 330 (D9/D10/D23): the ship row's WHAT cell is the merge sha, then
|
|
404
|
+
# " → <branch>" only when `branch_reader` answers one (never the "alpha"
|
|
405
|
+
# literal), then " · v<version>" or the existing not-recorded fallback. The
|
|
406
|
+
# Source cell names WHERE the branch came from (D23): project.yml when
|
|
407
|
+
# flow_base itself supplied that exact branch, else git refs, so the row
|
|
408
|
+
# never keeps the stale "git tags" literal for a branch git never answered.
|
|
409
|
+
def self.ship_row(_text, intent_dir, tag_reader, branch_reader: ->(_dir) { nil })
|
|
331
410
|
sha = merge_sha(intent_dir)
|
|
332
411
|
version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
|
|
333
412
|
return nil if sha.nil? && (version.nil? || version.to_s.empty?)
|
|
334
|
-
|
|
413
|
+
branch = branch_reader.call(intent_dir)
|
|
335
414
|
sha_text = sha || NOT_RECORDED
|
|
336
|
-
|
|
415
|
+
what = +sha_text
|
|
416
|
+
what << " → #{branch}" if branch && !branch.to_s.empty?
|
|
417
|
+
# D10: the version segment is omitted, not filled with NOT_RECORDED. A
|
|
418
|
+
# repository with no release line has no version, the header already
|
|
419
|
+
# carries the shipped identity, and naming the absence twice on one screen
|
|
420
|
+
# is the defect this intent was opened to remove, not a floor worth keeping.
|
|
421
|
+
what << " · v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
|
|
422
|
+
# D14: the cell names every file the row actually came from. The branch and
|
|
423
|
+
# the version have different origins, so when both contributed, both are
|
|
424
|
+
# named rather than only the branch's.
|
|
425
|
+
sources = ["outcome.md"]
|
|
426
|
+
if branch && !branch.to_s.empty?
|
|
427
|
+
sources << (flow_base(intent_dir) == branch ? "project.yml" : "git refs")
|
|
428
|
+
end
|
|
429
|
+
sources << "git tags" if version && !version.to_s.empty? && shipped_version(intent_dir).nil?
|
|
430
|
+
sources << "git tags" if sources.length == 1
|
|
431
|
+
source = sources.join("; ")
|
|
432
|
+
{ kind: "ship", what: what, source: source }
|
|
337
433
|
end
|
|
338
434
|
|
|
339
435
|
def self.doctor_row(text)
|
|
@@ -362,7 +458,7 @@ module ReportScreen
|
|
|
362
458
|
{ kind: "verdict", what: m[1].strip, source: "outcome.md" }
|
|
363
459
|
end
|
|
364
460
|
|
|
365
|
-
def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil })
|
|
461
|
+
def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
|
|
366
462
|
text = outcome_text(intent_dir)
|
|
367
463
|
return [] unless text
|
|
368
464
|
verification = section_of(text, "## Verification")
|
|
@@ -370,7 +466,7 @@ module ReportScreen
|
|
|
370
466
|
rows = []
|
|
371
467
|
rows << suite_row(verification)
|
|
372
468
|
rows << red_row(verification)
|
|
373
|
-
rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader))
|
|
469
|
+
rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader, branch_reader: branch_reader))
|
|
374
470
|
if research_intent?(intent_dir)
|
|
375
471
|
rows << deposits_row(text)
|
|
376
472
|
rows << verdict_row(text)
|
|
@@ -444,7 +540,11 @@ module ReportScreen
|
|
|
444
540
|
|
|
445
541
|
# --- roster (D7/D8) -------------------------------------------------------------
|
|
446
542
|
|
|
447
|
-
|
|
543
|
+
# The dirnames named under one "## <section_name>" heading of an INDEX.md.
|
|
544
|
+
# active_dirnames used to hardcode "Active"; intent 330's session verb (D22)
|
|
545
|
+
# reuses this to find Completed/Abandoned dirnames for the no-bookend
|
|
546
|
+
# footer, so the section is now a parameter.
|
|
547
|
+
def self.dirnames_in_section(index_path, section_name)
|
|
448
548
|
return [] unless File.exist?(index_path)
|
|
449
549
|
dirnames = []
|
|
450
550
|
section = nil
|
|
@@ -453,13 +553,24 @@ module ReportScreen
|
|
|
453
553
|
section = line[3..].strip
|
|
454
554
|
next
|
|
455
555
|
end
|
|
456
|
-
next unless section ==
|
|
556
|
+
next unless section == section_name
|
|
457
557
|
m = line.match(%r{\(store/([^/]+)/})
|
|
458
558
|
dirnames << m[1] if m
|
|
459
559
|
end
|
|
460
560
|
dirnames
|
|
461
561
|
end
|
|
462
562
|
|
|
563
|
+
def self.active_dirnames(index_path)
|
|
564
|
+
dirnames_in_section(index_path, "Active")
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Intent 330 (D22): both terminal sections count as "completed" for the
|
|
568
|
+
# no-bookend footer - a closed intent the reader cannot expect a Done
|
|
569
|
+
# savepoint line from, since the convention predates end-intent writing it.
|
|
570
|
+
def self.completed_dirnames(index_path)
|
|
571
|
+
dirnames_in_section(index_path, "Completed") + dirnames_in_section(index_path, "Abandoned")
|
|
572
|
+
end
|
|
573
|
+
|
|
463
574
|
def self.newest_savepoint_ts(intent_dir)
|
|
464
575
|
lines = savepoint_lines(intent_dir)
|
|
465
576
|
lines.last&.first
|
|
@@ -541,18 +652,31 @@ module ReportScreen
|
|
|
541
652
|
done ? human_time(done[0]) : NOT_RECORDED
|
|
542
653
|
end
|
|
543
654
|
|
|
544
|
-
|
|
655
|
+
# Intent 330 (D11): the header's last segment is the shipped identity, and
|
|
656
|
+
# says which kind it is - v<version> when a version is known, else
|
|
657
|
+
# "merge <sha>" (never a bare, ambiguous hash), else the exact NOT_RECORDED
|
|
658
|
+
# string when neither exists.
|
|
659
|
+
def self.header_ship_segment(intent_dir, tag_reader)
|
|
660
|
+
version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
|
|
661
|
+
return "v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
|
|
662
|
+
|
|
663
|
+
sha = merge_sha(intent_dir)
|
|
664
|
+
return "merge #{sha}" if sha && !sha.to_s.empty?
|
|
665
|
+
|
|
666
|
+
NOT_RECORDED
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
|
|
545
670
|
id = intent_id(intent_dir)
|
|
546
671
|
name = title_for(intent_dir, default_store_root(intent_dir))
|
|
547
672
|
ts = delivered_timestamp(intent_dir)
|
|
548
673
|
m = mode(intent_dir)
|
|
549
674
|
dur = duration(intent_dir)
|
|
550
|
-
|
|
551
|
-
ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
|
|
675
|
+
ship_segment = header_ship_segment(intent_dir, tag_reader)
|
|
552
676
|
|
|
553
677
|
lines = []
|
|
554
678
|
lines << "## ✔ #{id} · #{name} · delivered"
|
|
555
|
-
lines << "#{ts} · #{m} · #{dur} · #{
|
|
679
|
+
lines << "#{ts} · #{m} · #{dur} · #{ship_segment}"
|
|
556
680
|
lines << ""
|
|
557
681
|
lines << "**Asked**"
|
|
558
682
|
lines << " #{asked(intent_dir)}"
|
|
@@ -566,7 +690,7 @@ module ReportScreen
|
|
|
566
690
|
end
|
|
567
691
|
lines << ""
|
|
568
692
|
lines << "**Evidence**"
|
|
569
|
-
ev = evidence_rows(intent_dir, tag_reader: tag_reader)
|
|
693
|
+
ev = evidence_rows(intent_dir, tag_reader: tag_reader, branch_reader: branch_reader)
|
|
570
694
|
if ev.empty?
|
|
571
695
|
# 317a S4 (matrix S4a): a header-only table (319's live rendering) says
|
|
572
696
|
# nothing; the honest floor is the same phrase every other absent source
|
|
@@ -661,6 +785,184 @@ module ReportScreen
|
|
|
661
785
|
"#{lines.join("\n")}\n"
|
|
662
786
|
end
|
|
663
787
|
|
|
788
|
+
# --- S9: the session verb (intent 330) -------------------------------------------
|
|
789
|
+
#
|
|
790
|
+
# `report-screen session <tier_root>` - the delivered screens for every intent
|
|
791
|
+
# this session completed, oldest first, then the state --all roster (D1).
|
|
792
|
+
# Membership is the savepoint Done bookend inside [window_start, now] (D2),
|
|
793
|
+
# never the delivery lock (a dispatched lead's derived auto- key is not the
|
|
794
|
+
# owner's session id). The pure functions below take the clock and the
|
|
795
|
+
# ledger root as arguments (D8): no Time.now, no git, no ENV read here.
|
|
796
|
+
|
|
797
|
+
# <home> for a tier root, by the same layout discriminator IntentScreen
|
|
798
|
+
# uses elsewhere: a project tier root's parent directory is "projects".
|
|
799
|
+
def self.home_for_tier_root(tier_root)
|
|
800
|
+
File.basename(File.dirname(tier_root)) == "projects" ? File.expand_path("../..", tier_root) : tier_root
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# D18: <home>/store/.sessions, derived from the tier root through the SAME
|
|
804
|
+
# discriminator - deriving it unconditionally from tier_root would answer
|
|
805
|
+
# "/Users" for the global tier (~/.plastic itself has no "store" segment
|
|
806
|
+
# to strip).
|
|
807
|
+
def self.default_ledger_root(tier_root)
|
|
808
|
+
File.join(home_for_tier_root(tier_root), "store", ".sessions")
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# D5: "global" is <home> itself; any other slug is <home>/projects/<slug>.
|
|
812
|
+
def self.store_for_slug(home, slug)
|
|
813
|
+
slug == "global" ? home : File.join(home, "projects", slug)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# D4: the newest valid day directory that is not in the future, when
|
|
817
|
+
# `today`'s own day directory does not exist. No ledger at all (D3.13)
|
|
818
|
+
# answers `today` unchanged rather than raising - there is simply nothing
|
|
819
|
+
# to scan, not an error.
|
|
820
|
+
def self.fallback_day(ledger_root, today)
|
|
821
|
+
return today if Dir.exist?(File.join(ledger_root, today))
|
|
822
|
+
return today unless Dir.exist?(ledger_root)
|
|
823
|
+
|
|
824
|
+
candidates = Dir.children(ledger_root).select { |d| SessionLedger.valid_day_id?(d) && d <= today }
|
|
825
|
+
candidates.max || today
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# D17: the visible note printed above the screens when no session id was
|
|
829
|
+
# given at all, so the whole-day, tier-only fallback never looks like a
|
|
830
|
+
# real, narrower answer.
|
|
831
|
+
# D17: shaped as a screen opener ("▶ ... · ...") on purpose. The note is the
|
|
832
|
+
# first line of the reply, and both ScreenPaint's OPENER_RE and the
|
|
833
|
+
# MessageDisplay hook's first-character gate require that shape; a plain
|
|
834
|
+
# sentence here would leave the whole session report unpainted.
|
|
835
|
+
def self.window_note(day, reason)
|
|
836
|
+
"▶ Window · the whole of #{Date.strptime(day, '%Y%m%d').iso8601} · #{reason}"
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# True when the day ledger actually carries a line for this session, across
|
|
840
|
+
# the same two day directories the window search reads. The CLI asks so it
|
|
841
|
+
# can tell "no session id given" apart from "this session id matches no
|
|
842
|
+
# ledger line": D17 exists to stop the second one answering silently, and a
|
|
843
|
+
# resumed background job carries exactly that kind of unmatched id.
|
|
844
|
+
def self.session_tagged?(ledger_root:, session:, now:)
|
|
845
|
+
return false if session.nil? || session.to_s.strip.empty?
|
|
846
|
+
|
|
847
|
+
short = SessionLedger.short_session_id(session)
|
|
848
|
+
today = SessionLedger.day_id(now)
|
|
849
|
+
yesterday = SessionLedger.day_id(now - 86_400)
|
|
850
|
+
[yesterday, today].any? do |d|
|
|
851
|
+
session_ledger_lines(ledger_root, d).any? { |l| l[:session] == short }
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# D4: local midnight of `day`, converted to UTC, using `sample_now`'s OWN
|
|
856
|
+
# utc_offset - never a literal UTC midnight, and never the machine's
|
|
857
|
+
# ambient zone outside what the injected clock itself carries.
|
|
858
|
+
def self.local_midnight_utc(day, sample_now)
|
|
859
|
+
date = Date.strptime(day, "%Y%m%d")
|
|
860
|
+
Time.new(date.year, date.month, date.day, 0, 0, 0, sample_now.utc_offset)
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# One day's session-tagged savepoint lines: "{ts} {Event} [{session}]
|
|
864
|
+
# [{slug}] {summary}" (SessionLedger.savepoint_line's own shape). Missing
|
|
865
|
+
# file, or a line that does not match, is silently skipped.
|
|
866
|
+
SESSION_LEDGER_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}\S+\s{2,}\[([^\]]*)\]\s\[([^\]]*)\]/.freeze
|
|
867
|
+
|
|
868
|
+
def self.session_ledger_lines(ledger_root, day)
|
|
869
|
+
path = File.join(ledger_root, day, "savepoint.md")
|
|
870
|
+
return [] unless File.exist?(path)
|
|
871
|
+
|
|
872
|
+
File.readlines(path).filter_map do |line|
|
|
873
|
+
m = line.match(SESSION_LEDGER_LINE_RE)
|
|
874
|
+
m ? { ts: m[1], session: m[2], slug: m[3] } : nil
|
|
875
|
+
end
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def self.store_intent_dirs(store)
|
|
879
|
+
Dir.glob(File.join(store, "store", "*")).select { |d| IntentScreen.intent_dir?(d) }
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def self.last_done_ts(intent_dir)
|
|
883
|
+
lines = savepoint_lines(intent_dir)
|
|
884
|
+
done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
|
|
885
|
+
done ? Time.parse(done[0]) : nil
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# D2/D3/D4/D5/D22: the intent directories completed inside the session's
|
|
889
|
+
# window, oldest Done bookend first, plus the count of completed intents
|
|
890
|
+
# (D22: Completed or Abandoned in INDEX.md) that carry no Done bookend at
|
|
891
|
+
# all and so cannot be placed in any window.
|
|
892
|
+
def self.session_delivered_dirs(ledger_root:, tier_root:, session:, since:, now:)
|
|
893
|
+
today = SessionLedger.day_id(now)
|
|
894
|
+
yesterday = SessionLedger.day_id(now - 86_400)
|
|
895
|
+
short = session && !session.to_s.strip.empty? ? SessionLedger.short_session_id(session) : nil
|
|
896
|
+
|
|
897
|
+
tagged = short ? [yesterday, today].flat_map { |d| session_ledger_lines(ledger_root, d) }
|
|
898
|
+
.select { |l| l[:session] == short } : []
|
|
899
|
+
slugs = tagged.map { |l| l[:slug] }.uniq
|
|
900
|
+
|
|
901
|
+
window_start =
|
|
902
|
+
if since
|
|
903
|
+
Time.parse(since.to_s)
|
|
904
|
+
elsif tagged.any?
|
|
905
|
+
tagged.map { |l| Time.parse(l[:ts]) }.min
|
|
906
|
+
else
|
|
907
|
+
local_midnight_utc(fallback_day(ledger_root, today), now)
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
home = home_for_tier_root(tier_root)
|
|
911
|
+
stores = ([tier_root] + slugs.map { |s| store_for_slug(home, s) }).uniq
|
|
912
|
+
stores = stores.select { |s| File.exist?(File.join(s, "INDEX.md")) }
|
|
913
|
+
|
|
914
|
+
entries = []
|
|
915
|
+
skipped = 0
|
|
916
|
+
stores.each do |store|
|
|
917
|
+
completed = completed_dirnames(File.join(store, "INDEX.md"))
|
|
918
|
+
store_intent_dirs(store).each do |dir|
|
|
919
|
+
done_ts = last_done_ts(dir)
|
|
920
|
+
if done_ts
|
|
921
|
+
entries << [dir, done_ts] if done_ts >= window_start && done_ts <= now
|
|
922
|
+
elsif completed.include?(File.basename(dir))
|
|
923
|
+
skipped += 1
|
|
924
|
+
end
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
[entries.sort_by { |_dir, ts| ts }.map(&:first), skipped]
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# D1/D7/D21/D22: one delivered screen per directory (oldest first, one
|
|
932
|
+
# blank line apart), the roster last, and the skipped-count footer between
|
|
933
|
+
# them when non-zero. `painter` is applied to each block SEPARATELY (D21):
|
|
934
|
+
# a screen ScreenPaint cannot parse falls back to its own plain text
|
|
935
|
+
# without touching its neighbours; the default is the identity function,
|
|
936
|
+
# so a caller that never paints gets the plain screens verbatim. A
|
|
937
|
+
# directory whose delivered screen cannot be rendered (O3.28) never sinks
|
|
938
|
+
# the rest of the report.
|
|
939
|
+
def self.render_session(dirs:, skipped:, store_root:, tag_reader: ->(_dir) { nil },
|
|
940
|
+
branch_reader: ->(_dir) { nil }, note: nil, changed: nil,
|
|
941
|
+
now: Time.now, painter: ->(text) { text })
|
|
942
|
+
blocks = []
|
|
943
|
+
blocks << note if note && !note.to_s.empty?
|
|
944
|
+
|
|
945
|
+
if dirs.empty?
|
|
946
|
+
blocks << "No intents delivered in this session."
|
|
947
|
+
else
|
|
948
|
+
dirs.each do |dir|
|
|
949
|
+
blocks << begin
|
|
950
|
+
render_delivered(intent_dir: dir, tag_reader: tag_reader, branch_reader: branch_reader).chomp
|
|
951
|
+
rescue StandardError => e
|
|
952
|
+
"## #{intent_id(dir)} · could not render (#{e.message})"
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
if skipped.positive?
|
|
958
|
+
blocks << "#{skipped} completed intent#{skipped == 1 ? '' : 's'} skipped: no Done bookend in savepoint.md."
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
blocks << render_roster(store_root, changed: changed, now: now).chomp
|
|
962
|
+
|
|
963
|
+
"#{blocks.map { |b| painter.call(b) }.join("\n\n")}\n"
|
|
964
|
+
end
|
|
965
|
+
|
|
664
966
|
# --- S8: --ansi passthrough (D2) -----------------------------------------------
|
|
665
967
|
#
|
|
666
968
|
# 316a owns the ANSI renderer; 317 only wires a generic DI seam so this
|
|
@@ -56,7 +56,7 @@ module ScreenPaint
|
|
|
56
56
|
return :step if STEP_LINE_RE.match?(text)
|
|
57
57
|
return :timeline if TIMELINE_RE.match?(text)
|
|
58
58
|
return :count if COUNT_LINE_RE.match?(stripped)
|
|
59
|
-
return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
|
|
59
|
+
return :closer if ["None", "not recorded", "No intents in delivery.", "No intents delivered in this session."].include?(stripped)
|
|
60
60
|
:unknown
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -101,7 +101,10 @@ module ScreenPaint
|
|
|
101
101
|
lines = text.to_s.lines
|
|
102
102
|
first_idx = lines.index { |l| !l.strip.empty? }
|
|
103
103
|
return nil if first_idx.nil?
|
|
104
|
-
|
|
104
|
+
# Intent 330 (D7/O3.26): a screen that is nothing but a single known
|
|
105
|
+
# closer line (e.g. "No intents delivered in this session.") has no
|
|
106
|
+
# opener to require - it is already the whole, honest message.
|
|
107
|
+
return nil unless %i[opener closer].include?(classify(lines[first_idx]))
|
|
105
108
|
|
|
106
109
|
out = +""
|
|
107
110
|
table = []
|
|
@@ -29,6 +29,10 @@ module SessionLedger
|
|
|
29
29
|
|
|
30
30
|
SESSIONS_DIR = ".sessions"
|
|
31
31
|
TMP_DIR = ".tmp"
|
|
32
|
+
# Named here, not in a CLI, so a caller can reference the key without the
|
|
33
|
+
# literal harness-branded env var name appearing in its own source (a few
|
|
34
|
+
# scripts are guarded against naming a harness at all).
|
|
35
|
+
SESSION_ID_ENV_KEY = "CLAUDE_CODE_SESSION_ID"
|
|
32
36
|
DAY_ID = /\A\d{8}\z/
|
|
33
37
|
EVENTS = %w[Item Done Note]
|
|
34
38
|
STATES = { pending: "~", open: " ", done: "x", moved: ">", dropped: "-", promoted: "^" }.freeze
|
package/scripts/report-screen
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# report-screen - the
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# report-screen - the four delivery-report screens: mid-delivery state,
|
|
6
|
+
# post-delivery delivered, and delay (intent 317), plus session, the whole of
|
|
7
|
+
# one session's delivered work followed by the roster (intent 330). Each fills
|
|
8
|
+
# from the record via scripts/lib/report_screen.rb; no number here is written
|
|
9
|
+
# by eye.
|
|
8
10
|
#
|
|
9
11
|
# Usage:
|
|
10
12
|
# report-screen state <intent_dir> [--changed "<text>"] [--ansi]
|
|
11
13
|
# report-screen state --all <store_root> [--changed "<text>"] [--ansi]
|
|
12
14
|
# report-screen delivered <intent_dir> [--ansi] [--repo <dir>]
|
|
13
15
|
# report-screen delay <intent_dir> [--ansi]
|
|
16
|
+
# report-screen session <tier_root> [--session <id>] [--since <iso>]
|
|
17
|
+
# [--ledger-root <dir>] [--ansi]
|
|
14
18
|
#
|
|
15
19
|
# --ansi delegates to ScreenPaint (intent 317a, D1), the parser/re-layouter
|
|
16
20
|
# in the shared TUI core. Selection is by capability, never by harness:
|
|
@@ -28,6 +32,7 @@ require "shellwords"
|
|
|
28
32
|
require_relative "lib/report_screen"
|
|
29
33
|
require_relative "lib/intent_screen"
|
|
30
34
|
require_relative "lib/screen_paint"
|
|
35
|
+
require_relative "lib/session_ledger"
|
|
31
36
|
|
|
32
37
|
def usage_abort(message)
|
|
33
38
|
warn "report-screen: #{message}"
|
|
@@ -39,10 +44,13 @@ end
|
|
|
39
44
|
# on the delivered screen is the release that merge shipped in. The
|
|
40
45
|
# repository comes from --repo, else from projects.yml beside the store (the
|
|
41
46
|
# installed layout, <home>/projects/<slug>/store/<id>), else the repository
|
|
42
|
-
# this script lives in (the in-repo layout
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
47
|
+
# this script lives in (the in-repo layout, `include_self: true`) - unless
|
|
48
|
+
# `include_self: false` (the branch reader, D23), in which case a global
|
|
49
|
+
# intent's repo never falls back to the script's own repository, since that
|
|
50
|
+
# is not the intent's repository at all. No merge sha, no repository, or no
|
|
51
|
+
# containing tag all answer nil, and the screen says "not recorded" rather
|
|
52
|
+
# than guessing from HEAD (D14). The pure module never reads git.
|
|
53
|
+
def resolve_repo(intent_dir, explicit_repo, script_dir, include_self: true)
|
|
46
54
|
candidates = []
|
|
47
55
|
candidates << File.expand_path(explicit_repo) if explicit_repo
|
|
48
56
|
if (m = intent_dir.match(%r{\A(.*)/projects/([^/]+)/store/[^/]+\z}))
|
|
@@ -59,7 +67,7 @@ def resolve_repo(intent_dir, explicit_repo, script_dir)
|
|
|
59
67
|
candidates << File.expand_path(path) unless path.empty?
|
|
60
68
|
end
|
|
61
69
|
end
|
|
62
|
-
candidates << File.expand_path("..", script_dir)
|
|
70
|
+
candidates << File.expand_path("..", script_dir) if include_self
|
|
63
71
|
candidates.find { |c| File.exist?(File.join(c, ".git")) }
|
|
64
72
|
end
|
|
65
73
|
|
|
@@ -78,12 +86,39 @@ def git_tag_reader(explicit_repo:, script_dir:)
|
|
|
78
86
|
end
|
|
79
87
|
end
|
|
80
88
|
|
|
89
|
+
# D9/D23: the injected branch reader. flow_base (a pure YAML read, never
|
|
90
|
+
# git) wins when the intent's project names a flow base; otherwise the
|
|
91
|
+
# repository's remote HEAD, else whichever of main/master exists. Resolves
|
|
92
|
+
# the repo with `include_self: false` (D23): a global-store intent must
|
|
93
|
+
# never fall back to the script's OWN repository (~/.plastic itself, on
|
|
94
|
+
# "main" in the installed layout) and print a confident, wrong branch.
|
|
95
|
+
def git_branch_reader(explicit_repo:, script_dir:)
|
|
96
|
+
lambda do |intent_dir|
|
|
97
|
+
flow = ReportScreen.flow_base(intent_dir)
|
|
98
|
+
next flow if flow && !flow.to_s.empty?
|
|
99
|
+
|
|
100
|
+
repo = resolve_repo(intent_dir, explicit_repo, script_dir, include_self: false)
|
|
101
|
+
next nil unless repo
|
|
102
|
+
|
|
103
|
+
branch = `git -C #{repo.shellescape} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`.strip
|
|
104
|
+
branch = branch.sub(%r{\Aorigin/}, "")
|
|
105
|
+
next branch unless branch.empty?
|
|
106
|
+
|
|
107
|
+
%w[main master].find do |b|
|
|
108
|
+
system("git", "-C", repo, "rev-parse", "--verify", "--quiet", b, out: File::NULL, err: File::NULL)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
81
113
|
args = ARGV.dup
|
|
82
114
|
verb = args.shift
|
|
83
115
|
changed = nil
|
|
84
116
|
ansi = false
|
|
85
117
|
template_path = nil
|
|
86
118
|
repo_flag = nil
|
|
119
|
+
session_flag = nil
|
|
120
|
+
since_flag = nil
|
|
121
|
+
ledger_root_flag = nil
|
|
87
122
|
positional = []
|
|
88
123
|
|
|
89
124
|
while (arg = args.shift)
|
|
@@ -99,13 +134,19 @@ while (arg = args.shift)
|
|
|
99
134
|
repo_flag = args.shift or usage_abort("--repo needs a path")
|
|
100
135
|
when "--template"
|
|
101
136
|
template_path = args.shift or usage_abort("--template needs a path")
|
|
137
|
+
when "--session"
|
|
138
|
+
session_flag = args.shift or usage_abort("--session needs a value")
|
|
139
|
+
when "--since"
|
|
140
|
+
since_flag = args.shift or usage_abort("--since needs a value")
|
|
141
|
+
when "--ledger-root"
|
|
142
|
+
ledger_root_flag = args.shift or usage_abort("--ledger-root needs a path")
|
|
102
143
|
else
|
|
103
144
|
usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--") && arg != "--all"
|
|
104
145
|
positional << arg
|
|
105
146
|
end
|
|
106
147
|
end
|
|
107
148
|
|
|
108
|
-
usage_abort("usage: report-screen state|delivered|delay <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
|
|
149
|
+
usage_abort("usage: report-screen state|delivered|delay|session <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
|
|
109
150
|
|
|
110
151
|
all_mode = positional.delete("--all") ? true : false
|
|
111
152
|
target = positional.first
|
|
@@ -142,7 +183,8 @@ when "delivered"
|
|
|
142
183
|
intent_dir = File.expand_path(target)
|
|
143
184
|
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
144
185
|
out = ReportScreen.render_delivered(intent_dir: intent_dir,
|
|
145
|
-
tag_reader: git_tag_reader(explicit_repo: repo_flag, script_dir: __dir__)
|
|
186
|
+
tag_reader: git_tag_reader(explicit_repo: repo_flag, script_dir: __dir__),
|
|
187
|
+
branch_reader: git_branch_reader(explicit_repo: repo_flag, script_dir: __dir__))
|
|
146
188
|
$stdout.write paint(out, ansi_enabled)
|
|
147
189
|
when "delay"
|
|
148
190
|
usage_abort("usage: report-screen delay <intent_dir>") unless target
|
|
@@ -150,8 +192,49 @@ when "delay"
|
|
|
150
192
|
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
151
193
|
out = ReportScreen.render_delay(intent_dir: intent_dir)
|
|
152
194
|
$stdout.write paint(out, ansi_enabled)
|
|
195
|
+
when "session"
|
|
196
|
+
usage_abort("usage: report-screen session <tier_root> [--session <id>] [--since <iso>] [--ledger-root <dir>] [--ansi]") unless target
|
|
197
|
+
store_root = File.expand_path(target)
|
|
198
|
+
usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
|
|
199
|
+
|
|
200
|
+
now = Time.now
|
|
201
|
+
session_id = session_flag || ENV[SessionLedger::SESSION_ID_ENV_KEY]
|
|
202
|
+
ledger_root = ledger_root_flag ? File.expand_path(ledger_root_flag) : ReportScreen.default_ledger_root(store_root)
|
|
203
|
+
|
|
204
|
+
if since_flag
|
|
205
|
+
begin
|
|
206
|
+
Time.parse(since_flag)
|
|
207
|
+
rescue ArgumentError
|
|
208
|
+
usage_abort("--since needs an ISO timestamp, got #{since_flag.inspect}")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
dirs, skipped = ReportScreen.session_delivered_dirs(ledger_root: ledger_root, tier_root: store_root,
|
|
213
|
+
session: session_id, since: since_flag, now: now)
|
|
214
|
+
# D17: the widened window is announced whether the id was missing or simply
|
|
215
|
+
# matched nothing. A resumed background job carries a session id that is not
|
|
216
|
+
# the one on the ledger lines, and that case must not answer silently. An
|
|
217
|
+
# explicit --since is the caller naming the window, so it needs no note.
|
|
218
|
+
note =
|
|
219
|
+
if since_flag
|
|
220
|
+
nil
|
|
221
|
+
elsif session_id.nil? || session_id.to_s.strip.empty?
|
|
222
|
+
ReportScreen.window_note(ReportScreen.fallback_day(ledger_root, SessionLedger.day_id(now)),
|
|
223
|
+
"no session id given")
|
|
224
|
+
elsif !ReportScreen.session_tagged?(ledger_root: ledger_root, session: session_id, now: now)
|
|
225
|
+
ReportScreen.window_note(ReportScreen.fallback_day(ledger_root, SessionLedger.day_id(now)),
|
|
226
|
+
"this session has no line in the day ledger")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
out = ReportScreen.render_session(
|
|
230
|
+
dirs: dirs, skipped: skipped, store_root: store_root,
|
|
231
|
+
tag_reader: git_tag_reader(explicit_repo: nil, script_dir: __dir__),
|
|
232
|
+
branch_reader: git_branch_reader(explicit_repo: nil, script_dir: __dir__),
|
|
233
|
+
note: note, now: now, painter: ->(text) { paint(text, ansi_enabled) }
|
|
234
|
+
)
|
|
235
|
+
$stdout.write out
|
|
153
236
|
else
|
|
154
|
-
usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay)")
|
|
237
|
+
usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay|session)")
|
|
155
238
|
end
|
|
156
239
|
|
|
157
240
|
exit 0
|
package/scripts/verify-intent
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
# other flags exist.
|
|
16
16
|
#
|
|
17
17
|
# Checks, all run every invocation (the fourth only when --suite is given):
|
|
18
|
-
# 1. doctor - Doctor#run_intent_check(id, store: scope), fail-open on a crash
|
|
18
|
+
# 1. doctor - Doctor#run_intent_check(id, store: scope), fail-open on a crash; the doctor
|
|
19
|
+
# scan includes the intent_ticks_lag warning (intent 329)
|
|
19
20
|
# 2. em-dash - added lines only of `git diff <base>...HEAD`, never the whole tree
|
|
20
21
|
# 3. diffstat - `git diff --stat <base>...HEAD`, never a failure on its own
|
|
21
22
|
# 4. suite - the supplied --suite command, run with RUBYOPT cleared
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -217,7 +217,7 @@ Then Exec.
|
|
|
217
217
|
then builds, then drives the full suite green.
|
|
218
218
|
2. Read its return by code: DONE or DONE_WITH_CONCERNS proceeds; NEEDS_CONTEXT re-dispatches
|
|
219
219
|
with the missing context; BLOCKED stops under the error procedure.
|
|
220
|
-
3. Tick the checklist as items land (the executor does this; verify
|
|
220
|
+
3. Tick the checklist as items land (the executor does this); verify tick-versus-diff against the diff. A mismatch is a review finding, not a lead cleanup.
|
|
221
221
|
|
|
222
222
|
## Review by risk (boot 3, only when a rule fires)
|
|
223
223
|
|
|
@@ -254,7 +254,7 @@ every choice is non-destructive and the team has full autonomy.
|
|
|
254
254
|
|
|
255
255
|
Read `../plastic-conventions/references/completion-and-done.md` for what "intent done" means.
|
|
256
256
|
|
|
257
|
-
1.
|
|
257
|
+
1. This is the merge gate: verify every checklist item is checked, verify tick-versus-diff against the diff, and confirm the suite is green once on the branch.
|
|
258
258
|
2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered`,
|
|
259
259
|
`## Delivered` as the labeled table whose row labels match the action-file headings (317a).
|
|
260
260
|
3. Release, if configured: match the working directory against `~/.plastic/projects.yml`, read
|
|
@@ -272,13 +272,14 @@ Read `../plastic-conventions/references/completion-and-done.md` for what "intent
|
|
|
272
272
|
--session "$CLAUDE_CODE_SESSION_ID" \
|
|
273
273
|
--index-note "<what shipped>; <suite result>"
|
|
274
274
|
```
|
|
275
|
-
Exit 4
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15):
|
|
280
|
-
|
|
281
|
-
|
|
275
|
+
Exit 4: a live foreign session holds the lock. 5: the worktree is dirty (commit first, or
|
|
276
|
+
pass `--discard-worktree-changes` deliberately). 3: the lock survived the disarm
|
|
277
|
+
(`/plastic-doctor check the lock status`). 6: the structure check refused. Never leave an
|
|
278
|
+
orphaned worktree; run `git worktree prune` on a stale reference.
|
|
279
|
+
6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15): the owner
|
|
280
|
+
report at End, replacing the old prose Done briefing. A mid-batch status ask instead runs
|
|
281
|
+
`report-screen session <tier_root> --session "$CLAUDE_CODE_SESSION_ID"` (intent 330). Print
|
|
282
|
+
it as the first thing in the reply, no code fence, or the hook cannot paint it.
|
|
282
283
|
|
|
283
284
|
## Error Handling
|
|
284
285
|
|
|
@@ -14,6 +14,10 @@ written by eye:
|
|
|
14
14
|
Asked, Delivered (with a Proven-by column), Evidence, Needs you.
|
|
15
15
|
- **`report-screen delay <intent_dir>`** - printed only on request ("why did X take so long"):
|
|
16
16
|
the delivery as a timeline plus the derived `Where the time went` line.
|
|
17
|
+
- **`report-screen session <tier_root>`** - the answer to an UNNAMED status ask ("where are we
|
|
18
|
+
with delivery", "what is the status"): one `delivered` screen per intent this session
|
|
19
|
+
completed, oldest first, then the `state --all` roster. Intent 330's ruling: a status ask
|
|
20
|
+
answers with what actually shipped, not the in-flight roster alone.
|
|
17
21
|
|
|
18
22
|
## The five triggers for `state`
|
|
19
23
|
|
|
@@ -111,11 +111,15 @@ For a live intent's directory:
|
|
|
111
111
|
`ruby ~/.plastic/scripts/report-screen state <intent_dir>` and print its output as it is:
|
|
112
112
|
the title, the field table, the `Changed` row, and the Steps table come from the record,
|
|
113
113
|
never by eye. For "where are we" with no intent named, run
|
|
114
|
-
`ruby ~/.plastic/scripts/report-screen
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
`ruby ~/.plastic/scripts/report-screen session <tier_root> --session <this session's id>`
|
|
115
|
+
(intent 330; pass the id your harness gives you, or the screen widens to the whole day and
|
|
116
|
+
says so): it prints one
|
|
117
|
+
`delivered` screen per intent this session actually completed, oldest first, then the same
|
|
118
|
+
roster `report-screen state --all <store_root>` prints on its own - `state --all` stays the
|
|
119
|
+
right call when only the in-flight roster is wanted, with nothing delivered above it. Route
|
|
120
|
+
"why did X take so long" to `ruby ~/.plastic/scripts/report-screen delay <intent_dir>`
|
|
121
|
+
instead - every verb prints the same plain screen on any harness, painted only where the
|
|
122
|
+
harness supports it, with no branching on harness name. Under the `state` screen
|
|
119
123
|
write **What this means** as two to four bullets in plain words (what the intent is for,
|
|
120
124
|
what has landed, what is left, any defect named by step), then close with **needs input:**
|
|
121
125
|
naming the first open step. Then continue the work in the
|
|
@@ -108,11 +108,15 @@ Capture observations in `## Insights`. When ALL checklist items are checked:
|
|
|
108
108
|
|
|
109
109
|
## Tick-as-you-land
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
Progress` to `## Completed` in `checklist.md
|
|
113
|
-
row (Date, Items Completed, Notes).
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
A tick is two edits, made together: mark the item's box `[x]`, and move its
|
|
112
|
+
checklist item from `## In Progress` to `## Completed` in `checklist.md`;
|
|
113
|
+
then add one `## Session Log` row (Date, Items Completed, Notes). The box is
|
|
114
|
+
the half the state screen's Progress bar reads: `IntentScreen::ITEM_RE` and
|
|
115
|
+
`progress_fields` count `[x]`, not which section the line sits in, so a line
|
|
116
|
+
moved to `## Completed` with its box left unmarked still reads as zero
|
|
117
|
+
progress. Do not batch several tasks' worth of checklist updates into one
|
|
118
|
+
later edit; tick the moment the task is verified, before moving to the next
|
|
119
|
+
task.
|
|
116
120
|
|
|
117
121
|
## Verify before every owner review
|
|
118
122
|
|
|
@@ -20,12 +20,17 @@ You are implementing a specific task from a plan. You have been given the full t
|
|
|
20
20
|
2. Implement exactly what the task specifies — nothing more, nothing less.
|
|
21
21
|
3. Write tests first when the task includes test steps (TDD).
|
|
22
22
|
4. Follow the file paths specified in the task exactly.
|
|
23
|
-
5. Commit after each logical unit of work
|
|
23
|
+
5. Commit after each logical unit of work, and in the same step tick the checklist item that
|
|
24
|
+
unit lands: mark its box `[x]` and move the line to `## Completed`, then append the
|
|
25
|
+
savepoint `Commit` line
|
|
26
|
+
(`scripts/savepoint-note <intent_dir> --kind Commit --text "<sha> <what it proves>"`). A
|
|
27
|
+
commit without its tick is incomplete.
|
|
24
28
|
6. When done, self-review against this checklist:
|
|
25
29
|
- [ ] All steps in the task are completed
|
|
26
30
|
- [ ] Tests pass
|
|
27
31
|
- [ ] Code is clean and follows project conventions
|
|
28
32
|
- [ ] No unrelated changes
|
|
33
|
+
- [ ] Every landed unit's checklist item is ticked
|
|
29
34
|
|
|
30
35
|
## Report Format
|
|
31
36
|
|