@plot-pm/board 0.14.1 → 0.14.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/plot-deliver.sh CHANGED
@@ -419,6 +419,64 @@ decide_transition() { # $1=file → prints "<Phase>\t<record>\t<write|already>"
419
419
  # writes — and appending a second is how one plan came to hold two `Delivered:`
420
420
  # lines (2026-09-01). The domain returns the written record unchanged in that
421
421
  # case, so the phase still flips and nothing is inserted.
422
+ # Would the PARSER read this phase out of the file we are about to land?
423
+ #
424
+ # THE WRITE SUCCEEDING IS NOT THE OUTCOME HOLDING, and that gap is #924. A plan
425
+ # carrying BOTH front matter and a `## Status` block was delivered in a project
426
+ # repo: `flip_phase` wrote `Delivered` into the block, `mv` landed it, the
427
+ # summary said `phase=flipped`, and `plot-plan-meta.sh` went on answering
428
+ # `approved` — because it prefers front matter wherever it exists and reads the
429
+ # block only in the `else if` below. The write took effect on bytes nobody
430
+ # reads.
431
+ #
432
+ # `flip_phase`'s awk matches only inside `section == "status"`. That one guard
433
+ # IS the defect: on a front-matter plan it edits the block and leaves the front
434
+ # matter untouched, and returns 0 for having changed something.
435
+ #
436
+ # SO THE TEST IS WHAT THE PARSER ANSWERS, NEVER WHETHER AWK CHANGED A LINE.
437
+ # Refusing on `flipped=0` would break every re-run of a correct delivery — a
438
+ # plan already carrying `Delivered` flips nothing and is fine. This asks the one
439
+ # question that distinguishes them: read the scratch copy the way every later
440
+ # consumer will read the plan, and compare.
441
+ #
442
+ # IT RUNS ON THE SCRATCH COPY, BEFORE THE `mv`, and the caller passes whichever
443
+ # file that arm is about to land — `$a` on the `recorded=yes` arm, `$b` on the
444
+ # other. Parsing `$a` on the record arm would check content that never reaches
445
+ # the plan. After the `mv` is too late twice over: the script's own header
446
+ # documents exit 0 as *"the plan is Delivered on the default branch"*, so
447
+ # refusing there would exit 1 on a run meeting the documented success
448
+ # condition — and `runAutoDeliver` spawns this detached, logging a non-zero exit
449
+ # to nobody while the plan sits delivered on main.
450
+ #
451
+ # AN UNREADABLE SCRATCH COPY REFUSES. `decide_transition` already takes that
452
+ # line — *"refusing rather than guessing"* — and a file the parser cannot read
453
+ # is exactly the state this gate exists to keep off the plan.
454
+ phase_would_read() { # $1=scratch file $2=expected phase (lowercase) → 0 agrees, 1 refuses
455
+ local scratch="$1" want="$2" m got
456
+ m=$(bash "$script_dir/plot-plan-meta.sh" "$scratch" 2>/dev/null) || m=""
457
+ if [ -z "$m" ]; then
458
+ echo "plot-deliver: $rel — the written file does not parse, so the delivery was not landed." >&2
459
+ echo " Nothing was written. Re-run after fixing the file." >&2
460
+ return 1
461
+ fi
462
+ got=$(printf '%s' "$m" | jq -r '.phase // ""')
463
+ [ "$got" = "$want" ] && return 0
464
+
465
+ # THE REFUSAL NAMES BOTH VALUES AND THE FILE. A message saying only "delivery
466
+ # failed" throws away the half a person acts on: which phase was written, and
467
+ # which one the parser still reads. The cause is named too, because the file
468
+ # holding two records of one fact is the thing to fix — and which format ought
469
+ # to win is a decision this gate deliberately leaves to a person.
470
+ echo "plot-deliver: $rel — wrote phase '$want', but the parser still reads '$got'." >&2
471
+ echo " The plan states its phase in TWO places and they disagree: the write" >&2
472
+ echo " landed in the '## Status' block while front matter takes precedence," >&2
473
+ echo " so the delivery would have reported a success it did not achieve." >&2
474
+ echo " Nothing was written — the plan is unchanged. Remove one of the two" >&2
475
+ echo " records (front matter, or the '## Status' block) and re-run." >&2
476
+ echo " See what the parser reads: $script_dir/plot-plan-meta.sh $rel" >&2
477
+ return 1
478
+ }
479
+
422
480
  write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_report record_report
423
481
  local f="$1" record="$2" recorded="$3" a="$1.plot-phase" b="$1.plot-record" flipped=0
424
482
 
@@ -428,6 +486,11 @@ write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_repo
428
486
  [ -s "$a" ] || { rm -f "$a"; echo "plot-deliver: could not read $rel" >&2; return 1; }
429
487
 
430
488
  if [ "$recorded" = "yes" ]; then
489
+ # THE DRY RUN, on the file this arm is about to land. A refusal discards the
490
+ # scratch copy and leaves the plan byte-identical — and never reaches
491
+ # `record_state_receipt`, which would otherwise license a commit of a state
492
+ # that was refused.
493
+ phase_would_read "$a" delivered || { rm -f "$a"; return 1; }
431
494
  mv "$a" "$f" || { rm -f "$a"; return 1; }
432
495
  record_report="already"
433
496
  else
@@ -438,6 +501,9 @@ write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_repo
438
501
  echo " with no record is invisible to the scan. Fix the section and re-run." >&2
439
502
  return 1
440
503
  fi
504
+ # `$b` AND NOT `$a`: this arm lands the file carrying the record, so `$a`
505
+ # is content that never reaches the plan.
506
+ phase_would_read "$b" delivered || { rm -f "$a" "$b"; return 1; }
441
507
  mv "$b" "$f" || { rm -f "$a" "$b"; return 1; }
442
508
  rm -f "$a"
443
509
  record_report="written"
package/plot-dispatch.sh CHANGED
@@ -56,6 +56,25 @@
56
56
  # tradition of --allow-local: a gate with no exit is one people
57
57
  # route around by never annotating at all. It says so on the
58
58
  # line it overrides, so the override is on the record.
59
+ # --agent <name> dispatch this run's agents under the charter
60
+ # `.plot/charters/<name>.json`. It SETS `PLOT_AGENT`, which was
61
+ # the input all along and which nothing chose: the charter
62
+ # mechanism shipped in v2.17.0 with 16 readers for `harness` and
63
+ # no selector at all, so a kind could be declared and never
64
+ # asked for. The choice is EXPLICIT and never inferred — a
65
+ # matcher reading a plan could guess a kind, and a guess that is
66
+ # usually right produces a fleet whose wrong answers cannot be
67
+ # explained. An already-exported PLOT_AGENT is overridden, since
68
+ # a flag on this run is the more specific answer.
69
+ # AMENDED 2026-09-15: a slice may now DECLARE its kind, and
70
+ # `start_worker` reads it where this flag left `PLOT_AGENT`
71
+ # unset. That is not the matcher refused above — a declaration is
72
+ # a field a person wrote, and nothing here ranks candidates or
73
+ # infers a kind from a slice's contents. The flag still wins,
74
+ # because a flag typed on this run is more specific than a field
75
+ # written when the plan was drafted. A charter this clone does
76
+ # not hold is REPORTED and dispatched anyway; see
77
+ # `plan_declared_agent`.
59
78
  # <slug> the plan to fan out
60
79
  # Output: one line per branch, each optionally followed by an indented
61
80
  # `in flight:` line naming a branch that already holds files, then the
@@ -230,6 +249,7 @@ allow_waiting=0
230
249
  max=0
231
250
  slug=""
232
251
  migrate_yes=0
252
+ agent=""
233
253
  while [ $# -gt 0 ]; do
234
254
  case "$1" in
235
255
  --dry-run) dry_run=1 ;;
@@ -270,17 +290,56 @@ while [ $# -gt 0 ]; do
270
290
  --offline|--no-fetch) offline="--offline" ;;
271
291
  --allow-local) allow_local=1 ;;
