@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.
@@ -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