@iceinvein/agent-skills 0.8.5 → 0.10.0
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/package.json +1 -1
- package/skills/index.json +2 -2
- package/skills/sluice/SKILL.md +15 -4
- package/skills/sluice/references/deep-channel.md +106 -24
- package/skills/sluice/references/status.md +213 -0
- package/skills/sluice/scripts/plan.sh +524 -0
- package/skills/sluice/scripts/status.sh +549 -0
- package/skills/sluice/skill.json +2 -2
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# sluice plan tooling: the parts of the plan format that a reader can check
|
|
3
|
+
# without judgement.
|
|
4
|
+
#
|
|
5
|
+
# `references/deep-channel.md` is 21KB read once, at the point the plan is
|
|
6
|
+
# written, and relied on from memory for the rest of the run. Most of what it
|
|
7
|
+
# asks for is not a matter of taste: a Needs no task Offers, a "TBD", a step
|
|
8
|
+
# deferring to another task, a plan with no flip or two. Those are checkable, so
|
|
9
|
+
# they should be checked rather than remembered.
|
|
10
|
+
#
|
|
11
|
+
# plan.sh validate <plan.md>
|
|
12
|
+
# plan.sh import <plan.md> [--dir <path>] [--force]
|
|
13
|
+
#
|
|
14
|
+
# validate prints one line per finding. An error means the plan cannot be
|
|
15
|
+
# dispatched as written; a warning is a judgement call left to its author.
|
|
16
|
+
# import seeds the run state's task rows from the plan, so the ids, names, the
|
|
17
|
+
# flip, the model marks and the tiers come from the file rather than from a dozen
|
|
18
|
+
# hand-typed commands. It is safe to re-run: a status, a review mark or a
|
|
19
|
+
# ratified model already recorded is left alone, and a tier is only ever raised.
|
|
20
|
+
#
|
|
21
|
+
# Exit: 0 no errors, 2 errors found, 4 bad arguments, 5 jq missing (import only).
|
|
22
|
+
|
|
23
|
+
set -uo pipefail
|
|
24
|
+
|
|
25
|
+
err() { echo "plan.sh: $*" >&2; }
|
|
26
|
+
|
|
27
|
+
# ASCII unit separator. Tab would be merged by `read`; see the parser comment.
|
|
28
|
+
SEP="$(printf '\037')"
|
|
29
|
+
|
|
30
|
+
usage() {
|
|
31
|
+
echo "usage:" >&2
|
|
32
|
+
sed -n '/^# plan.sh validate/,/^# Exit:/p' "$0" | sed 's/^# \{0,2\}//' >&2
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# The parser. Emits rows on stdout separated by 0x1f, the ASCII unit separator,
|
|
36
|
+
# NOT by a tab. Tab is an IFS *whitespace* character, so `read` merges runs of it
|
|
37
|
+
# and drops empty fields: one task with an empty Needs then shifts every later
|
|
38
|
+
# field left, which is how the graph columns silently swapped. 0x1f is
|
|
39
|
+
# non-whitespace, so empty columns survive.
|
|
40
|
+
#
|
|
41
|
+
# Rows:
|
|
42
|
+
# summary <task count> <flip task or 0>
|
|
43
|
+
# error|warn <message>
|
|
44
|
+
# task <id> <name> <tier 0-3> <model 1|0> <flips 1|0> <needs> <offers> <touches>
|
|
45
|
+
#
|
|
46
|
+
# The last three are space-separated and are what answers "which tasks may go
|
|
47
|
+
# now": a task is ready when every Needs it names is offered by something already
|
|
48
|
+
# done, and two ready tasks are safe together when their Touches are disjoint.
|
|
49
|
+
#
|
|
50
|
+
# The tier is a floor derived from what the plan actually settles: an (edit) in
|
|
51
|
+
# Touches means existing code changed, no (test) means nothing executable covers
|
|
52
|
+
# the task, and Flips or a Review flag is tier 3 outright. A task matching more
|
|
53
|
+
# than one row takes the highest, per the tier table.
|
|
54
|
+
#
|
|
55
|
+
# One pass, line-oriented, because the plan format is a strict skeleton rather
|
|
56
|
+
# than free markdown. Fenced blocks are skipped: a plan carries signatures and
|
|
57
|
+
# commands in them, and a `- [ ]` inside a fence is an example, not a step.
|
|
58
|
+
PARSER='
|
|
59
|
+
BEGIN { SEP = sprintf("%c", 31) }
|
|
60
|
+
|
|
61
|
+
function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
|
|
62
|
+
|
|
63
|
+
# Symbols on one side of a Contract line. The two sides are read differently
|
|
64
|
+
# because a mistake costs differently on each.
|
|
65
|
+
#
|
|
66
|
+
# Supply (Offers) is generous: every identifier in the half goes on offer, so a
|
|
67
|
+
# return shape spelled `parsePlan(path) -> `PlanDoc`` puts PlanDoc on offer as
|
|
68
|
+
# well as parsePlan, which is what the format asks for when it says both sides
|
|
69
|
+
# are spelled out whole. An extra symbol on offer costs nothing.
|
|
70
|
+
#
|
|
71
|
+
# Demand (Needs) is strict, because an extra symbol here is a false error:
|
|
72
|
+
# backticked names and signatures only, falling back to the leading identifier
|
|
73
|
+
# of each comma-separated part when the half carries neither. Primitive type
|
|
74
|
+
# names are dropped from it: a return shape drags in whatever it is spelled with,
|
|
75
|
+
# and no task in any plan creates `string`.
|
|
76
|
+
function is_primitive(w) {
|
|
77
|
+
return (w == "string" || w == "number" || w == "boolean" || w == "bool" ||
|
|
78
|
+
w == "void" || w == "any" || w == "unknown" || w == "never" ||
|
|
79
|
+
w == "null" || w == "undefined" || w == "int" || w == "float" ||
|
|
80
|
+
w == "object" || w == "Promise" || w == "Array")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function symbols(half, out, generous, count, rest, tok, lower, n, parts, i, chunk) {
|
|
84
|
+
lower = half
|
|
85
|
+
gsub(/`/, "", lower)
|
|
86
|
+
lower = tolower(trim(lower))
|
|
87
|
+
if (lower == "" || lower == "none" || lower == "-" || lower == "n/a") return 0
|
|
88
|
+
count = 0
|
|
89
|
+
|
|
90
|
+
if (generous) {
|
|
91
|
+
rest = half
|
|
92
|
+
gsub(/`/, " ", rest)
|
|
93
|
+
while (match(rest, /[A-Za-z_][A-Za-z0-9_]*/)) {
|
|
94
|
+
out[++count] = substr(rest, RSTART, RLENGTH)
|
|
95
|
+
rest = substr(rest, RSTART + RLENGTH)
|
|
96
|
+
}
|
|
97
|
+
return count
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
rest = half
|
|
101
|
+
while (match(rest, /`[^`]*`/)) {
|
|
102
|
+
chunk = substr(rest, RSTART + 1, RLENGTH - 2)
|
|
103
|
+
rest = substr(rest, RSTART + RLENGTH)
|
|
104
|
+
# The leading identifier of a backticked chunk, unless that is a wrapper
|
|
105
|
+
# like Promise<Config> or Array<TaskRow>: then the dependency is what it
|
|
106
|
+
# wraps, and taking the leading one both misses the real symbol and
|
|
107
|
+
# invents a false one.
|
|
108
|
+
while (match(chunk, /[A-Za-z_][A-Za-z0-9_]*/)) {
|
|
109
|
+
tok = substr(chunk, RSTART, RLENGTH)
|
|
110
|
+
chunk = substr(chunk, RSTART + RLENGTH)
|
|
111
|
+
if (!is_primitive(tok)) { out[++count] = tok; break }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
rest = half
|
|
115
|
+
gsub(/`/, "", rest)
|
|
116
|
+
while (match(rest, /[A-Za-z_][A-Za-z0-9_]*\(/)) {
|
|
117
|
+
tok = substr(rest, RSTART, RLENGTH - 1)
|
|
118
|
+
if (!is_primitive(tok)) out[++count] = tok
|
|
119
|
+
rest = substr(rest, RSTART + RLENGTH)
|
|
120
|
+
}
|
|
121
|
+
if (count == 0) {
|
|
122
|
+
rest = half
|
|
123
|
+
gsub(/`/, "", rest)
|
|
124
|
+
n = split(rest, parts, /[,;]/)
|
|
125
|
+
for (i = 1; i <= n; i++) {
|
|
126
|
+
chunk = parts[i]
|
|
127
|
+
# Guarded like the strict rules above. Unguarded, a half whose every
|
|
128
|
+
# candidate was a primitive fell through to here and the filter was
|
|
129
|
+
# undone by the code meant to back it up.
|
|
130
|
+
while (match(chunk, /[A-Za-z_][A-Za-z0-9_]*/)) {
|
|
131
|
+
tok = substr(chunk, RSTART, RLENGTH)
|
|
132
|
+
chunk = substr(chunk, RSTART + RLENGTH)
|
|
133
|
+
if (!is_primitive(tok)) { out[++count] = tok; break }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return count
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function finding(sev, msg) { out[++nout] = sev SEP msg }
|
|
141
|
+
|
|
142
|
+
/^[ \t]*```/ { fenced = 1 - fenced; next }
|
|
143
|
+
fenced { next }
|
|
144
|
+
|
|
145
|
+
/^# Plan:/ { has_title = 1 }
|
|
146
|
+
|
|
147
|
+
/^## / {
|
|
148
|
+
in_rules = (tolower($0) ~ /^## ground rules/) ? 1 : 0
|
|
149
|
+
if (in_rules) has_rules_heading = 1
|
|
150
|
+
# A section after the last task belongs to the plan, not to that task. Left
|
|
151
|
+
# set, cur attributes its prose and any checkbox in it to a task that ended
|
|
152
|
+
# several headings ago.
|
|
153
|
+
cur = 0
|
|
154
|
+
next
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/^### Task / {
|
|
158
|
+
in_rules = 0
|
|
159
|
+
s = substr($0, 10)
|
|
160
|
+
colon = index(s, ":")
|
|
161
|
+
if (colon == 0) { finding("error", "a task heading has no name: " $0); next }
|
|
162
|
+
id = trim(substr(s, 1, colon - 1))
|
|
163
|
+
if (id !~ /^[0-9]+$/) { finding("error", "a task heading is not numbered: " $0); next }
|
|
164
|
+
id = id + 0
|
|
165
|
+
if (id in seen) {
|
|
166
|
+
finding("error", "task " id " appears twice; a repeated number makes the dependency order unreadable")
|
|
167
|
+
}
|
|
168
|
+
seen[id] = 1
|
|
169
|
+
order[++ntasks] = id
|
|
170
|
+
name[id] = trim(substr(s, colon + 1))
|
|
171
|
+
cur = id
|
|
172
|
+
next
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
in_rules && /^- / { nrules++ }
|
|
176
|
+
|
|
177
|
+
# Everything below belongs to a task, so a field outside one is a field nobody
|
|
178
|
+
# reads.
|
|
179
|
+
!cur { next }
|
|
180
|
+
|
|
181
|
+
# Scanned over every line of a task body, and deliberately ahead of the field
|
|
182
|
+
# and step rules: those end in `next`, so a check placed after them never sees a
|
|
183
|
+
# step, which is exactly where a "TBD" turns up.
|
|
184
|
+
{
|
|
185
|
+
if ($0 ~ /TBD/)
|
|
186
|
+
finding("error", "task " cur ": \"TBD\" is not an instruction")
|
|
187
|
+
if (tolower($0) ~ /same as (task|step)/)
|
|
188
|
+
finding("error", "task " cur ": \"same as ...\" defers to a task its implementer never sees")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/^\*\*Contract:\*\*/ {
|
|
192
|
+
has_contract[cur] = 1
|
|
193
|
+
body = substr($0, length("**Contract:**") + 1)
|
|
194
|
+
oi = index(body, "Offers:")
|
|
195
|
+
if (oi > 0) { nh = substr(body, 1, oi - 1); oh = substr(body, oi + 7) }
|
|
196
|
+
else { nh = body; oh = "" }
|
|
197
|
+
ni = index(nh, "Needs:")
|
|
198
|
+
if (ni > 0) nh = substr(nh, ni + 6)
|
|
199
|
+
sub(/\|[ \t]*$/, "", nh)
|
|
200
|
+
|
|
201
|
+
delete syms
|
|
202
|
+
n = symbols(nh, syms, 0)
|
|
203
|
+
for (i = 1; i <= n; i++) needs[cur] = needs[cur] " " syms[i]
|
|
204
|
+
|
|
205
|
+
delete syms
|
|
206
|
+
n = symbols(oh, syms, 1)
|
|
207
|
+
for (i = 1; i <= n; i++) {
|
|
208
|
+
if (!(syms[i] in offered_by) || cur < offered_by[syms[i]]) offered_by[syms[i]] = cur
|
|
209
|
+
offers[cur] = offers[cur] " " syms[i]
|
|
210
|
+
}
|
|
211
|
+
next
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/^\*\*Touches:\*\*/ {
|
|
215
|
+
has_touches[cur] = 1
|
|
216
|
+
body = substr($0, length("**Touches:**") + 1)
|
|
217
|
+
n = split(body, parts, /\|/)
|
|
218
|
+
for (i = 1; i <= n; i++) {
|
|
219
|
+
# The path is everything before the first parenthesis. Stripping only a
|
|
220
|
+
# trailing annotation left the rest of a mid-field one in the path, and
|
|
221
|
+
# those fragments then read as real paths: two tasks with disjoint files
|
|
222
|
+
# came out sharing "(edit)" and were serialised for nothing.
|
|
223
|
+
p = parts[i]
|
|
224
|
+
if (index(p, "(") > 0) p = substr(p, 1, index(p, "(") - 1)
|
|
225
|
+
p = trim(p)
|
|
226
|
+
if (p == "") continue
|
|
227
|
+
# The annotation is what the tier table turns on, so it is kept rather
|
|
228
|
+
# than stripped and forgotten: an (edit) means existing code changed, and
|
|
229
|
+
# the absence of any (test) means nothing executable covers the task.
|
|
230
|
+
if (parts[i] ~ /\(edit\)/) has_edit[cur] = 1
|
|
231
|
+
if (parts[i] ~ /\(test\)/) has_test[cur] = 1
|
|
232
|
+
if (parts[i] ~ /\(new\)/) has_new[cur] = 1
|
|
233
|
+
# Under-tiering is the unsafe direction: an unrecognised annotation, or a
|
|
234
|
+
# missing one, reads as "nothing was edited here" and the task then owes
|
|
235
|
+
# no review. Say so rather than deriving from a spelling nobody checked.
|
|
236
|
+
if (parts[i] !~ /\((new|edit|test)\)/)
|
|
237
|
+
finding("warn", "task " cur " Touches " p " with no (new), (edit) or (test) annotation, so its tier is derived as if nothing was edited")
|
|
238
|
+
else if (parts[i] !~ /\((new|edit|test)\)[ \t]*$/)
|
|
239
|
+
finding("warn", "task " cur " Touches " p " with text after its annotation; the tier reads correctly but the line is not the format")
|
|
240
|
+
# Accumulated rather than assigned: with three tasks on one path,
|
|
241
|
+
# reporting a single pair leaves the reader serialising two of them and
|
|
242
|
+
# still running the third alongside.
|
|
243
|
+
if (index(" " owners[p] " ", " " cur " ") == 0)
|
|
244
|
+
owners[p] = owners[p] (owners[p] == "" ? "" : " ") cur
|
|
245
|
+
if (index(" " paths[cur] " ", " " p " ") == 0)
|
|
246
|
+
paths[cur] = paths[cur] (paths[cur] == "" ? "" : " ") p
|
|
247
|
+
}
|
|
248
|
+
next
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/^\*\*Flips:\*\*/ { has_flips[cur] = 1; nflips++; flips_list = flips_list (flips_list == "" ? "" : ", ") cur; next }
|
|
252
|
+
/^\*\*Review:\*\*/ { has_review[cur] = 1; next }
|
|
253
|
+
/^\*\*Model:\*\*/ { has_model[cur] = 1; next }
|
|
254
|
+
|
|
255
|
+
/^- \[[ xX]\]/ {
|
|
256
|
+
nsteps[cur]++
|
|
257
|
+
# Counted rather than reported here. One warning per step turns a plan
|
|
258
|
+
# written before the proof convention into forty identical lines, and the
|
|
259
|
+
# errors underneath them stop being read.
|
|
260
|
+
if (index($0, "->") == 0) noproof[cur]++
|
|
261
|
+
next
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
END {
|
|
265
|
+
if (!has_title) finding("warn", "no \"# Plan: <topic>\" heading, so the file does not say what it plans")
|
|
266
|
+
|
|
267
|
+
if (!has_rules_heading || nrules == 0)
|
|
268
|
+
finding("error", "no Ground Rules with entries; an implementer arrives with its own defaults and uses them on anything left unsaid")
|
|
269
|
+
|
|
270
|
+
if (ntasks == 0) {
|
|
271
|
+
finding("error", "no tasks, so there is nothing to dispatch")
|
|
272
|
+
} else {
|
|
273
|
+
for (i = 1; i <= ntasks; i++) {
|
|
274
|
+
id = order[i]
|
|
275
|
+
if (id != i)
|
|
276
|
+
finding("error", "tasks are not numbered 1.." ntasks " in sequence: task " id " sits where " i " should")
|
|
277
|
+
}
|
|
278
|
+
for (i = 1; i <= ntasks; i++) {
|
|
279
|
+
id = order[i]
|
|
280
|
+
if (!(id in has_contract))
|
|
281
|
+
finding("error", "task " id " has no Contract, so a symbol absent from it does not exist for whoever builds it")
|
|
282
|
+
if (!(id in has_touches))
|
|
283
|
+
finding("error", "task " id " has no Touches, and concurrency is derived from it")
|
|
284
|
+
if (!(id in nsteps))
|
|
285
|
+
finding("error", "task " id " has no steps")
|
|
286
|
+
else if (id in noproof)
|
|
287
|
+
finding("warn", "task " id ": " noproof[id] " of " nsteps[id] " steps have no proof after ->, so nothing says they worked")
|
|
288
|
+
if ((id in has_model) && (id in has_flips))
|
|
289
|
+
finding("error", "task " id " carries Flips and a Model mark; the flip is tier 3 and may not be downshifted")
|
|
290
|
+
if ((id in has_model) && (id in has_review))
|
|
291
|
+
finding("error", "task " id " is flagged for Review and carries a Model mark; a tier 3 task may not be downshifted")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (nflips == 0)
|
|
295
|
+
finding("error", "no task carries Flips, so nothing in the plan turns anything on")
|
|
296
|
+
else if (nflips > 1)
|
|
297
|
+
finding("error", "two or more tasks carry Flips (" flips_list "); a plan with more than one holds that many branches of work")
|
|
298
|
+
|
|
299
|
+
for (i = 1; i <= ntasks; i++) {
|
|
300
|
+
id = order[i]
|
|
301
|
+
n = split(needs[id], want, " ")
|
|
302
|
+
for (j = 1; j <= n; j++) {
|
|
303
|
+
sym = want[j]
|
|
304
|
+
if (sym == "" || ((id "\t" sym) in reported)) continue
|
|
305
|
+
reported[id "\t" sym] = 1
|
|
306
|
+
if (!(sym in offered_by))
|
|
307
|
+
finding("error", "task " id " Needs " sym ", which no task Offers")
|
|
308
|
+
else if (offered_by[sym] > id)
|
|
309
|
+
finding("warn", "task " id " Needs " sym ", offered only by the later task " offered_by[sym])
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (p in owners) {
|
|
315
|
+
n = split(owners[p], who, " ")
|
|
316
|
+
if (n < 2) continue
|
|
317
|
+
list = who[1]
|
|
318
|
+
for (i = 2; i <= n; i++) list = list (i == n ? " and " : ", ") who[i]
|
|
319
|
+
finding("warn", "tasks " list " " (n == 2 ? "both" : "all") " touch " p ", so they cannot run at the same time")
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# A task matching more than one row takes the highest of them, which is what
|
|
323
|
+
# the tier table says and the reason this is a max rather than a chain of
|
|
324
|
+
# elses. Computed once, ahead of both the histogram and the task rows.
|
|
325
|
+
# The LAST task demanding each symbol, so "later tasks build on it" answers
|
|
326
|
+
# for any consumer rather than only the earliest. Kept as a minimum, one early
|
|
327
|
+
# consumer hid every later one and the producer three tasks were built blind
|
|
328
|
+
# against came out owing no review at all.
|
|
329
|
+
for (i = 1; i <= ntasks; i++) {
|
|
330
|
+
n = split(needs[order[i]], want, " ")
|
|
331
|
+
for (j = 1; j <= n; j++)
|
|
332
|
+
if (want[j] != "" && (!(want[j] in last_demand) || order[i] > last_demand[want[j]]))
|
|
333
|
+
last_demand[want[j]] = order[i]
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (i = 1; i <= ntasks; i++) {
|
|
337
|
+
id = order[i]
|
|
338
|
+
t = 0
|
|
339
|
+
if (id in has_edit) t = 1
|
|
340
|
+
# A task whose only path is a test is changing a suite that already
|
|
341
|
+
# exists: the format offers no "(test) (edit)" spelling, so the annotation
|
|
342
|
+
# cannot say so, and reading it as a creation leaves the task owing
|
|
343
|
+
# nothing. Under-tiering is the unsafe direction.
|
|
344
|
+
if ((id in has_test) && !(id in has_new) && !(id in has_edit)) t = 1
|
|
345
|
+
n = split(offers[id], mine, " ")
|
|
346
|
+
for (j = 1; j <= n; j++)
|
|
347
|
+
if (mine[j] != "" && (mine[j] in last_demand) && last_demand[mine[j]] > id) t = (t > 1 ? t : 1)
|
|
348
|
+
if (!(id in has_test)) t = (t > 2 ? t : 2)
|
|
349
|
+
if ((id in has_flips) || (id in has_review)) t = 3
|
|
350
|
+
tier_of[id] = t
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
flip = 0
|
|
354
|
+
for (i = 1; i <= ntasks; i++) if (order[i] in has_flips) { flip = order[i]; break }
|
|
355
|
+
# The histogram is what prices the review question at pre-flight: "four of
|
|
356
|
+
# nine need a reviewer" is a decision a partner can weigh, and counting it
|
|
357
|
+
# by hand off a nine-task plan is how the count comes out wrong.
|
|
358
|
+
hist = ""
|
|
359
|
+
for (t = 0; t <= 3; t++) {
|
|
360
|
+
c = 0
|
|
361
|
+
for (i = 1; i <= ntasks; i++) if (tier_of[order[i]] == t) c++
|
|
362
|
+
if (c > 0) hist = hist (hist == "" ? "" : " ") t ":" c
|
|
363
|
+
}
|
|
364
|
+
print "summary" SEP ntasks SEP flip SEP hist
|
|
365
|
+
for (i = 1; i <= nout; i++) print out[i]
|
|
366
|
+
for (i = 1; i <= ntasks; i++) {
|
|
367
|
+
id = order[i]
|
|
368
|
+
tier = tier_of[id]
|
|
369
|
+
print "task" SEP id SEP name[id] SEP tier SEP ((id in has_model) ? 1 : 0) SEP ((id in has_flips) ? 1 : 0) \
|
|
370
|
+
SEP trim(needs[id]) SEP trim(offers[id]) SEP trim(paths[id])
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
'
|
|
374
|
+
|
|
375
|
+
# Runs the parser and splits its output into the shell. Sets: NTASKS, FLIP,
|
|
376
|
+
# FINDINGS (newline-separated sev\tmsg), TASKROWS (newline-separated), NERR,
|
|
377
|
+
# NWARN.
|
|
378
|
+
parse_plan() {
|
|
379
|
+
local plan="$1" raw
|
|
380
|
+
raw="$(awk "$PARSER" "$plan")" || { err "could not read $plan"; exit 4; }
|
|
381
|
+
|
|
382
|
+
local summary
|
|
383
|
+
summary="$(printf '%s\n' "$raw" | grep "^summary$SEP" | head -1)"
|
|
384
|
+
NTASKS="$(printf '%s' "$summary" | cut -d"$SEP" -f2)"
|
|
385
|
+
FLIP="$(printf '%s' "$summary" | cut -d"$SEP" -f3)"
|
|
386
|
+
TIERS="$(printf '%s' "$summary" | cut -d"$SEP" -f4)"
|
|
387
|
+
FINDINGS="$(printf '%s\n' "$raw" | grep -E "^(error|warn)$SEP" || true)"
|
|
388
|
+
TASKROWS="$(printf '%s\n' "$raw" | grep "^task$SEP" || true)"
|
|
389
|
+
NERR="$(printf '%s\n' "$FINDINGS" | grep -c "^error$SEP" || true)"
|
|
390
|
+
NWARN="$(printf '%s\n' "$FINDINGS" | grep -c "^warn$SEP" || true)"
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
# "1 error" / "2 errors", so the header does not read as a template.
|
|
394
|
+
plural() { [ "$1" = "1" ] && echo "$1 $2" || echo "$1 $2s"; }
|
|
395
|
+
|
|
396
|
+
SUB="${1-}"
|
|
397
|
+
[ $# -gt 0 ] && shift
|
|
398
|
+
if [ -z "$SUB" ]; then
|
|
399
|
+
usage
|
|
400
|
+
exit 4
|
|
401
|
+
fi
|
|
402
|
+
|
|
403
|
+
case "$SUB" in
|
|
404
|
+
validate)
|
|
405
|
+
PLAN="${1-}"
|
|
406
|
+
[ -n "$PLAN" ] || { err "validate needs a plan path"; exit 4; }
|
|
407
|
+
[ -f "$PLAN" ] || { err "no such plan: $PLAN"; exit 4; }
|
|
408
|
+
shift
|
|
409
|
+
[ $# -eq 0 ] || { err "unknown argument: $1"; exit 4; }
|
|
410
|
+
|
|
411
|
+
parse_plan "$PLAN"
|
|
412
|
+
|
|
413
|
+
head="$(basename "$PLAN"): $(plural "$NTASKS" task)"
|
|
414
|
+
[ "$FLIP" != "0" ] && head="$head, flip at task $FLIP"
|
|
415
|
+
[ -n "$TIERS" ] && head="$head, tiers $TIERS"
|
|
416
|
+
if [ "$NERR" = "0" ] && [ "$NWARN" = "0" ]; then
|
|
417
|
+
echo "$head, no errors"
|
|
418
|
+
exit 0
|
|
419
|
+
fi
|
|
420
|
+
[ "$NERR" != "0" ] && head="$head, $(plural "$NERR" error)"
|
|
421
|
+
[ "$NWARN" != "0" ] && head="$head, $(plural "$NWARN" warning)"
|
|
422
|
+
[ "$NERR" = "0" ] && head="$head, no errors"
|
|
423
|
+
echo "$head"
|
|
424
|
+
echo
|
|
425
|
+
# Errors before warnings. Within each, the parser's own order, which
|
|
426
|
+
# follows the file.
|
|
427
|
+
{ printf '%s\n' "$FINDINGS" | grep "^error$SEP" || true
|
|
428
|
+
printf '%s\n' "$FINDINGS" | grep "^warn$SEP" || true
|
|
429
|
+
} | while IFS="$SEP" read -r sev msg; do
|
|
430
|
+
[ -n "$sev" ] || continue
|
|
431
|
+
printf ' %-5s %s\n' "$sev" "$msg"
|
|
432
|
+
done
|
|
433
|
+
|
|
434
|
+
[ "$NERR" = "0" ] || exit 2
|
|
435
|
+
;;
|
|
436
|
+
|
|
437
|
+
import)
|
|
438
|
+
PLAN="${1-}"
|
|
439
|
+
[ -n "$PLAN" ] || { err "import needs a plan path"; exit 4; }
|
|
440
|
+
[ -f "$PLAN" ] || { err "no such plan: $PLAN"; exit 4; }
|
|
441
|
+
shift
|
|
442
|
+
|
|
443
|
+
DIR="$PWD" FORCE=""
|
|
444
|
+
while [ $# -gt 0 ]; do
|
|
445
|
+
case "$1" in
|
|
446
|
+
--dir)
|
|
447
|
+
if [ $# -lt 2 ] || [ -z "${2-}" ]; then err "--dir needs a path"; exit 4; fi
|
|
448
|
+
case "$2" in --*) err "--dir needs a path, but the next argument is the flag $2"; exit 4 ;; esac
|
|
449
|
+
DIR="$2"; shift 2 ;;
|
|
450
|
+
--force) FORCE=1; shift ;;
|
|
451
|
+
*) err "unknown flag: $1"; exit 4 ;;
|
|
452
|
+
esac
|
|
453
|
+
done
|
|
454
|
+
|
|
455
|
+
command -v jq >/dev/null 2>&1 || { err "jq is required"; exit 5; }
|
|
456
|
+
STATUS="$(dirname "$0")/status.sh"
|
|
457
|
+
[ -f "$STATUS" ] || { err "status.sh not found beside this script"; exit 4; }
|
|
458
|
+
|
|
459
|
+
parse_plan "$PLAN"
|
|
460
|
+
|
|
461
|
+
# A plan with errors in it is a plan nobody should be running yet, so
|
|
462
|
+
# importing one would seed a run against work that has to change first.
|
|
463
|
+
if [ "$NERR" != "0" ]; then
|
|
464
|
+
if [ -n "$FORCE" ]; then
|
|
465
|
+
err "$PLAN has $(plural "$NERR" error); importing anyway because --force was passed"
|
|
466
|
+
else
|
|
467
|
+
err "$PLAN has $(plural "$NERR" error); fix them or re-run with --force (plan.sh validate lists them)"
|
|
468
|
+
exit 2
|
|
469
|
+
fi
|
|
470
|
+
fi
|
|
471
|
+
|
|
472
|
+
[ "$NTASKS" != "0" ] || { err "$PLAN has no tasks to import"; exit 2; }
|
|
473
|
+
|
|
474
|
+
# The plan marks that a task is mechanical; pre-flight ratifies which model
|
|
475
|
+
# it actually runs on, and that answer lives in run.json. Re-import must
|
|
476
|
+
# not replace it with the placeholder, so the ids already carrying a model
|
|
477
|
+
# are read first and skipped.
|
|
478
|
+
HAS_MODEL=" $(bash "$STATUS" show --json --dir "$DIR" 2>/dev/null |
|
|
479
|
+
jq -r '[.tasks[]? | select(.model != null) | .id] | join(" ")' 2>/dev/null) "
|
|
480
|
+
|
|
481
|
+
# The tier table takes the highest row a task matches, so a tier raised by
|
|
482
|
+
# hand is a decision and re-import may only ever raise. Lowering it back
|
|
483
|
+
# silently un-decides it and drops the task out of the review debt it was
|
|
484
|
+
# owed, which is the opposite of what re-import is for.
|
|
485
|
+
RECORDED_TIERS="$(bash "$STATUS" show --json --dir "$DIR" 2>/dev/null |
|
|
486
|
+
jq -r '[.tasks[]? | select(.tier != null) | "\(.id):\(.tier)"] | join(" ")' 2>/dev/null)"
|
|
487
|
+
|
|
488
|
+
printf '%s\n' "$TASKROWS" | while IFS="$SEP" read -r _ id name tier model flips needs offers touches; do
|
|
489
|
+
[ -n "${id:-}" ] || continue
|
|
490
|
+
set -- task "$id" --name "$name" --dir "$DIR"
|
|
491
|
+
# The flip is a plan fact rather than a run decision, so import is
|
|
492
|
+
# authoritative on it both ways. Add-only left a moved flip set on two
|
|
493
|
+
# tasks, and the render then named the wrong milestone.
|
|
494
|
+
if [ "$flips" = "1" ]; then set -- "$@" --flips; else set -- "$@" --no-flips; fi
|
|
495
|
+
if [ -n "$tier" ] && [ "$tier" != "-" ]; then
|
|
496
|
+
recorded=""
|
|
497
|
+
for pair in $RECORDED_TIERS; do
|
|
498
|
+
case "$pair" in "$id:"*) recorded="${pair#*:}" ;; esac
|
|
499
|
+
done
|
|
500
|
+
if [ -z "$recorded" ] || [ "$tier" -gt "$recorded" ]; then
|
|
501
|
+
set -- "$@" --tier "$tier"
|
|
502
|
+
fi
|
|
503
|
+
fi
|
|
504
|
+
case "$HAS_MODEL" in
|
|
505
|
+
*" $id "*) ;;
|
|
506
|
+
*) [ "$model" = "1" ] && set -- "$@" --model cheap ;;
|
|
507
|
+
esac
|
|
508
|
+
# Passed unconditionally, empty included: the graph is a plan fact like
|
|
509
|
+
# the flip, so an edge the plan dropped has to be cleared rather than
|
|
510
|
+
# left behind reporting a contract that no longer exists.
|
|
511
|
+
set -- "$@" --needs "${needs:-}" --offers "${offers:-}" --touches "${touches:-}"
|
|
512
|
+
bash "$STATUS" "$@" || exit 1
|
|
513
|
+
done || exit 1
|
|
514
|
+
imported="$NTASKS"
|
|
515
|
+
|
|
516
|
+
echo "imported $(plural "$imported" task) from $(basename "$PLAN")"
|
|
517
|
+
;;
|
|
518
|
+
|
|
519
|
+
*)
|
|
520
|
+
err "unknown subcommand: $SUB"
|
|
521
|
+
usage
|
|
522
|
+
exit 4
|
|
523
|
+
;;
|
|
524
|
+
esac
|