272
292
  --allow-waiting) allow_waiting=1 ;;
293
+ # THE VALUE IS REQUIRED AND ALWAYS CONSUMED, which is the opposite of the
294
+ # rule `--stop`, `--restart` and `--start` follow — and the difference is
295
+ # the value's SHAPE rather than a change of mind. Those three take a branch
296
+ # (`*/*`) or a count (digits), each recognisable on sight, so an absent one
297
+ # can be left for the parser. An agent name is a bare word and so is a plan
298
+ # slug: nothing tells `--agent reviewer` from `--agent` followed by the
299
+ # slug. Leaving it unconsumed would let the `*)` arm below silently take
300
+ # the agent name as the plan to dispatch, which reads as "no such plan" and
301
+ # names neither what was asked nor what went wrong. So a missing value
302
+ # REFUSES rather than guessing.
303
+ --agent) agent="${2:?--agent needs a charter name, e.g. --agent reviewer}"
304
+ case "$agent" in
305
+ -*) echo "plot-dispatch: --agent needs a charter name, got '$agent'" >&2; exit 1 ;;
306
+ esac
307
+ shift ;;
273
308
  --max) max="${2:?--max needs a value}"
274
309
  case "$max" in
275
310
  ''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
276
311
  esac
277
312
  shift ;;
