@plot-pm/board 0.13.0 → 0.14.1

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/plot-reap.sh CHANGED
@@ -100,6 +100,15 @@
100
100
  # repo that configures one the reaper matched nothing and reported
101
101
  # `reapable=0 kept=0` over nine trees.
102
102
  #
103
+ # A TREE NEITHER TEST PLACES IS REPORTED `unknown` AND STILL NOT TOUCHED.
104
+ # The recognition test above is unchanged in strictness — this adds no tree to
105
+ # the reapable population and offers no removal. What it removes is the
106
+ # SILENCE: until 2026-09-10 such a tree hit `continue`, so it was not reaped,
107
+ # not kept, not counted and not named, and that is how ten finished desks went
108
+ # unnoticed while the reaper reported three. Only a tree under the configured
109
+ # `Worktree root` qualifies; a hand-made checkout elsewhere stays silent,
110
+ # because a person's tree must never become an instruction to remove it.
111
+ #
103
112
  # THE MANIFEST GOES WITH THE WORKTREE. `readAgentRegistry` renders one row per
104
113
  # manifest, so a reap that removes only the checkout converts a finished agent
105
114
  # into an `unknown` row naming a directory that no longer exists — measured
@@ -231,6 +240,53 @@ if [ -r "$CONFIG" ]; then
231
240
  fi
232
241
  fi
233
242
 
