forge-orkes 0.53.0 → 0.56.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.53.0",
3
+ "version": "0.56.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env sh
2
+ # forge-release-fold — the fold-of-record: unit-id → release_state, computed
3
+ # from git truth alone (m-30, plan 50-01). Generalizes the wu-done.sh stub:
4
+ # where wu-done answers the single `done` predicate, this fold answers the
5
+ # full release ladder for a unit — unmerged → merged → validated — and each
6
+ # rung REQUIRES the one below it (validated requires merged, always checked
7
+ # first). No committed file stores release_state; this script recomputes it
8
+ # on every call, so a stored copy can never stick.
9
+ #
10
+ # forge-release-fold.sh <unit-id> [--sha <sha>] [--main <ref>] [--project <path>]
11
+ #
12
+ # unit-id m-{N} (milestone unit).
13
+ # --sha <sha> landed commit to fold over (skips resolution below).
14
+ # --main <ref> the mainline ref. Default: main.
15
+ # --project <path> operate on the repo at <path> (any dir inside it).
16
+ # Default: the repo containing the CWD.
17
+ #
18
+ # Landed-sha resolution when --sha is absent (the unit's stable identity):
19
+ # (1) live branch recorded for the unit — MAIN-TREE
20
+ # .forge/state/milestone-{N}.yml lifecycle.worktree_branch, else
21
+ # MAIN-TREE .forge/streams/m-{N}.yml runtime.branch → that branch's tip
22
+ # (when the ref exists).
23
+ # (2) no live branch, state file tracked in main's tree → the LAST COMMIT
24
+ # TOUCHING that state file on main. NEVER main's moving tip (operator
25
+ # ruling 2026-07-16): a tip-resolving fold flaps off as soon as main
26
+ # advances past the sign-off. Containment (phase 51) checks against
27
+ # THIS stable sha.
28
+ # (3) unknown in both → exit 2 — the fold cannot ask the question, so it
29
+ # never emits a false verdict.
30
+ #
31
+ # merged = `git merge-base --is-ancestor <sha> <main-ref>`.
32
+ #
33
+ # Validation is dispatched on forge.validation_event from the project's
34
+ # .forge/project.yml (default: in_session_signoff; promotion_approval is the
35
+ # opt-in host-truth binding, plan 51-01). Under in_session_signoff, applied
36
+ # only when merged, in order:
37
+ # (a) signoff tag: refs/tags/signoff/<unit-id> verifies via verify-signoff.sh
38
+ # (allowed signers from git config gpg.ssh.allowedSignersFile) against
39
+ # the resolved landed sha → validated reason=signed-off. A tag that
40
+ # FAILS verification yields nothing — fall through to the YAML routes.
41
+ # verify-signoff exit 2 (unconfigured) → route unavailable, silent.
42
+ # (b) main-tree milestone current.human_verified set and non-null
43
+ # → validated (today's YAML mechanism, preserved honestly).
44
+ # (c) main-tree current.status == complete → validated reason=legacy-complete
45
+ # (the FORGE.md grandfather — pre-gate closed milestones never
46
+ # retro-invalidate).
47
+ # (d) none of the above → merged (reason=bad-signature when a signoff tag
48
+ # exists but its signature failed, else reason=no-signoff).
49
+ #
50
+ # Under promotion_approval (B4 rev 2 — CI-signed tag, verified OFFLINE), the
51
+ # promotion artifact is the ONLY validation route — the YAML/signoff routes
52
+ # above never apply (host truth, not repo-side files). Tag pattern FROZEN
53
+ # 2026-07-16: promotion/{sha}, {sha} = the full DEPLOY sha the host-side
54
+ # Environment-approved job signed off (not per-unit). Verification is
55
+ # CONTAINMENT, not tip-equality (operator ruling 2026-07-16): enumerate tags
56
+ # by the pattern's prefix; a candidate validates the unit iff its SSH
57
+ # signature verifies against the promotion allowed-signers AND the unit's
58
+ # landed sha is an ancestor of the tag's target commit. First verified
59
+ # containing tag → validated reason=promotion-approved. Otherwise merged with
60
+ # reason=not-contained (a verified tag exists, unit outside it) |
61
+ # bad-signature (candidates exist, none verify — unsigned/lightweight/
62
+ # wrong-key lookalikes land here) | no-approval (no candidate tags at all).
63
+ # Binding config (forge.promotion.*): tag_pattern (default promotion/{sha}),
64
+ # allowed_signers (path; empty ⇒ git config gpg.ssh.allowedSignersFile).
65
+ #
66
+ # ALL milestone/stream YAML reads go through `git show <main-ref>:<path>` —
67
+ # never the working tree. An agent-writable working-tree edit must not flip
68
+ # the render (contract F7-F9). The validation MODE (project.yml) is a
69
+ # working-tree read — that file is protected-path-held per the contract. The
70
+ # promotion allowed-signers FILE (the signature root-of-trust) is likewise
71
+ # read main-tree when it is tracked in main (see validate_promotion_approval)
72
+ # — an in-repo signers file must not be forgeable from the working tree.
73
+ #
74
+ # stdout (machine-parseable, nothing else):
75
+ # release_state=unmerged reason=not-on-main
76
+ # release_state=merged reason=<no-signoff|no-approval|bad-signature|not-contained>
77
+ # release_state=validated [reason=<signed-off|legacy-complete|promotion-approved>]
78
+ # Human-readable detail goes to stderr.
79
+ #
80
+ # Exit codes (mirrors wu-done.sh):
81
+ # 0 a state was computed — ANY of the three. unmerged is an answer, not
82
+ # an error.
83
+ # 2 bad invocation / unknown unit / unconfigured mode — no verdict emitted.
84
+ #
85
+ # Portable: POSIX sh + git + awk/sed. No network calls anywhere (NFR-030) —
86
+ # works in a repo with no remote at all.
87
+ set -eu
88
+
89
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
90
+ VERIFY="$SCRIPT_DIR/verify-signoff.sh"
91
+
92
+ usage() {
93
+ printf 'usage: forge-release-fold.sh <unit-id> [--sha <sha>] [--main <ref>] [--project <path>]\n' >&2
94
+ exit 2
95
+ }
96
+
97
+ # --- helpers -------------------------------------------------------------
98
+
99
+ # yaml_get <content> <block> <key> — scalar value of a 2-space-indented key
100
+ # inside a top-level block. Strips trailing `# comments`. Prints the raw
101
+ # value (possibly empty); never fails.
102
+ yaml_get() {
103
+ printf '%s\n' "$1" | awk -v blk="$2" -v key="$3" '
104
+ /^[^ \t]/ { inblk = (index($0, blk ":") == 1) }
105
+ inblk && index($0, " " key ":") == 1 {
106
+ val = substr($0, length(key) + 4)
107
+ sub(/[ \t]#.*$/, "", val)
108
+ if (val ~ /^[ \t]*#/) val = ""
109
+ gsub(/^[ \t]+/, "", val); gsub(/[ \t]+$/, "", val)
110
+ print val
111
+ exit
112
+ }
113
+ '
114
+ }
115
+
116
+ # unquote <value> — strip one layer of surrounding double or single quotes.
117
+ unquote() {
118
+ uq="$1"
119
+ uq="${uq#\"}"; uq="${uq%\"}"
120
+ uq="${uq#"'"}"; uq="${uq%"'"}"
121
+ printf '%s\n' "$uq"
122
+ }
123
+
124
+ yaml_scalar() { # <content> <block> <key> — yaml_get + unquote
125
+ unquote "$(yaml_get "$1" "$2" "$3")"
126
+ }
127
+
128
+ # yaml_sub_get <content> <block> <subblock> <key> — scalar value of a 4-space-
129
+ # indented key inside a 2-space-indented sub-block of a top-level block
130
+ # (e.g. forge: → promotion: → tag_pattern:). Same comment-stripping contract
131
+ # as yaml_get; never fails.
132
+ yaml_sub_get() {
133
+ printf '%s\n' "$1" | awk -v blk="$2" -v sb="$3" -v key="$4" '
134
+ /^[^ \t]/ { inblk = (index($0, blk ":") == 1); insb = 0 }
135
+ inblk && /^ [^ \t]/ { insb = (index($0, " " sb ":") == 1) }
136
+ insb && index($0, " " key ":") == 1 {
137
+ val = substr($0, length(key) + 6)
138
+ sub(/[ \t]#.*$/, "", val)
139
+ if (val ~ /^[ \t]*#/) val = ""
140
+ gsub(/^[ \t]+/, "", val); gsub(/[ \t]+$/, "", val)
141
+ print val
142
+ exit
143
+ }
144
+ '
145
+ }
146
+
147
+ yaml_sub_scalar() { # <content> <block> <subblock> <key> — yaml_sub_get + unquote
148
+ unquote "$(yaml_sub_get "$1" "$2" "$3" "$4")"
149
+ }
150
+
151
+ # hv_is_set <milestone-yaml> — prints "set" iff current.human_verified is
152
+ # populated: a non-empty non-null scalar, or an empty value followed by an
153
+ # indented child block (`at:` etc.). Literal null/~ or absence is NOT set.
154
+ hv_is_set() {
155
+ printf '%s\n' "$1" | awk '
156
+ /^[^ \t]/ { incur = (index($0, "current:") == 1); look = 0 }
157
+ incur && index($0, " human_verified:") == 1 {
158
+ val = substr($0, 19)
159
+ sub(/[ \t]#.*$/, "", val)
160
+ if (val ~ /^[ \t]*#/) val = ""
161
+ gsub(/[ \t"]/, "", val)
162
+ if (val == "") { look = 1; next }
163
+ if (val != "null" && val != "~") print "set"
164
+ exit
165
+ }
166
+ look {
167
+ if ($0 ~ /^[ \t]*$/) next
168
+ if ($0 ~ /^ /) print "set"
169
+ exit
170
+ }
171
+ '
172
+ }
173
+
174
+ # --- argument parsing ------------------------------------------------------
175
+
176
+ unit=""
177
+ sha_arg=""
178
+ main_ref="main"
179
+ project="."
180
+
181
+ while [ $# -gt 0 ]; do
182
+ case "$1" in
183
+ --sha) shift; sha_arg="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
184
+ --sha=*) sha_arg="${1#*=}"; shift ;;
185
+ --main) shift; main_ref="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
186
+ --main=*) main_ref="${1#*=}"; shift ;;
187
+ --project) shift; project="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
188
+ --project=*) project="${1#*=}"; shift ;;
189
+ -*) printf 'forge-release-fold: unknown option: %s\n' "$1" >&2; usage ;;
190
+ *)
191
+ if [ -z "$unit" ]; then unit="$1"; shift
192
+ else printf 'forge-release-fold: unexpected argument: %s\n' "$1" >&2; usage; fi
193
+ ;;
194
+ esac
195
+ done
196
+
197
+ [ -n "$unit" ] || usage
198
+ case "$unit" in
199
+ m-*) ;;
200
+ *) printf 'forge-release-fold: unit-id must be m-<N>, got: %s\n' "$unit" >&2; usage ;;
201
+ esac
202
+ num="${unit#m-}"
203
+ case "$num" in
204
+ ''|*[!0-9]*) printf 'forge-release-fold: unit-id must be m-<N>, got: %s\n' "$unit" >&2; usage ;;
205
+ esac
206
+
207
+ # --- repo + main ref -------------------------------------------------------
208
+
209
+ if ! root="$(git -C "$project" rev-parse --show-toplevel 2>/dev/null)"; then
210
+ printf 'forge-release-fold: not a git repository: %s\n' "$project" >&2
211
+ exit 2
212
+ fi
213
+
214
+ if ! git -C "$root" rev-parse -q --verify "$main_ref^{commit}" >/dev/null 2>&1; then
215
+ printf 'forge-release-fold: cannot resolve main ref: %s\n' "$main_ref" >&2
216
+ exit 2
217
+ fi
218
+
219
+ state_path=".forge/state/milestone-$num.yml"
220
+ stream_path=".forge/streams/$unit.yml"
221
+
222
+ # MAIN-TREE state read — never the working tree. Empty when not tracked.
223
+ state_yaml=""
224
+ if git -C "$root" cat-file -e "$main_ref:$state_path" 2>/dev/null; then
225
+ state_yaml="$(git -C "$root" show "$main_ref:$state_path")"
226
+ fi
227
+
228
+ # --- landed-sha resolution ---------------------------------------------------
229
+
230
+ # (1) live branch recorded for the unit (main-tree reads only).
231
+ branch=""
232
+ if [ -n "$state_yaml" ]; then
233
+ branch="$(yaml_scalar "$state_yaml" lifecycle worktree_branch)"
234
+ case "$branch" in null|'~') branch="" ;; esac
235
+ fi
236
+ if [ -z "$branch" ]; then
237
+ if git -C "$root" cat-file -e "$main_ref:$stream_path" 2>/dev/null; then
238
+ stream_yaml="$(git -C "$root" show "$main_ref:$stream_path")"
239
+ branch="$(yaml_scalar "$stream_yaml" runtime branch)"
240
+ case "$branch" in null|'~') branch="" ;; esac
241
+ fi
242
+ fi
243
+
244
+ sha=""
245
+ if [ -n "$sha_arg" ]; then
246
+ if ! sha="$(git -C "$root" rev-parse -q --verify "$sha_arg^{commit}" 2>/dev/null)"; then
247
+ printf 'forge-release-fold: --sha does not resolve to a commit: %s\n' "$sha_arg" >&2
248
+ exit 2
249
+ fi
250
+ else
251
+ if [ -n "$branch" ]; then
252
+ if tip="$(git -C "$root" rev-parse -q --verify "refs/heads/$branch^{commit}" 2>/dev/null)"; then
253
+ sha="$tip"
254
+ fi
255
+ fi
256
+ # (2) tracked-only → last commit touching the state file on main — the
257
+ # stable identity. NEVER the moving tip of main (anti-flap ruling).
258
+ if [ -z "$sha" ] && [ -n "$state_yaml" ]; then
259
+ if landed="$(git -C "$root" log -1 --format=%H "$main_ref" -- "$state_path" 2>/dev/null)" \
260
+ && [ -n "$landed" ]; then
261
+ sha="$landed"
262
+ fi
263
+ fi
264
+ # (3) unknown unit → exit 2, no verdict.
265
+ if [ -z "$sha" ]; then
266
+ printf 'forge-release-fold: unknown unit %s — no live branch recorded and %s is not tracked in %s\n' \
267
+ "$unit" "$state_path" "$main_ref" >&2
268
+ exit 2
269
+ fi
270
+ fi
271
+
272
+ # --- rung 1: merged? ---------------------------------------------------------
273
+ # validated REQUIRES merged — checked first, always. An unmerged unit is
274
+ # unmerged no matter what any approval artifact says.
275
+
276
+ if ! git -C "$root" merge-base --is-ancestor "$sha" "$main_ref" 2>/dev/null; then
277
+ printf 'release_state=unmerged reason=not-on-main\n'
278
+ exit 0
279
+ fi
280
+
281
+ # --- rung 2: validation, dispatched on the binding mode ----------------------
282
+
283
+ validate_in_session_signoff() {
284
+ tag_fail=""
285
+ tag_ref="refs/tags/signoff/$unit"
286
+
287
+ # (a) signoff-tag route: verify-signoff machinery, when usable. The fold
288
+ # constructs the invocation itself: --head is the landed sha it resolved;
289
+ # --evidence is extracted from the tag body (the signature + head checks
290
+ # are the real gate — a self-consistent evidence line cannot mismatch).
291
+ if git -C "$root" rev-parse -q --verify "$tag_ref" >/dev/null 2>&1 && [ -x "$VERIFY" ]; then
292
+ signers="$(git -C "$root" config --get gpg.ssh.allowedSignersFile 2>/dev/null || true)"
293
+ if [ -n "$signers" ]; then
294
+ # Tag message body: strip headers up to the first blank line, stop at
295
+ # the embedded signature block (same parse as verify-signoff.sh).
296
+ tag_msg="$(git -C "$root" cat-file -p "$tag_ref" \
297
+ | awk 'f{ if ($0 ~ /-----BEGIN SSH SIGNATURE-----/) exit; print } /^$/{ f=1 }')"
298
+ tag_evidence="$(printf '%s\n' "$tag_msg" | sed -n 's/^evidence: *//p' | head -1)"
299
+ if [ -n "$tag_evidence" ]; then
300
+ # if-guarded so errexit never aborts on a non-zero verify verdict.
301
+ if vout="$( (cd "$root" && "$VERIFY" "$unit" --head "$sha" \
302
+ --evidence "$tag_evidence" --allowed-signers "$signers") 2>/dev/null)"; then
303
+ vrc=0
304
+ else
305
+ vrc=$?
306
+ fi
307
+ if [ "$vrc" -eq 0 ]; then
308
+ printf 'release_state=validated reason=signed-off\n'
309
+ exit 0
310
+ elif [ "$vrc" -eq 1 ] && [ "$vout" = "reason=bad-signature" ]; then
311
+ tag_fail="bad-signature"
312
+ fi
313
+ # vrc 2 (unconfigured) or other exit-1 reasons (stale, mismatch):
314
+ # the tag route yields nothing — fall through to the YAML routes.
315
+ fi
316
+ fi
317
+ fi
318
+
319
+ # (b) human_verified route — main-tree read (git show), never working tree.
320
+ if [ -n "$state_yaml" ] && [ "$(hv_is_set "$state_yaml")" = "set" ]; then
321
+ printf 'release_state=validated\n'
322
+ exit 0
323
+ fi
324
+
325
+ # (c) legacy-complete grandfather — pre-gate closed milestones stay valid.
326
+ if [ -n "$state_yaml" ]; then
327
+ status="$(yaml_scalar "$state_yaml" current status)"
328
+ if [ "$status" = "complete" ]; then
329
+ printf 'release_state=validated reason=legacy-complete\n'
330
+ exit 0
331
+ fi
332
+ fi
333
+
334
+ # (d) merged, not validated.
335
+ if [ -n "$tag_fail" ]; then
336
+ printf 'release_state=merged reason=%s\n' "$tag_fail"
337
+ else
338
+ printf 'release_state=merged reason=no-signoff\n'
339
+ fi
340
+ exit 0
341
+ }
342
+
343
+ validate_promotion_approval() {
344
+ # Binding config — same working-tree project.yml read as the mode itself
345
+ # (protected-path-held per the contract). Pattern FROZEN: promotion/{sha}.
346
+ tag_pattern="$(yaml_sub_scalar "$project_yml" forge promotion tag_pattern)"
347
+ [ -n "$tag_pattern" ] || tag_pattern="promotion/{sha}"
348
+ case "$tag_pattern" in
349
+ *'{sha}'*) ;;
350
+ *)
351
+ printf 'forge-release-fold: forge.promotion.tag_pattern must contain {sha}, got: %s\n' "$tag_pattern" >&2
352
+ exit 2
353
+ ;;
354
+ esac
355
+ prefix="${tag_pattern%%\{sha\}*}"
356
+
357
+ # Allowed signers: the CI key's public half (committed, public keys only).
358
+ # Empty ⇒ git config gpg.ssh.allowedSignersFile. Neither → unconfigured
359
+ # mode, exit 2 — never a false verdict.
360
+ signers="$(yaml_sub_scalar "$project_yml" forge promotion allowed_signers)"
361
+ case "$signers" in null|'~') signers="" ;; esac
362
+ if [ -z "$signers" ]; then
363
+ signers="$(git -C "$root" config --get gpg.ssh.allowedSignersFile 2>/dev/null || true)"
364
+ fi
365
+ if [ -z "$signers" ]; then
366
+ printf 'forge-release-fold: promotion_approval is unconfigured — set forge.promotion.allowed_signers or git config gpg.ssh.allowedSignersFile\n' >&2
367
+ exit 2
368
+ fi
369
+ case "$signers" in
370
+ /*) ;;
371
+ *) signers="$root/$signers" ;;
372
+ esac
373
+
374
+ # Root-of-trust hardening — read the signers CONTENT from main-tree when the
375
+ # file is tracked in main, exactly like every milestone/stream field above
376
+ # (header invariant: the working tree is never the fold's source of truth).
377
+ # Without this, an agent that appends its own public key to the WORKING-TREE
378
+ # copy of the committed signers file, then mints an own-key-signed promotion
379
+ # tag, forges `validated` — and because boot re-renders through this same
380
+ # fold, the forgery would PERSIST across boots (defeating the F7-F9
381
+ # self-correction the whole trust story leans on). A signers path OUTSIDE the
382
+ # repo (absolute host path, or the git-config fallback pointing outside) is
383
+ # host-managed — trusted as-is, not agent-writable in the threat model.
384
+ case "$signers" in
385
+ "$root"/*)
386
+ signers_rel="${signers#"$root"/}"
387
+ if git -C "$root" cat-file -e "$main_ref:$signers_rel" 2>/dev/null; then
388
+ signers_main="$(mktemp)"
389
+ trap 'rm -f "$signers_main"' EXIT INT TERM
390
+ git -C "$root" show "$main_ref:$signers_rel" > "$signers_main"
391
+ signers="$signers_main"
392
+ fi
393
+ # In-repo but NOT tracked in main → untracked/git-ignored host file:
394
+ # left as-is (same trust as before; no tracked copy to prefer).
395
+ ;;
396
+ esac
397
+
398
+ # Enumerate candidate tags by the frozen pattern's prefix; first verified
399
+ # CONTAINING tag validates (a promotion approves a deploy sha; every unit
400
+ # whose landed sha is an ancestor of it is covered — never tip-equality).
401
+ saw_candidate=0
402
+ saw_verified=0
403
+ for tag in $(git -C "$root" tag -l "${prefix}*"); do
404
+ saw_candidate=1
405
+ # Offline SSH-signature verification: explicit -c gpg.format=ssh +
406
+ # allowedSignersFile so a scrubbed environment can still verify — the
407
+ # verify-signoff.sh:120-135 invocation, reused verbatim (no shared-lib
408
+ # extraction for 3 lines; Article III).
409
+ if git -C "$root" -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$signers" \
410
+ tag -v "$tag" >/dev/null 2>&1; then
411
+ saw_verified=1
412
+ if git -C "$root" merge-base --is-ancestor "$sha" "$tag^{commit}" 2>/dev/null; then
413
+ printf 'release_state=validated reason=promotion-approved\n'
414
+ exit 0
415
+ fi
416
+ fi
417
+ done
418
+
419
+ # No verified containing tag. Most-specific reason wins: a genuine tag that
420
+ # simply doesn't cover this unit (not-contained) beats lookalike noise
421
+ # (bad-signature) beats silence (no-approval).
422
+ if [ "$saw_verified" -eq 1 ]; then
423
+ printf 'release_state=merged reason=not-contained\n'
424
+ elif [ "$saw_candidate" -eq 1 ]; then
425
+ printf 'release_state=merged reason=bad-signature\n'
426
+ else
427
+ printf 'release_state=merged reason=no-approval\n'
428
+ fi
429
+ exit 0
430
+ }
431
+
432
+ # Validation MODE comes from the project's working-tree config (the mode file
433
+ # is protected-path-held per the contract); every FIELD read above/below is
434
+ # main-tree only. Default when absent: in_session_signoff.
435
+ project_yml=""
436
+ if [ -f "$root/.forge/project.yml" ]; then
437
+ project_yml="$(cat "$root/.forge/project.yml")"
438
+ fi
439
+ validation_event="$(yaml_scalar "$project_yml" forge validation_event)"
440
+ [ -n "$validation_event" ] || validation_event="in_session_signoff"
441
+
442
+ # Single dispatch point — both arms of the B3 binding.
443
+ case "$validation_event" in
444
+ in_session_signoff)
445
+ validate_in_session_signoff
446
+ ;;
447
+ promotion_approval)
448
+ validate_promotion_approval
449
+ ;;
450
+ *)
451
+ printf 'forge-release-fold: unknown validation_event: %s\n' "$validation_event" >&2
452
+ exit 2
453
+ ;;
454
+ esac
455
+
456
+ # Unreachable — every dispatch branch exits. Defensive: never fall off the
457
+ # end into an implicit success with no verdict printed.
458
+ exit 2
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env sh
2
+ # forge-slice-notify.sh <type> <payload> — the DEFAULT notify channel for
3
+ # forge-slice-runner.sh. Delivers a §8 ping to the operator's phone via the
4
+ # PushNotification agent tool through a thin headless `claude -p` micro-call, and
5
+ # ALSO appends the ping to a local log so a suppressed/undelivered push is never
6
+ # silently lost.
7
+ #
8
+ # The runner calls this as `${FORGE_SLICE_NOTIFY:-forge-slice-notify.sh} <type>
9
+ # <payload>`; the whole channel is swappable via that env (tests use notify-stub.sh).
10
+ #
11
+ # type one of the §8 surface: ambiguity | irreversible | fork | budget | done
12
+ # (plus `halt`, the failure slice-end). The runner has already validated
13
+ # the type before calling; this script just delivers it.
14
+ # payload the ping body. For a design `fork` it is a PATH to a sketch/mockup the
15
+ # operator LOOKS AT (§8) — passed through so the notification names the file.
16
+ #
17
+ # BEST-EFFORT by contract: this must exit 0 even when delivery is impossible (no
18
+ # `claude` on PATH, no CLAUDE_CODE_OAUTH_TOKEN, the tool suppressed while the
19
+ # operator is active). The runner treats a non-zero exit as a best-effort miss, not
20
+ # a slice failure — so never hard-fail here.
21
+ #
22
+ # ── DEFERRED phone-delivery caveat (context.md M29, 2026-07-15) ───────────────
23
+ # A PushNotification SENT from a DETACHED one-shot `claude -p` returns success but
24
+ # may NOT buzz the phone — this is the Claude-app Remote-Control DEVICE-pairing
25
+ # side (an ephemeral unpaired session), not this code. It is a known open item,
26
+ # fixed later. The channel is swappable precisely so that fix can land without
27
+ # touching the runner. Escalate only if delivery turns out to need Remote-Control
28
+ # state a detached runner cannot satisfy.
29
+ # ──────────────────────────────────────────────────────────────────────────────
30
+ set -eu
31
+
32
+ TYPE="${1:-note}"
33
+ PAYLOAD="${2:-}"
34
+
35
+ # 1) Durable local record — always, before any delivery attempt. Never lost even
36
+ # if the phone channel is down. Default location travels with the slice.
37
+ LOG="${FORGE_SLICE_NOTIFY_LOG:-${FORGE_SLICE_WORK_DIR:-.}/notify.log}"
38
+ mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
39
+ printf '%s\t%s\t%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo now)" "$TYPE" "$PAYLOAD" >> "$LOG" 2>/dev/null || true
40
+
41
+ # 2) Best-effort phone delivery via PushNotification. Skip cleanly if headless
42
+ # `claude` or its credential is absent (dev boxes, CI) — that is a miss, not a
43
+ # failure. A design fork's payload is a path, so name it in the body.
44
+ LAUNCHER="${FORGE_SLICE_NOTIFY_LAUNCHER:-claude}"
45
+ if command -v "$LAUNCHER" >/dev/null 2>&1 \
46
+ && { [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; }; then
47
+ _title="Forge slice: ${TYPE}"
48
+ case "$TYPE" in
49
+ fork) _body="Design fork — review the sketch: ${PAYLOAD}" ;;
50
+ done) _body="Slice complete. ${PAYLOAD}" ;;
51
+ *) _body="${PAYLOAD}" ;;
52
+ esac
53
+ # One-shot: ask the headless agent to fire PushNotification with the title/body.
54
+ printf 'Send a push notification with title "%s" and body "%s". Use only the PushNotification tool; do nothing else.\n' \
55
+ "$_title" "$_body" \
56
+ | "$LAUNCHER" -p \
57
+ --allowedTools PushNotification \
58
+ --permission-mode bypassPermissions \
59
+ --output-format json \
60
+ >/dev/null 2>&1 \
61
+ || printf 'forge-slice-notify: push delivery best-effort miss for %s (logged locally at %s)\n' "$TYPE" "$LOG" >&2
62
+ else
63
+ printf 'forge-slice-notify: no headless claude/credential — %s ping logged locally only (%s)\n' "$TYPE" "$LOG" >&2
64
+ fi
65
+
66
+ exit 0