278
- -h|--help) sed -n '2,59p' "$0"; exit 0 ;;
313
+ # THE RANGE MOVED WITH THE HEADER IT PRINTS. It ended at `<slug>` and still
314
+ # does; adding `--agent` above pushed that line from 59 to 69, and
315
+ # documenting the plan-declared kind pushed it from 69 to 78. Two records
316
+ # of one fact, and nothing compares them — a stale number here silently
317
+ # truncates the help rather than failing, so it is checked by a test.
318
+ -h|--help) sed -n '2,78p' "$0"; exit 0 ;;
279
319
  *) slug="$1" ;;
280
320
  esac
281
321
  shift
282
322
  done
283
323
 
324
+ # `--agent` SETS THE INPUT THAT ALREADY EXISTED, and it sets it in ONE place.
325
+ #
326
+ # `PLOT_AGENT` was read in three places before this flag — `resolve_launch`'s
327
+ # call in `start_worker`, the `--capabilities` block, and the forwarded export
328
+ # into the worker's environment — and assigned in none: `plot-dispatch.sh` held
329
+ # `PLOT_AGENT="${PLOT_AGENT:-}"`, a pass-through of whatever the operator had
330
+ # already exported. So the selector existed as a variable nothing chose.
331
+ #
332
+ # ONE ASSIGNMENT RATHER THAN A THREADED ARGUMENT. Every reader already asks
333
+ # `${PLOT_AGENT:-}`; exporting here reaches all three with no further change and
334
+ # leaves no call site that could be missed when a fourth reader is added. The
335
+ # flag's whole meaning is "set PLOT_AGENT", which is also what keeps it from
336
+ # becoming a second selector competing with the variable.
337
+ #
338
+ # THE FLAG WINS OVER AN INHERITED VALUE. A dispatched worker runs with its own
339
+ # `PLOT_AGENT` in the environment, so a run launched from inside one would
340
+ # otherwise inherit a kind nobody asked for on this dispatch.
341
+ [ -n "$agent" ] && export PLOT_AGENT="$agent"
342
+
284
343
  # THE TWO PRECONDITIONS OF A DISPATCH, and a sourcing test has neither.
285
344
  # `PLOT_DISPATCH_SOURCED=1` is taking the definitions below rather than running
286
345
  # a dispatch, so it has no slug and often no git repository; the guard that