243
+ # Where the DESKS live, which is the same key resolved for a different question.
244
+ #
245
+ # `LOG_DIR` and this answer two things: `LOG_DIR` is where a finished branch's
246
+ # log files are swept from, and this is the directory a tree must sit under to
247
+ # be a candidate desk at all. A repository with NO configured root has no
248
+ # `.worktrees/` and its desks are named `plot-wt-*` beside the repo — so this
249
+ # stays EMPTY there rather than defaulting to the parent, because the parent
250
+ # holds every sibling checkout a person ever made and calling those candidate
251
+ # desks is the over-broad reading this slice must not introduce.
252
+ #
253
+ # IT IS RESOLVED AGAINST THE MAIN CHECKOUT, NOT `$ROOT`, and that is the one
254
+ # place in this script where the two differ on purpose. `git rev-parse
255
+ # --show-toplevel` answers *this* worktree, so a reaper run from inside a desk
256
+ # resolves `.worktrees` to a directory beneath that desk — which does not
257
+ # exist, so every tree reads as unplaceable-but-elsewhere and the reading is
258
+ # silently empty. Measured 2026-09-10 from this worktree: `$ROOT/.worktrees`
259
+ # named `.../plot-wt-feature-a-finished-desk-is-a-finding/.worktrees` while
260
+ # every desk sits under `.../plot/.worktrees`. A dispatched agent runs the
261
+ # reaper from exactly there, so the wrong answer would be the usual one.
262
+ #
263
+ # `--git-common-dir` is shared by every worktree of one repository, and its
264
+ # parent is the primary checkout — which is what `plot-dispatch.sh` composes
265
+ # desk paths from when it creates them. Same directory, same key, so a
266
+ # creation and this reading cannot disagree.
267
+ #
268
+ # `LOG_DIR` above keeps `$ROOT` untouched: it is this script's established
269
+ # behaviour with its own callers, and changing where a sweep DELETES from is a
270
+ # blast radius rather than a reading. Named here rather than fixed silently.
271
+ WT_ROOT=""
272
+ if [ -r "$CONFIG" ]; then
273
+ d=$(bash "$CONFIG" get "Worktree root" "" 2>/dev/null) || d=""
274
+ if [ -n "$d" ]; then
275
+ case "$d" in
276
+ /*) WT_ROOT="$d" ;;
277
+ *)
278
+ # Falls back to `$ROOT` where git cannot answer, which is the same
279
+ # directory on a single-checkout repository and the only one available.
280
+ main_checkout=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)") \
281
+ || main_checkout="$ROOT"
282
+ [ -d "$main_checkout" ] || main_checkout="$ROOT"
283
+ WT_ROOT="$main_checkout/$d"
284
+ ;;
285
+ esac
286
+ WT_ROOT="${WT_ROOT%/}"
287
+ fi
288
+ fi
289
+
234
290
  # The files ONE branch's agent run leaves beside its worktree, removed with it.
235
291
  #
236
292
  # WHICH LOG THIS IS, since the plan says "the dispatcher log" and the estate
@@ -326,7 +382,7 @@ manifest_for() {
326
382
  return 1
327
383
  }
328
384
 
329
- reap=0; kept=0; removed=0; cleared=0; vanished=0
385
+ reap=0; kept=0; removed=0; cleared=0; vanished=0; unplaced=0
330
386
  printf '%-8s %-52s %s\n' "verdict" "branch" "why"
331
387
 
332
388
  while IFS=$'\t' read -r wt br prunable; do
@@ -381,8 +437,54 @@ while IFS=$'\t' read -r wt br prunable; do
381
437
  # and whose path does not match goes unrecognised — which fails by
382
438
  # REFUSING, the same safe direction the path test failed in, and for one
383
439
  # tree instead of all of them.
384
- if [ ! -f "$wt/.plot-worker.pid" ]; then
385
- case "$wt" in *"/plot-wt-"*) ;; *) continue ;; esac
440
+ #
441
+ # THE REFUSAL IS NOW MEASURED RATHER THAN PERFORMED, and that is this
442
+ # slice's whole subject. The two tests below produce READINGS; nothing
443
+ # here decides what the readings mean. A `case` deciding it is where the
444
+ # 2026-08-30 defect lived — one line, in shell, that no test could reach,
445
+ # shadowing `ReapEvidence.isDispatchTree`, declared for the same question.
446
+ #
447
+ # `is_dispatch_tree` is UNCHANGED in strictness. Widening it would move
448
+ # trees into the reaper's population, which is a blast radius, not a
449
+ # report — and turning a person's checkout into a removal instruction is
450
+ # worse than the silence being fixed.
451
+ #
452
+ # `unclassified` is the reading that was missing: a tree sitting under
453
+ # the configured `Worktree root` that neither test placed. It looks like a
454
+ # desk by location and is recognised as one by nothing, so a person is the
455
+ # only thing that can say. Measured 2026-09-10 on this estate:
456
+ # `.worktrees/feature-one-monitor-watches-the-slice`, 5 unpushed commits,
457
+ # a PLOT-BLOCKED marker and no PR, reported by nothing.
458
+ is_dispatch_tree=false
459
+ if [ -f "$wt/.plot-worker.pid" ]; then
460
+ is_dispatch_tree=true
461
+ else
462
+ case "$wt" in *"/plot-wt-"*) is_dispatch_tree=true ;; esac
463
+ fi
464
+
465
+ unclassified=false
466
+ if [ "$is_dispatch_tree" = false ] && [ -n "$WT_ROOT" ]; then
467
+ case "$wt" in "$WT_ROOT"/*) unclassified=true ;; esac
468
+ fi
469
+
470
+ # A tree that is NEITHER is silent, and that silence is correct: it is a
471
+ # hand-made checkout outside the population, not a tree that failed a test.
472
+ # Measured here — four `/tmp` baseline and scratchpad checkouts, each of
473
+ # which carries a `.plot/` directory because the repo tracks one, which is
474
+ # why the presence of `.plot/` cannot be the test.
475
+ if [ "$is_dispatch_tree" = false ] && [ "$unclassified" = false ]; then
476
+ continue
477
+ fi
478
+
479
+ # Reported and NOT judged. Every reading below measures something inside a
480
+ # desk, and this is a tree nothing has established is one — so the loop says
481
+ # what it found and moves on, rather than asking the rule a question about a
482
+ # population the rule excludes. It counts, because a refusal that counts is
483
+ # the difference between *nothing to clean* and *nothing was looked at*.
484
+ if [ "$unclassified" = true ]; then
485
+ printf '%-8s %-52s %s\n' "unknown" "$short" \
486
+ "under $(basename "$WT_ROOT")/, no worker pid and no recognised name — needs a person"
487
+ unplaced=$((unplaced+1)); continue
386
488
  fi
387
489
 
388
490
  # THE READINGS. Everything from here to the rule call MEASURES; nothing
@@ -940,5 +1042,5 @@ fi
940
1042
  # which is why `plot-release-refs.sh` deletes those, plan-scoped, under its own
941
1043
  # licence and its own five guards. The asymmetry between the kinds is the whole
942
1044
  # safety argument and it stays.
943
- echo "summary: reapable=$reap removed=$removed kept=$kept vanished=$vanished cleared=$cleared branches=$swept_branches branches_deleted=$deleted_branches branches_kept=$kept_branches claims=$swept_claims claims_deleted=$deleted_claims claims_kept=$kept_claims dirty_trees=$dirty_trees dry_run=$DRY"
1045
+ echo "summary: reapable=$reap removed=$removed kept=$kept vanished=$vanished unplaced=$unplaced cleared=$cleared branches=$swept_branches branches_deleted=$deleted_branches branches_kept=$kept_branches claims=$swept_claims claims_deleted=$deleted_claims claims_kept=$kept_claims dirty_trees=$dirty_trees dry_run=$DRY"
944
1046
  exit 0
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env bash
2
+ # The receipt a lifecycle write leaves behind, and the ONE place it is written
3
+ # and read — sourced, not run, by the scripts that own a `State:` line and by
4
+ # `plot-state-gate.sh`, which refuses every other writer.
5
+ #
6
+ # record_state_receipt <path> <value> # the owning script, after its write
7
+ # receipt_clears <path> <value> # the gate, before it refuses
8
+ #
9
+ # IT IS ALSO RUNNABLE, and that is the named escape:
10
+ #
11
+ # plot-state-receipt.sh --unowned <path> <value> <reason>
12
+ #
13
+ # for the three writes that have NO owning script today. `/plot-approve` step 3b
14
+ # writes the phase by hand under `Review: in-session` and `Review: ballot` —
15
+ # `plot-approve.sh:190` refuses both by name, because a script cannot stand in
16
+ # for a human reviewer or read a ballot. `/plot-release` writes `State:
17
+ # Released`, and `/plot-reject` writes Delivered -> Approved; `setPlanPhase`
18
+ # does not exist, so neither has a script to route to.
19
+ #
20
+ # A GATE WITH NO EXIT IS ONE PEOPLE ROUTE AROUND — `plot-dispatch.sh`'s
21
+ # `--allow-local` in the same tradition, and the reason this slice waited at all:
22
+ # a gate refusing the only available method stops work rather than routing it.
23
+ #
24
+ # THE ESCAPE IS COUNTABLE, WHICH IS THE POINT. The reason is required and it is
25
+ # recorded, so each use is a line naming a routing gap the later slices of
26
+ # `the-master-agent-uses-the-controllers` close: `/plot-reject` becomes a
27
+ # controller command, `/plot-release` asks a controller for its verdict. When
28
+ # those land, their scripts record their own receipts and the escape stops being
29
+ # reached for. Until then it is the difference between a gap that is visible and
30
+ # a gate that is turned off.
31
+ #
32
+ # WHY A RECEIPT RATHER THAN A COMMIT MESSAGE. `plot-state-gate.sh` sees a
33
+ # `git commit`, and every fact reachable from there — the message, the branch,
34
+ # the author — is one an agent types. A receipt is not: it names the file and
35
+ # the value the owning script decided, and the only way to produce a matching
36
+ # one is to have run that script. That is the difference between a gate and a
37
+ # rule, and this whole plan exists because a rule was walked past three times in
38
+ # one afternoon.
39
+ #
40
+ # WHAT IT DOES NOT PROVE. The receipt says *this value was written here by an
41
+ # owner*; it does not say the working tree still holds only that write. An agent
42
+ # that runs `/plot-approve` and then edits the same line again inside the same
43
+ # commit is not caught, and no receipt could catch it — the gate reads the
44
+ # staged value, so a second edit changes the value and the receipt stops
45
+ # matching. Only a re-write to the SAME value would pass, and that write is a
46
+ # no-op.
47
+ #
48
+ # MACHINE-LOCAL AND UNTRACKED. `.plot/state/` is gitignored for the reason
49
+ # `plot-boardctl.sh:83` gives: a receipt is true on the machine that made it,
50
+ # and a receipt travelling in a commit would clear the gate on every other
51
+ # checkout that pulled it.
52
+ #
53
+ # A RECEIPT IS SPENT WHEN IT CLEARS. `receipt_clears` deletes the one it
54
+ # matched, so a single `/plot-approve` licenses a single commit. Leaving it
55
+ # would let one approval clear a `sed` over the same line a week later.
56
+ #
57
+ # IT FAILS TOWARD REFUSING, and that is the opposite of `plot-phase-gate.sh`'s
58
+ # choice, deliberately: an unwritable state directory means no receipt exists,
59
+ # so `receipt_clears` answers false and the gate refuses with the command to
60
+ # run. The blast radius is one commit an operator repeats through the owning
61
+ # script, against a phase gate whose refusal would cost every commit in the
62
+ # repository.
63
+
64
+ # Where receipts live. One file per receipt, named by a hash of the path, so two
65
+ # lifecycle writes in one session never overwrite each other.
66
+ _receipt_dir() {
67
+ local root
68
+ root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
69
+ printf '%s\n' "$root/.plot/state/state-receipts"
70
+ }
71
+
72
+ # The receipt's identity is the repo-relative path. Hashed rather than
73
+ # flattened: a path is not a filename, and `docs/plans/x.md` and `docs-plans/x.md`
74
+ # must not collide.
75
+ _receipt_file() { # $1=repo-relative path
76
+ local dir
77
+ dir="$(_receipt_dir)" || return 1
78
+ printf '%s/%s\n' "$dir" "$(printf '%s' "$1" | git hash-object --stdin 2>/dev/null)"
79
+ }
80
+
81
+ # $1 = path (absolute or relative), $2 = the value written.
82
+ record_state_receipt() {
83
+ local path="$1" value="$2" root rel file
84
+ root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0
85
+ rel="$(_repo_relative "$path" "$root")" || return 0
86
+ file="$(_receipt_file "$rel")" || return 0
87
+ mkdir -p "$(dirname "$file")" 2>/dev/null || return 0
88
+ printf '%s\t%s\n' "$rel" "$value" > "$file" 2>/dev/null || return 0
89
+ return 0
90
+ }
91
+
92
+ # $1 = repo-relative path, $2 = the staged value. Prints nothing; exit 0 means
93
+ # an owner wrote exactly this, and the receipt is spent.
94
+ receipt_clears() {
95
+ local rel="$1" value="$2" file recorded
96
+ file="$(_receipt_file "$rel")" || return 1
97
+ [ -f "$file" ] || return 1
98
+ recorded="$(cut -f2 <"$file" 2>/dev/null)" || return 1
99
+ [ "$recorded" = "$value" ] || return 1
100
+ rm -f "$file" 2>/dev/null
101
+ return 0
102
+ }
103
+
104
+ # A path as git names it. `git rev-parse` is not used: the file may be a scratch
105
+ # copy that has already been `mv`ed, and this must work on a path alone.
106
+ _repo_relative() { # $1=path $2=root
107
+ local path="$1" root="$2" dir base
108
+ case "$path" in
109
+ /*) ;;
110
+ *) path="$PWD/$path" ;;
111
+ esac
112
+ dir="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)" || return 1
113
+ base="$(basename "$path")"
114
+ root="$(cd "$root" 2>/dev/null && pwd -P)" || return 1
115
+ case "$dir" in
116
+ "$root") printf '%s\n' "$base" ;;
117
+ "$root"/*) printf '%s/%s\n' "${dir#"$root"/}" "$base" ;;
118
+ *) return 1 ;;
119
+ esac
120
+ }
121
+
122
+ # --- the ACTION receipt: the controller's, one layer earlier ------------------
123
+ #
124
+ # The same instrument aimed at a different question. A state receipt answers
125
+ # *did an owning script write this value?*; an action receipt answers *did a
126
+ # controller authorise this script running at all?*
127
+ #
128
+ # record_action_receipt <script> <subject> # the endpoint, before it spawns
129
+ # action_receipt_clears <script> # the gate, before it refuses
130
+ # spend_action_receipt <script> # the script, on its own exit 0
131
+ #
132
+ # ONLY THE CONTROLLER CAN LEAVE ONE, because only the controller runs before the
133
+ # script does. That is the whole property: `/api/dispatch` and a hand-typed
134
+ # `bash plot-dispatch.sh <slug>` are byte-identical at the command line, and no
135
+ # grep separates them. A receipt is not something the caller types.
136
+ #
137
+ # IT IS SPENT ON THE ACTION COMPLETING, NOT ON THE GATE CLEARING, and that is a
138
+ # DELIBERATE DIFFERENCE from `receipt_clears` above. `plot-approve.sh` and
139
+ # `plot-deliver.sh` are documented idempotent precisely because their
140
+ # irreversible step is a host write: *"re-running is the repair for any
141
+ # interruption after it"*. A receipt spent at the gate would refuse that repair,
142
+ # turning the documented fix into a second controller call — and the operator
143
+ # most likely to need it is the one whose first run died halfway. So the gate
144
+ # clearing LEAVES the receipt, the script's own successful exit spends it, and a
145
+ # failed run is retried on the same licence.
146
+ #
147
+ # THE SUBJECT IS RECORDED AND NOT MATCHED ON. It is the slug the controller
148
+ # named, kept so an operator reading the file learns which action was licensed.
149
+ # The gate matches on the SCRIPT alone: `/api/dispatch` names a plan slug and
150
+ # `plot-dispatch.sh` fans out to branches, so a gate comparing the two words
151
+ # would refuse the controller's own call. Matching what the caller typed against
152
+ # what the endpoint meant is a second rule, and this file holds none.
153
+
154
+ # Where action receipts live — beside the state receipts, and machine-local for
155
+ # the same reason: one travelling in a commit would clear the gate on every
156
+ # checkout that pulled it.
157
+ _action_receipt_dir() {
158
+ local root
159
+ root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
160
+ printf '%s\n' "$root/.plot/state/action-receipts"
161
+ }
162
+
163
+ # NAMED BY THE ACTION, not by the script. The board writes these too, and
164
+ # `scripts/check-script-names.sh` refuses a `plot-*.sh` literal outside an
165
+ # adapter — a script name in a controller is a boundary crossing no spawn
166
+ # counter can see. So the shared filename is the action word, and mapping a
167
+ # script back to it happens HERE, on the side that reads command lines.
168
+ _action_of() { # $1=script name or action word → the action, or nothing
169
+ case "${1##*/}" in
170
+ plot-dispatch.sh|dispatch) printf 'dispatch\n' ;;
171
+ plot-approve.sh|approve) printf 'approve\n' ;;
172
+ plot-deliver.sh|deliver) printf 'deliver\n' ;;
173
+ esac
174
+ }
175
+
176
+ _action_receipt_file() { # $1=script name or action word
177
+ local dir action
178
+ dir="$(_action_receipt_dir)" || return 1
179
+ action="$(_action_of "$1")" || return 1
180
+ [ -n "$action" ] || return 1
181
+ printf '%s/%s\n' "$dir" "$action"
182
+ }
183
+
184
+ # $1 = the script the controller is about to run, $2 = what it is running it on.
185
+ # Called by the controller endpoint immediately before it spawns.
186
+ record_action_receipt() {
187
+ local action subject="${2:-}" file
188
+ action="$(_action_of "${1:-}")"
189
+ [ -n "$action" ] || return 0
190
+ file="$(_action_receipt_file "$action")" || return 0
191
+ mkdir -p "$(dirname "$file")" 2>/dev/null || return 0
192
+ printf '%s\t%s\t%s\n' "$action" "$subject" "$(date +%FT%T)" > "$file" 2>/dev/null || return 0
193
+ return 0
194
+ }
195
+
196
+ # $1 = the script named on the command line. Exit 0 means a controller
197
+ # authorised it. THE RECEIPT IS LEFT IN PLACE — see the spending rule above.
198
+ action_receipt_clears() {
199
+ local file
200
+ file="$(_action_receipt_file "${1##*/}")" || return 1
201
+ [ -f "$file" ] || return 1
202
+ return 0
203
+ }
204
+
205
+ # $1 = the script that just finished. Called by the script itself on exit 0, so
206
+ # one authorisation licenses one COMPLETED action.
207
+ spend_action_receipt() {
208
+ local file
209
+ file="$(_action_receipt_file "${1##*/}")" || return 0
210
+ rm -f "$file" 2>/dev/null
211
+ return 0
212
+ }
213
+
214
+ # --- the named escape, when this file is RUN rather than sourced -------------
215
+ #
216
+ # Guarded on BASH_SOURCE so sourcing never executes it: the three owning scripts
217
+ # source this file and pass no arguments, and a bare `$1` under `set -u` would
218
+ # abort them at load.
219
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
220
+ set -uo pipefail
221
+
222
+ # THE SECOND ESCAPE, for the layer above: a controller-owned ACTION run
223
+ # without a controller, which `plot-controller-gate.sh` refuses. Same shape
224
+ # and same argument as `--unowned` below — the reason is REQUIRED, and each
225
+ # use appends a line to `.plot/state/unowned-action-writes.tsv`, which is the
226
+ # measurement of how large the routing gap still is. An uncounted escape is an
227
+ # off switch.
228
+ if [ "${1:-}" = "--unowned-action" ]; then
229
+ shift
230
+ action_script="${1:-}"; action_subject="${2:-}"; shift 2 2>/dev/null || true
231
+ action_reason="$*"
232
+ if [ -z "$action_script" ] || [ -z "$action_subject" ] || [ -z "$action_reason" ]; then
233
+ echo "plot-state-receipt: --unowned-action needs an action, a subject and a reason." >&2
234
+ echo " usage: plot-state-receipt.sh --unowned-action <dispatch|approve|deliver> <slug> <reason>" >&2
235
+ echo "" >&2
236
+ echo " The reason is required because each use names a routing gap, and a gap" >&2
237
+ echo " nobody wrote down is one nobody closes. Where the board IS running, the" >&2
238
+ echo " controller is the route and this is not needed." >&2
239
+ exit 2
240
+ fi
241
+ # The action word, or the script name — an operator reaching for this has
242
+ # just read a refusal naming a script, so both spellings are accepted.
243
+ case "$action_script" in
244
+ dispatch|plot-dispatch.sh) action_script="dispatch" ;;
245
+ approve|plot-approve.sh) action_script="approve" ;;
246
+ deliver|plot-deliver.sh) action_script="deliver" ;;
247
+ *)
248
+ echo "plot-state-receipt: '$action_script' is not a controller-owned action." >&2
249
+ echo " The three are: dispatch, approve, deliver." >&2
250
+ exit 2
251
+ ;;
252
+ esac
253
+ record_action_receipt "$action_script" "$action_subject"
254
+ log_root="$(git rev-parse --show-toplevel 2>/dev/null)" || log_root=""
255
+ if [ -n "$log_root" ]; then
256
+ mkdir -p "$log_root/.plot/state" 2>/dev/null || true
257
+ printf '%s\t%s\t%s\t%s\n' "$(date +%F)" "$action_script" "$action_subject" "$action_reason" \
258
+ >> "$log_root/.plot/state/unowned-action-writes.tsv" 2>/dev/null || true
259
+ fi
260
+ echo "plot-state-receipt: recorded an UNOWNED action — $action_script $action_subject"
261
+ echo " reason: $action_reason"
262
+ echo " This clears plot-controller-gate.sh for one run of that script. The controller"
263
+ echo " is still the route: this exists for a machine running no board."
264
+ exit 0
265
+ fi
266
+
267
+ if [ "${1:-}" != "--unowned" ]; then
268
+ echo "usage: plot-state-receipt.sh --unowned <path> <value> <reason>" >&2
269
+ echo " plot-state-receipt.sh --unowned-action <dispatch|approve|deliver> <slug> <reason>" >&2
270
+ echo "" >&2
271
+ echo "The named escape for a lifecycle write with no owning script:" >&2
272
+ echo " /plot-approve under 'Review: in-session' or 'Review: ballot'" >&2
273
+ echo " /plot-release writing 'State: Released'" >&2
274
+ echo " /plot-reject reverting Delivered -> Approved" >&2
275
+ echo "" >&2
276
+ echo "--unowned-action is the other layer: a controller-owned action run with no" >&2
277
+ echo "controller, for a machine running no board." >&2
278
+ echo "" >&2
279
+ echo "Every other State: write has a command that owns it. Use that command." >&2
280
+ exit 2
281
+ fi
282
+ shift
283
+ unowned_path="${1:-}"; unowned_value="${2:-}"; shift 2 2>/dev/null || true
284
+ unowned_reason="$*"
285
+ if [ -z "$unowned_path" ] || [ -z "$unowned_value" ] || [ -z "$unowned_reason" ]; then
286
+ echo "plot-state-receipt: --unowned needs a path, a value and a reason." >&2
287
+ echo " The reason is required because each use names a routing gap, and a gap" >&2
288
+ echo " nobody wrote down is one nobody closes." >&2
289
+ exit 2
290
+ fi
291
+ [ -f "$unowned_path" ] || {
292
+ echo "plot-state-receipt: $unowned_path is not a file — the receipt names a write that happened." >&2
293
+ exit 1
294
+ }
295
+ record_state_receipt "$unowned_path" "$unowned_value"
296
+ # The log is the countable half. One line per escape, appended, never rotated
297
+ # here: it is small by construction and the later slices are what shorten it.
298
+ log_root="$(git rev-parse --show-toplevel 2>/dev/null)" || log_root=""
299
+ if [ -n "$log_root" ]; then
300
+ mkdir -p "$log_root/.plot/state" 2>/dev/null || true
301
+ printf '%s\t%s\t%s\t%s\n' "$(date +%F)" "$unowned_path" "$unowned_value" "$unowned_reason" \
302
+ >> "$log_root/.plot/state/unowned-state-writes.tsv" 2>/dev/null || true
303
+ fi
304
+ echo "plot-state-receipt: recorded an UNOWNED write — $unowned_path -> $unowned_value"
305
+ echo " reason: $unowned_reason"
306
+ echo " This clears plot-state-gate.sh for one commit. It is a routing gap, not a workflow:"
307
+ echo " the later slices of the-master-agent-uses-the-controllers give these writes a command."
308
+ fi