@@ -930,6 +989,42 @@ start_worker() {
930
989
  local branch="$1" wt="$2"
931
990
  local cmd
932
991
 
992
+ # THE PLAN IS THE DEFAULT AND THE FLAG IS THE OVERRIDE.
993
+ #
994
+ # `[ -z "${PLOT_AGENT:-}" ]` is the whole precedence rule: `--agent` exported
995
+ # the variable before this line was reached, so a flag typed on this run stops
996
+ # the plan being read at all. A field written when the plan was drafted is the
997
+ # less specific answer — the same order `--agent` already applies to an
998
+ # inherited `PLOT_AGENT`.
999
+ #
1000
+ # ONE ASSIGNMENT, FOR THE REASON `--agent` GIVES ONE FIELD OVER. Four readers
1001
+ # ask `${PLOT_AGENT:-}` — `resolve_launch` below, the capabilities block, the
1002
+ # manifest, and the export into the worker environment — so setting the
1003
+ # variable reaches all four and leaves no call site to miss when a fifth
1004
+ # appears.
1005
+ #
1006
+ # A FREE AGENT HAS NO BRANCH and so has no plan to read: `--start` calls this
1007
+ # with `branch` empty, and `plan_declared_agent` returns 1 on it immediately.
1008
+ if [ -z "${PLOT_AGENT:-}" ] && [ -n "$branch" ]; then
1009
+ local declared charter
1010
+ if declared=$(plan_declared_agent "$branch"); then
1011
+ export PLOT_AGENT="$declared"
1012
+ # A MISSING CHARTER IS REPORTED AND DISPATCHED ANYWAY. Said HERE rather
1013
+ # than only in the manifest, because this is where a person watching the
1014
+ # run can still act on it: the launch below falls back to the repo default
1015
+ # and is otherwise silent about a declaration that reached nothing.
1016
+ charter=$(charter_file_for "$declared")
1017
+ if [ -n "$charter" ] && [ ! -f "$charter" ]; then
1018
+ echo " $branch declares agent '$declared', and no charter answers to that name"
1019
+ echo " looked for $charter"
1020
+ echo " starting it on the repo's 'Worker command' — a plan written where that"
1021
+ echo " charter exists must stay dispatchable on a clone that lacks it."
1022
+ else
1023
+ echo " $branch declares agent '$declared' — its plan selected the charter"
1024
+ fi
1025
+ fi
1026
+ fi
1027
+
933
1028
  # RESOLVED BEFORE `Worker command`, because the charter is the more specific
934
1029
  # answer and the config key is the fallback rather than the sole source.
935
1030
  #
@@ -1359,6 +1454,78 @@ start_worker() {
1359
1454
  return 0
1360
1455
  }
1361
1456
 
1457
+ # WHICH KIND OF AGENT A BRANCH'S PLAN DECLARES — the selector nobody has to type.
1458
+ #
1459
+ # `--agent` was the only one, and an operator is the only thing that can type a
1460
+ # flag. The registry hands a queued slice to a free agent with no `--agent`
1461
+ # anywhere in the path, so an unattended fleet ran every slice as the same
1462
+ # undifferentiated worker. A plan that names the kind is what reaches a dispatch
1463
+ # nobody is watching.
1464
+ #
1465
+ # IT ANSWERS AND EXPORTS NOTHING. The caller decides, because the precedence is
1466
+ # the caller's: `--agent` already won over an inherited `PLOT_AGENT` and must win
1467
+ # over this too — a flag typed on this run is more specific than a field written
1468
+ # when the plan was drafted.
1469
+ #
1470
+ # THE PLAN DIRECTORY IS SEARCHED, NOT THE ACTIVE INDEX, and the candidates are
1471
+ # grepped before any is parsed — both `plot-open-pr.sh`'s rules, measured there:
1472
+ # a plan governing a branch may carry no symlink, and parsing 253 plans took
1473
+ # 103 s against 0.6 s for one `grep -lF` over the directory. A branch name is a
1474
+ # literal string, so a plan that does not contain it cannot name it.
1475
+ #
1476
+ # ABSENCE IS THE COMMON ANSWER AND NEVER AN ERROR. Every plan on the estate
1477
+ # names no kind, a `--restart` may run on a branch no plan names at all, and the
1478
+ # annotation is optional by design. All three print nothing and return 1.
1479
+ plan_declared_agent() { # $1 = branch → prints the declared kind, or nothing
1480
+ local branch="$1" plan_dir root cands f found
1481
+ [ -n "$branch" ] || return 1
1482
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1
1483
+ plan_dir=$("$script_dir/plot-config.sh" get "Plan directory" "docs/plans/")
1484
+ case "$plan_dir" in /*) ;; *) plan_dir="$root/$plan_dir" ;; esac
1485
+
1486
+ cands=$(grep -lF "$branch" "$plan_dir"*.md 2>/dev/null || true)
1487
+ for f in $cands; do
1488
+ [ -e "$f" ] || continue
1489
+ # The PARSER answers, never a grep of the plan file. A `grep agent:` would
1490
+ # read the marker out of prose documenting it and out of a DIFFERENT branch
1491
+ # line in the same plan — the annotation binds to one branch, and only the
1492
+ # parser knows which.
1493
+ found=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null \
1494
+ | PLOT_WANT_BRANCH="$branch" node -e '
1495
+ let s = "";
1496
+ process.stdin.on("data", (d) => (s += d)).on("end", () => {
1497
+ let meta;
1498
+ try { meta = JSON.parse(s); } catch { return; }
1499
+ const want = process.env.PLOT_WANT_BRANCH;
1500
+ for (const wave of meta.waves ?? []) {
1501
+ for (const b of wave.branches ?? []) {
1502
+ // PRESENCE, not truthiness. The parser emits no `agent` key where
1503
+ // the plan declares none, which is every plan on the estate.
1504
+ if (b.branch === want && "agent" in b) { console.log(b.agent); return; }
1505
+ }
1506
+ }
1507
+ });
1508
+ ') || found=""
1509
+ if [ -n "$found" ]; then printf '%s\n' "$found"; return 0; fi
1510
+ done
1511
+ return 1
1512
+ }
1513
+
1514
+ # DOES THIS CLONE HOLD THE CHARTER? Answered so a MISSING one can be REPORTED.
1515
+ #
1516
+ # A DELIBERATE ASYMMETRY, and the plan settles it: `resolve_launch` refuses a
1517
+ # charter it cannot BELIEVE (malformed) and a harness not on PATH, and both
1518
+ # stay. A charter that simply does not EXIST is the adoption case — a plan
1519
+ # written where `reviewer` is declared, dispatched on a clone that declares
1520
+ # nothing — and refusing it would make that plan undispatchable on every such
1521
+ # clone. So the kind still travels, the launch falls back to the repo default,
1522
+ # and the run SAYS which name it looked for.
1523
+ charter_file_for() { # $1 = agent name → prints the path it would read
1524
+ local name="$1" root
1525
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1
1526
+ printf '%s/.plot/charters/%s.json\n' "$root" "$name"
1527
+ }
1528
+
1362
1529
  # `PLOT_DISPATCH_SOURCED=1` STOPS HERE, so a test can take `resolve_launch` and
1363
1530
  # `start_worker` without dispatching anything — `plot-worker-loop.sh` states
1364
1531
  # this idiom for `resolve_prompt_file`, and this is the same one applied to the
package/plot-host.sh CHANGED
@@ -716,8 +716,41 @@ EOF
716
716
  # failed — Jenkins is unreachable (`jen auth status` says so, while EXITING
717
717
  # 0 — Done-when 4: the wording decides, never `$?`), or the listing
718
718
  # was empty/garbled. `map` is {}; the caller renders rows `unknown`.
719
- # unknown — the auth wording was unrecognised; degrade to failure-shaped
720
- # (cannot verify), never to ok. `map` is {}.
719
+ # unknown — the auth wording was unrecognised, or the configured job is a
720
+ # SHAPE NOBODY MEASURED; degrade to failure-shaped (cannot verify),
721
+ # never to ok. `map` is {}.
722
+ #
723
+ # THE JOB'S SHAPE DECIDES THE VERB, and reading the shape off the wrong object
724
+ # is the defect this function carried until 2026-09-15.
725
+ #
726
+ # `job list` enumerates a CONTAINER'S CHILDREN. A `WorkflowMultiBranchProject`
727
+ # has one child per branch, so listing it yields exactly the branch→colour map
728
+ # below. A plain `WorkflowJob` has no children, so the same call yields `null` —
729
+ # not an error, not an empty array — and the `type=="array"` guard reported
730
+ # `failed`, which is the word for an unreachable host. Measured live 2026-09-15
731
+ # on `Quatico.Webseite/quaweb-website`: `job list quaweb/continuous-deploy`
732
+ # answered `null` while `job view` on the same path answered `color blue`,
733
+ # `lastBuild #938 SUCCESS`. A healthy, signed-in, correctly declared pipeline
734
+ # read as *the connector cannot be asked*.
735
+ #
736
+ # THE DECIDING `_class` IS THE CONFIGURED JOB'S OWN, AND IT IS NOT IN THE
737
+ # LISTING THIS FUNCTION ALREADY PERFORMS. A child's `_class` describes the
738
+ # CHILD: measured live, every child of the multibranch `quaweb/continuous-build`
739
+ # carries `...job.WorkflowJob`, and this repository's own fixture agrees. So
740
+ # reading `.[0]._class` from the listing would read a healthy multibranch job as
741
+ # plain, route it to `job view`, and break the half that works today — while
742
+ # every gate still passed. That mistake sank an earlier draft of the plan.
743
+ #
744
+ # SO `job view` IS ASKED FIRST, and it answers BOTH questions in ONE call: the
745
+ # job's own `_class`, and — for a plain job — the `color` and `lastBuild` that
746
+ # are its state. The multibranch path then makes the single `job list` it has
747
+ # always made, so a multibranch refresh costs two calls rather than one per
748
+ # branch, and the branch→checks map it returns is byte-identical.
749
+ #
750
+ # EXACTLY TWO SHAPES ARE READ, and anything else is `unknown` rather than a
751
+ # guess. A `FreeStyleProject` has a `color` and would be readable; it still
752
+ # reports `unknown`, which is the honest word — *a shape nobody measured*. It is
753
+ # deliberately NOT `failed`, which claims the host did not answer.
721
754
  jenkins_build_map() {
722
755
  local instance="$1"
723
756
  local slug job
@@ -751,6 +784,67 @@ jenkins_build_map() {
751
784
  printf '{"status":"unknown","map":{}}\n'; return 0
752
785
  fi
753
786
 
787
+ # THE SHAPE, READ FROM THE CONFIGURED JOB ITSELF. `job view` returns that
788
+ # job's own `_class` — never a child's — plus the `color` and `lastBuild` a
789
+ # plain job's state is made of. One call, two answers.
790
+ #
791
+ # A BARE-HOST INSTANCE NAMES NO JOB, so there is nothing to view: `job` is
792
+ # empty, the root scope has no `_class` of its own, and the multibranch path
793
+ # below already handles it by listing at the root. Probing with an empty path
794
+ # would ask about the instance rather than about a job.
795
+ local shape="" view_out=""
796
+ if [ -n "$job" ]; then
797
+ view_out=$(jen -I "$slug" job view "$job" --json 2>&1) || true
798
+ if [ -n "$view_out" ]; then
799
+ shape=$(printf '%s' "$view_out" | jq -r 'if type=="object" then (._class // "") else "" end' 2>/dev/null || echo "")
800
+ fi
801
+ fi
802
+
803
+ case "$shape" in
804
+ # A PLAIN PIPELINE — the case that reported `failed` until 2026-09-15. Its
805
+ # state is already in hand: `job view` answered it, and no `job list`
806
+ # follows, because listing a job with no children is what returned `null`.
807
+ #
808
+ # THE BRANCH KEY IS THE JOB PATH'S LAST SEGMENT. A plain job builds one
809
+ # thing and Jenkins names no branch for it, so there is no branch→colour
810
+ # map to build. Keying on the job's own name is what lets `.map[$branch]`
811
+ # find it — `runs` reads that key, and the op's caller asks by the name the
812
+ # instance declares.
813
+ *'.WorkflowJob')
814
+ printf '%s' "$view_out" | jq -c --arg job "$job" '
815
+ def color_to_checks:
816
+ if . == null or . == "" then "none"
817
+ elif endswith("_anime") then "pending"
818
+ elif . == "blue" then "green"
819
+ elif . == "red" or . == "yellow" then "failing"
820
+ else "none"
821
+ end;
822
+ ($job | split("/") | last) as $name
823
+ | { status: "ok",
824
+ map: { ($name): { color: .color,
825
+ checks: (.color | color_to_checks),
826
+ job: $job } } }
827
+ ' 2>/dev/null || printf '{"status":"failed","map":{}}\n'
828
+ return 0
829
+ ;;
830
+ *'.WorkflowMultiBranchProject')
831
+ : # fall through to the listing below — the path that has always worked
832
+ ;;
833
+ '')
834
+ # `job view` answered nothing usable. NOT a shape verdict: an instance
835
+ # naming no job reaches here by design, and so does a `jen` too old to
836
+ # know the verb. Fall through and let the listing decide, which is
837
+ # exactly what this function did before the probe existed.
838
+ :
839
+ ;;
840
+ *)
841
+ # A SHAPE NOBODY MEASURED. `unknown` says that; `failed` would claim
842
+ # Jenkins did not answer, when it answered clearly and said something
843
+ # this reader has never been taught to read.
844
+ printf '{"status":"unknown","map":{}}\n'; return 0
845
+ ;;
846
+ esac
847
+
754
848
  # One call, every branch — the spike's whole point (Done-when 5).
755
849
  local out=""
756
850
  out=$(jen -I "$slug" job list ${job:+"$job"} --json 2>&1) || true
package/plot-plan-meta.sh CHANGED
@@ -150,6 +150,16 @@
150
150
  # BECAUSE annotations already work in both slice dialects from
151
151
  # one block of code — a field line would need two spellings,
152
152
  # and the template writes the list dialect.
153
+ # `<!-- agent: reviewer -->` names which KIND of agent this
154
+ # slice needs, reported as `waves[].branches[].agent` and read
155
+ # by plot-dispatch.sh where `--agent` is absent. OPTIONAL, and
156
+ # the key is ABSENT where none was written, never "" — dispatch
157
+ # reads presence, so a blank would send it looking for a charter
158
+ # called "". The value runs to the closing marker, as `builds:`
159
+ # does. It CANNOT validate its value the way `waits:` does: a
160
+ # charter name is a bare word with no structure to check, so a
161
+ # template documents the marker inside an outer comment block,
162
+ # where this pattern cannot see it.
153
163
  # prs PR numbers, sorted and unique, read from EITHER spelling:
154
164
  # `→ #NNN` / `→ owner/repo#NNN` links in the `## Branches`
155
165
  # section, OR `PR: #NNN` in a `## Waves` `### ` heading. The
@@ -424,6 +434,7 @@ function reset_state() {
424
434
  delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
425
435
  delete waits_of; delete waits_set
426
436
  delete builds_of; delete builds_set
437
+ delete agent_of; delete agent_set
427
438
  delete started; n_started = 0
428
439
  fm_changelog = ""
429
440
  delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
@@ -584,6 +595,13 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
584
595
  # names no deliverable emits no key, so a consumer reads a name or
585
596
  # nothing. An empty string would read as a deliverable called "".
586
597
  if (builds_set[i] == 1) out = out ",\"builds\":\"" jesc(builds_of[i]) "\""
598
+ # ABSENT, NOT EMPTY, the rule the three annotations above already keep.
599
+ # Dispatch reads the PRESENCE of this key to decide whether the plan names
600
+ # a kind at all, so an `"agent":""` would read as a charter called "" and
601
+ # send it looking for `.plot/charters/.json`.
602
+ # NOTE: no apostrophe may appear in this awk region — the whole program is
603
+ # one single-quoted shell string, and one closes it mid-comment.
604
+ if (agent_set[i] == 1) out = out ",\"agent\":\"" jesc(agent_of[i]) "\""
587
605
  out = out "}"
588
606
  first = 0
589
607
  }
@@ -1012,6 +1030,44 @@ section == "slices" && slice_shape != "heading" {
1012
1030
  if (waits_note ~ "^(" PREFIXES ")/[^ \t]+$") has_waits = 1
1013
1031
  else waits_note = ""
1014
1032
  }
1033
+ # WHICH KIND OF AGENT THIS SLICE NEEDS: `<!-- agent: reviewer -->`.
1034
+ #
1035
+ # `--agent <name>` was the only selector and only an operator could type it.
1036
+ # `plot-registryd` hands a queued slice to a free agent with no `--agent`
1037
+ # anywhere in the path, so an unattended fleet ran every slice as the same
1038
+ # undifferentiated worker. The declaration has to live in the PLAN to reach a
1039
+ # dispatch nobody is watching.
1040
+ #
1041
+ # PER-BRANCH, NEVER A `## Status` FIELD. Every Status field is plan-level, so
1042
+ # one there could declare a single kind for a plan with several slices — and a
1043
+ # reviewer slice beside an implementer slice is the population this is for.
1044
+ #
1045
+ # The value runs to the closing marker, the way `builds:` does and unlike
1046
+ # `waits:`. A charter name is a bare word today, so both rules agree; this one
1047
+ # is chosen because it does not silently truncate if a name grows a space.
1048
+ #
1049
+ # IT CANNOT VALIDATE ITS VALUE, and that is the difference from `waits:` above.
1050
+ # A prerequisite has branch-prefix structure, which is what keeps a SYNTAX
1051
+ # EXAMPLE in prose from reading as a declaration; a charter name is a bare word
1052
+ # with no structure to check. What protects the templates instead is NESTING:
1053
+ # their documentation sits inside an outer `<!-- ... -->` block, so the inner
1054
+ # `agent:` carries no `<!--` of its own and this pattern never matches it —
1055
+ # the same accident of shape that already keeps `builds:` out of the parse of
1056
+ # a template. Documenting it as a bare `<!-- agent: reviewer -->` line would
1057
+ # hand a kind to every plan created from that template. A test pins it.
1058
+ #
1059
+ # `has_agent` carries presence separately from the value, as all three
1060
+ # annotations before it do: a slice naming no kind emits no key, so dispatch
1061
+ # reads a name or nothing and never a blank string that looks like one.
1062
+ agent_note = ""
1063
+ has_agent = 0
1064
+ if ($0 ~ /<!--[ \t]*agent:[ \t]*/) {
1065
+ _ag = $0
1066
+ sub(/^.*<!--[ \t]*agent:[ \t]*/, "", _ag)
1067
+ sub(/[ \t]*-->.*$/, "", _ag)
1068
+ agent_note = trim(_ag)
1069
+ if (agent_note != "") has_agent = 1
1070
+ }
1015
1071
  # ONE LIST ITEM, AT MOST ONE CLAIM — an `if`, not the `while` this was.
1016
1072
  #
1017
1073
  # The old loop walked the line taking every backticked name on it, which is
@@ -1051,6 +1107,10 @@ section == "slices" && slice_shape != "heading" {
1051
1107
  waits_set[n_branches] = has_waits
1052
1108
  builds_of[n_branches] = builds_note
1053
1109
  builds_set[n_branches] = has_builds
1110
+ # The kind travels with the branch, presence tracked separately so a slice
1111
+ # declaring none emits no key.
1112
+ agent_of[n_branches] = agent_note
1113
+ agent_set[n_branches] = has_agent
1054
1114
  ordered_b[n_branches] = b
1055
1115
  }
1056
1116
  line = $0
@@ -1188,6 +1248,19 @@ section == "slices" && slice_shape == "heading" {
1188
1248
  if (waits_note ~ "^(" PREFIXES ")/[^ \t]+$") has_waits = 1
1189
1249
  else waits_note = ""
1190
1250
  }
1251
+ # The agent kind, read exactly as the list-item spelling reads it. Both
1252
+ # dialects emit the same waves[], so a field added to one only would break that
1253
+ # contract the first time a plan migrated. See the list-item block for why the
1254
+ # value runs to the closing marker and why this one cannot validate itself.
1255
+ agent_note = ""
1256
+ has_agent = 0
1257
+ if ($0 ~ /<!--[ \t]*agent:[ \t]*/) {
1258
+ _ag = $0
1259
+ sub(/^.*<!--[ \t]*agent:[ \t]*/, "", _ag)
1260
+ sub(/[ \t]*-->.*$/, "", _ag)
1261
+ agent_note = trim(_ag)
1262
+ if (agent_note != "") has_agent = 1
1263
+ }
1191
1264
 
1192
1265
  # The branch is the `Branch:` value, matched against the known prefixes exactly
1193
1266
  # as the old shape matched the backticked name. Written unquoted in the heading
@@ -1211,6 +1284,8 @@ section == "slices" && slice_shape == "heading" {
1211
1284
  waits_set[n_branches] = has_waits
1212
1285
  builds_of[n_branches] = builds_note
1213
1286
  builds_set[n_branches] = has_builds
1287
+ agent_of[n_branches] = agent_note
1288
+ agent_set[n_branches] = has_agent
1214
1289
  ordered_b[n_branches] = b
1215
1290
  }
1216
1291