forge-orkes 0.49.0 → 0.53.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.49.0",
3
+ "version": "0.53.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"
@@ -51,8 +51,10 @@ case "$kind" in
51
51
  def) prefix="DEF" ;;
52
52
  fr) prefix="FR" ;;
53
53
  nfr) prefix="NFR" ;;
54
- "") printf 'forge-reserve: missing kind (adr|def|fr|nfr)\n' >&2; exit 2 ;;
55
- *) printf 'forge-reserve: unknown kind: %s (expected adr|def|fr|nfr)\n' "$kind" >&2; exit 2 ;;
54
+ milestone) prefix="M" ;; # ADR-023 unpadded (M-26); ids used bare across the framework
55
+ refactor) prefix="R" ;; # ADR-023 zero-padded R0NN, matching the backlog format
56
+ "") printf 'forge-reserve: missing kind (adr|def|fr|nfr|milestone|refactor)\n' >&2; exit 2 ;;
57
+ *) printf 'forge-reserve: unknown kind: %s (expected adr|def|fr|nfr|milestone|refactor)\n' "$kind" >&2; exit 2 ;;
56
58
  esac
57
59
  [ -n "$milestone" ] || { printf 'forge-reserve: --milestone <id> is required\n' >&2; exit 2; }
58
60
 
@@ -99,33 +101,67 @@ trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
99
101
  # Everything numeric goes through awk (n+0) so leading-zero ids like 021 are read
100
102
  # as decimal 21, never octal — no shell $(( )) octal trap.
101
103
 
102
- # (a) ledger — same-machine siblings, INCLUDING uncommitted (the ADR-016-blind input)
104
+ # (a) ledger — same-machine siblings, INCLUDING uncommitted (the ADR-016-blind input).
105
+ # Extract the trailing number by stripping non-digits, NOT by split on "-": refactor
106
+ # ids (R100) carry no hyphen (ADR-023), so a hyphen-split would read them as 0 and
107
+ # reallocate a live id. gsub handles ADR-014, M-26, R100, FR-023 uniformly.
103
108
  ledger_max=0
104
109
  if [ -f "$ledger" ]; then
105
110
  ledger_max="$(awk -F'\t' -v k="$kind" \
106
- '$1==k { split($2,a,"-"); n=a[2]+0; if(n>m)m=n } END{print m+0}' "$ledger")"
111
+ '$1==k { x=$2; gsub(/[^0-9]/,"",x); n=x+0; if(n>m)m=n } END{print m+0}' "$ledger")"
107
112
  fi
108
113
 
109
114
  # (b) in-tree — landed ids in THIS worktree. Exact-prefix match in awk ($1==p) so
110
- # FR does not swallow NFR (FR is a substring of NFR).
111
- if [ "$kind" = "adr" ]; then
112
- intree_max="$(ls "$top/docs/decisions" 2>/dev/null \
113
- | awk -F- '/^ADR-[0-9]/ { n=$2+0; if(n>m)m=n } END{print m+0}')"
114
- else
115
- intree_max="$(grep -rhoE '[A-Z]+-[0-9]+' "$top/.forge/requirements/" 2>/dev/null \
116
- | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
117
- fi
115
+ # FR does not swallow NFR (FR is a substring of NFR). milestone + refactor (ADR-023)
116
+ # each scan BOTH live and archive locations so a burned id is never reallocated.
117
+ case "$kind" in
118
+ adr)
119
+ intree_max="$(ls "$top/docs/decisions" 2>/dev/null \
120
+ | awk -F- '/^ADR-[0-9]/ { n=$2+0; if(n>m)m=n } END{print m+0}')"
121
+ ;;
122
+ milestone)
123
+ # live state/milestone-<N>.yml ∪ archived .forge/archive/milestone-<N>/
124
+ intree_max="$( { ls "$top/.forge/state"/milestone-*.yml 2>/dev/null; \
125
+ ls -d "$top/.forge/archive"/milestone-*/ 2>/dev/null; } \
126
+ | grep -oE 'milestone-[0-9]+' \
127
+ | awk -F- '{ n=$2+0; if(n>m)m=n } END{print m+0}')"
128
+ ;;
129
+ refactor)
130
+ # live refactor-backlog.yml ∪ refactor-backlog-archive.yml
131
+ intree_max="$(grep -hoE 'R[0-9]+' \
132
+ "$top/.forge/refactor-backlog.yml" \
133
+ "$top/.forge/refactor-backlog-archive.yml" 2>/dev/null \
134
+ | awk '{ n=substr($0,2)+0; if(n>m)m=n } END{print m+0}')"
135
+ ;;
136
+ *)
137
+ intree_max="$(grep -rhoE '[A-Z]+-[0-9]+' "$top/.forge/requirements/" 2>/dev/null \
138
+ | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
139
+ ;;
140
+ esac
118
141
 
119
142
  # (c) main:reservations.yml — cross-machine / cold-start floor (survives a
120
143
  # .git/forge wipe; carries another machine's MERGED reservations). Absent → 0.
144
+ # Hyphen is OPTIONAL in the token ([A-Z]+-?[0-9]+): refactor ids (R100) carry none
145
+ # (ADR-023), so requiring a hyphen would hide them and drop the floor to 0. Split the
146
+ # alpha prefix from the digits in awk so R does not match e.g. FR (anchor ^prefix$).
121
147
  main_max="$(git show main:.forge/reservations.yml 2>/dev/null \
122
- | grep -oE '[A-Z]+-[0-9]+' \
123
- | awk -F- -v p="$prefix" '$1==p { n=$2+0; if(n>m)m=n } END{print m+0}')"
148
+ | grep -oE '[A-Z]+-?[0-9]+' \
149
+ | awk -v p="$prefix" '{ a=$0; sub(/[-0-9].*$/,"",a); d=$0; gsub(/[^0-9]/,"",d);
150
+ if(a==p){ n=d+0; if(n>m)m=n } } END{print m+0}')"
124
151
  [ -n "$main_max" ] || main_max=0
125
152
 
126
153
  next="$(awk -v a="$ledger_max" -v b="$intree_max" -v c="$main_max" \
127
154
  'BEGIN{m=a; if(b>m)m=b; if(c>m)m=c; print m+1}')"
128
- id="$(printf '%s-%03d' "$prefix" "$next")"
155
+ # Per-kind id format (ADR-023). The prefix-hyphen-paddednumber form (ADR-021's
156
+ # ADR-014, FR-023) is NOT universal:
157
+ # milestone → M-26 : hyphen, UNPADDED — ids are used bare (milestone-26.yml, m-26)
158
+ # refactor → R064 : NO hyphen, zero-padded 3-wide — matches the backlog convention
159
+ # adr/def/fr/nfr → ADR-014 : hyphen, zero-padded 3-wide (unchanged)
160
+ case "$kind" in
161
+ milestone) id="$(printf '%s-%d' "$prefix" "$next")" ;;
162
+ refactor) id="$(printf '%s%03d' "$prefix" "$next")" ;;
163
+ *) id="$(printf '%s-%03d' "$prefix" "$next")" ;;
164
+ esac
129
165
 
130
166
  # --- 6. dual-write (still under the lock) -----------------------------------
131
167
  now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env sh
2
+ # Self-contained unit test for .claude/hooks/verify-signoff.sh (Contract M0 / D1).
3
+ #
4
+ # Builds throwaway git repos with real SSH-signed tags (a fresh keypair per test
5
+ # run, registered as an allowed signer) and asserts exit code + reason for the
6
+ # four negative cases plus the one positive case. Needs ssh-keygen + git built
7
+ # with SSH signing support (git >= 2.34). Run:
8
+ # ./.claude/hooks/tests/verify-signoff.test.sh
9
+ # Exits 0 iff every case passes; non-zero on any failure. Self-cleans on exit.
10
+ set -u
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
13
+ HELPER="$SCRIPT_DIR/../verify-signoff.sh"
14
+
15
+ [ -x "$HELPER" ] || { printf 'FATAL: helper not executable: %s\n' "$HELPER" >&2; exit 1; }
16
+ command -v ssh-keygen >/dev/null 2>&1 || { printf 'FATAL: ssh-keygen not found\n' >&2; exit 1; }
17
+
18
+ ROOT="$(mktemp -d)"
19
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
20
+
21
+ PASSED=0
22
+ FAILED=0
23
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
24
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
25
+ check() { # desc, actual, expected
26
+ if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi
27
+ }
28
+
29
+ # One allowed-signer keypair (the "operator") + one non-allowed keypair (the
30
+ # "agent") shared across cases.
31
+ gen_key() { # path
32
+ ssh-keygen -t ed25519 -N "" -C "$2" -f "$1" -q
33
+ }
34
+ gen_key "$ROOT/operator_key" "operator@example.com"
35
+ gen_key "$ROOT/agent_key" "agent@example.com"
36
+
37
+ allowed_signers="$ROOT/allowed_signers"
38
+ printf 'operator@example.com namespaces="git" %s\n' "$(cat "$ROOT/operator_key.pub")" > "$allowed_signers"
39
+
40
+ new_repo() { # name
41
+ d="$ROOT/repo.$1"
42
+ mkdir -p "$d"
43
+ git -C "$d" init -q
44
+ git -C "$d" symbolic-ref HEAD refs/heads/main
45
+ git -C "$d" config user.email t@example.com
46
+ git -C "$d" config user.name tester
47
+ printf '%s\n' "$d"
48
+ }
49
+
50
+ # Mint a signoff tag with an arbitrary key + arbitrary head:/evidence: body.
51
+ mint_tag() { # repo, wu-id, signing-key, head, evidence
52
+ repo="$1"; wu="$2"; key="$3"; head="$4"; evidence="$5"
53
+ body="wu: $wu
54
+ head: $head
55
+ evidence: $evidence"
56
+ git -C "$repo" -c gpg.format=ssh -c "user.signingkey=$key" \
57
+ tag -s "signoff/$wu" -m "$body"
58
+ }
59
+
60
+ evidence_file="$ROOT/evidence.txt"
61
+ printf 'evidence payload\n' > evidence_tmp && mv evidence_tmp "$evidence_file" 2>/dev/null || printf 'evidence payload\n' > "$evidence_file"
62
+ evidence_hash="$(shasum -a 256 "$evidence_file" 2>/dev/null | awk '{print $1}')"
63
+ [ -n "$evidence_hash" ] || evidence_hash="$(sha256sum "$evidence_file" | awk '{print $1}')"
64
+
65
+ # --- Positive case: correctly signed tag at HEAD, matching evidence ---------
66
+ d="$(new_repo pos)"; cd "$d"
67
+ git commit --allow-empty -qm init
68
+ head_sha="$(git rev-parse HEAD)"
69
+ mint_tag "$d" "wu-pos" "$ROOT/operator_key" "$head_sha" "$evidence_hash"
70
+ out="$("$HELPER" wu-pos --head "$head_sha" --evidence "$evidence_hash" --allowed-signers "$allowed_signers")"; rc=$?
71
+ check "positive: exit 0" "$rc" "0"
72
+ check "positive: stdout ok" "$out" "ok"
73
+
74
+ # Same, using --evidence-file instead of a precomputed hash.
75
+ out2="$("$HELPER" wu-pos --head "$head_sha" --evidence-file "$evidence_file" --allowed-signers "$allowed_signers")"; rc2=$?
76
+ check "positive (evidence-file): exit 0" "$rc2" "0"
77
+
78
+ # --- Negative 1: no-signoff (tag absent) ------------------------------------
79
+ d="$(new_repo neg1)"; cd "$d"
80
+ git commit --allow-empty -qm init
81
+ head_sha="$(git rev-parse HEAD)"
82
+ out="$("$HELPER" wu-neg1 --head "$head_sha" --evidence "$evidence_hash" --allowed-signers "$allowed_signers" 2>/dev/null)"; rc=$?
83
+ check "no-signoff: exit 1" "$rc" "1"
84
+ check "no-signoff: reason" "$out" "reason=no-signoff"
85
+
86
+ # --- Negative 2: bad-signature (signed by a non-allowed key) ----------------
87
+ d="$(new_repo neg2)"; cd "$d"
88
+ git commit --allow-empty -qm init
89
+ head_sha="$(git rev-parse HEAD)"
90
+ mint_tag "$d" "wu-neg2" "$ROOT/agent_key" "$head_sha" "$evidence_hash"
91
+ out="$("$HELPER" wu-neg2 --head "$head_sha" --evidence "$evidence_hash" --allowed-signers "$allowed_signers" 2>/dev/null)"; rc=$?
92
+ check "bad-signature: exit 1" "$rc" "1"
93
+ check "bad-signature: reason" "$out" "reason=bad-signature"
94
+
95
+ # --- Negative 3: stale (head: does not match landed commit) -----------------
96
+ d="$(new_repo neg3)"; cd "$d"
97
+ git commit --allow-empty -qm init
98
+ head_sha="$(git rev-parse HEAD)"
99
+ mint_tag "$d" "wu-neg3" "$ROOT/operator_key" "0000000000000000000000000000000000000000" "$evidence_hash"
100
+ out="$("$HELPER" wu-neg3 --head "$head_sha" --evidence "$evidence_hash" --allowed-signers "$allowed_signers" 2>/dev/null)"; rc=$?
101
+ check "stale: exit 1" "$rc" "1"
102
+ check "stale: reason" "$out" "reason=stale"
103
+
104
+ # --- Negative 4: evidence-mismatch ------------------------------------------
105
+ d="$(new_repo neg4)"; cd "$d"
106
+ git commit --allow-empty -qm init
107
+ head_sha="$(git rev-parse HEAD)"
108
+ mint_tag "$d" "wu-neg4" "$ROOT/operator_key" "$head_sha" "deadbeef"
109
+ out="$("$HELPER" wu-neg4 --head "$head_sha" --evidence "$evidence_hash" --allowed-signers "$allowed_signers" 2>/dev/null)"; rc=$?
110
+ check "evidence-mismatch: exit 1" "$rc" "1"
111
+ check "evidence-mismatch: reason" "$out" "reason=evidence-mismatch"
112
+
113
+ # --- Bad invocation: missing wu-id / missing evidence exits 2 ---------------
114
+ out="$("$HELPER" 2>/dev/null)"; rc=$?
115
+ check "missing wu-id: exit 2" "$rc" "2"
116
+ d="$(new_repo badinvoke)"; cd "$d"
117
+ git commit --allow-empty -qm init
118
+ out="$("$HELPER" wu-x --allowed-signers "$allowed_signers" 2>/dev/null)"; rc=$?
119
+ check "missing evidence: exit 2" "$rc" "2"
120
+
121
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
122
+ [ "$FAILED" -eq 0 ]
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env sh
2
+ # Self-contained unit test for .claude/hooks/wu-done.sh (Contract M0 / D2).
3
+ #
4
+ # Asserts the fold stub reports done=true only for a valid signoff, and that
5
+ # replacing a good tag with a bad-signature one flips done back to false —
6
+ # the contract's verification #4. Run:
7
+ # ./.claude/hooks/tests/wu-done.test.sh
8
+ set -u
9
+
10
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
11
+ HELPER="$SCRIPT_DIR/../wu-done.sh"
12
+
13
+ [ -x "$HELPER" ] || { printf 'FATAL: helper not executable: %s\n' "$HELPER" >&2; exit 1; }
14
+ command -v ssh-keygen >/dev/null 2>&1 || { printf 'FATAL: ssh-keygen not found\n' >&2; exit 1; }
15
+
16
+ ROOT="$(mktemp -d)"
17
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
18
+
19
+ PASSED=0
20
+ FAILED=0
21
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
22
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
23
+ check() { # desc, actual, expected
24
+ if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi
25
+ }
26
+
27
+ ssh-keygen -t ed25519 -N "" -C "operator@example.com" -f "$ROOT/operator_key" -q
28
+ ssh-keygen -t ed25519 -N "" -C "agent@example.com" -f "$ROOT/agent_key" -q
29
+ allowed_signers="$ROOT/allowed_signers"
30
+ printf 'operator@example.com namespaces="git" %s\n' "$(cat "$ROOT/operator_key.pub")" > "$allowed_signers"
31
+
32
+ d="$ROOT/repo"
33
+ mkdir -p "$d"
34
+ git -C "$d" init -q
35
+ git -C "$d" symbolic-ref HEAD refs/heads/main
36
+ git -C "$d" config user.email t@example.com
37
+ git -C "$d" config user.name tester
38
+ cd "$d"
39
+ git commit --allow-empty -qm init
40
+ head_sha="$(git rev-parse HEAD)"
41
+ evidence="deadbeefcafe"
42
+
43
+ mint() { # signing-key
44
+ git -c gpg.format=ssh -c "user.signingkey=$1" \
45
+ tag -sf "signoff/wu-flip" -m "wu: wu-flip
46
+ head: $head_sha
47
+ evidence: $evidence"
48
+ }
49
+
50
+ # --- Before any tag: done=false, reason=no-signoff --------------------------
51
+ out="$("$HELPER" wu-flip --head "$head_sha" --evidence "$evidence" --allowed-signers "$allowed_signers")"; rc=$?
52
+ check "no tag: exit 1" "$rc" "1"
53
+ check "no tag: reason" "$out" "done=false reason=no-signoff"
54
+
55
+ # --- Good signature: done=true ----------------------------------------------
56
+ mint "$ROOT/operator_key"
57
+ out="$("$HELPER" wu-flip --head "$head_sha" --evidence "$evidence" --allowed-signers "$allowed_signers")"; rc=$?
58
+ check "good signoff: exit 0" "$rc" "0"
59
+ check "good signoff: stdout" "$out" "done=true"
60
+
61
+ # --- Flip: re-tag the same wu-id with a non-allowed key — done flips false --
62
+ mint "$ROOT/agent_key"
63
+ out="$("$HELPER" wu-flip --head "$head_sha" --evidence "$evidence" --allowed-signers "$allowed_signers")"; rc=$?
64
+ check "flipped to bad signature: exit 1" "$rc" "1"
65
+ check "flipped to bad signature: reason" "$out" "done=false reason=bad-signature"
66
+
67
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
68
+ [ "$FAILED" -eq 0 ]
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env sh
2
+ # verify-signoff — check that a work unit's completion is backed by a valid,
3
+ # SSH-signed sign-off tag. Implements the Contract M0 sign-off convention
4
+ # (docs/sign-off-convention.md): "done" for a work unit is true iff this
5
+ # script exits 0 for that work unit's id. No YAML `human_verified` field is
6
+ # the source of truth — a stored copy of that value is a derived echo only.
7
+ #
8
+ # Convention: the sign-off is an SSH-signed annotated tag `signoff/<wu-id>` at
9
+ # the landed commit, message body:
10
+ # wu: <wu-id>
11
+ # head: <full-sha>
12
+ # evidence: <evidence-set-hash>
13
+ # Minted by the operator (or a process holding the operator's signing key),
14
+ # never from an agent context:
15
+ # git -c gpg.format=ssh -c user.signingkey=<operator key> \
16
+ # tag -s signoff/<wu-id> -m "wu: <wu-id>\nhead: <sha>\nevidence: <hash>"
17
+ #
18
+ # verify-signoff.sh <wu-id> [--head <sha>] [--evidence <hash>]
19
+ # [--evidence-file <path>] [--allowed-signers <path>]
20
+ #
21
+ # --head <sha> expected landed commit. Default: `git rev-parse HEAD`.
22
+ # --evidence <hash> expected evidence-set hash, compared verbatim.
23
+ # --evidence-file <path> compute the expected hash as `sha256(path)` instead
24
+ # of passing it directly. Exactly one of --evidence /
25
+ # --evidence-file is required.
26
+ # --allowed-signers <path> SSH allowed-signers file. Default:
27
+ # `git config --get gpg.ssh.allowedSignersFile`.
28
+ #
29
+ # stdout on success: `ok`. stdout on failure: `reason=<code>` (machine-parseable;
30
+ # nothing else on stdout). Human-readable detail always goes to stderr.
31
+ #
32
+ # Exit codes:
33
+ # 0 signed, verified, and matching — the work unit is done.
34
+ # 1 a verification check failed; stdout carries the reason code:
35
+ # no-signoff no signoff/<wu-id> tag exists
36
+ # bad-signature tag exists but signature is missing/unknown/invalid
37
+ # stale tag's `head:` does not match the expected commit
38
+ # evidence-mismatch tag's `evidence:` does not match the expected hash
39
+ # 2 bad invocation (missing wu-id, missing evidence input, bad flag) — no
40
+ # verification was attempted.
41
+ #
42
+ # Portable: no network calls; only git (must support `gpg.format=ssh`, i.e.
43
+ # git >= 2.34) plus standard POSIX utilities (awk/sed/grep) and shasum/sha256sum
44
+ # for --evidence-file. Works with no remote at all — the tag is local git state.
45
+ set -eu
46
+
47
+ usage() {
48
+ printf 'usage: verify-signoff.sh <wu-id> [--head <sha>] [--evidence <hash> | --evidence-file <path>] [--allowed-signers <path>]\n' >&2
49
+ exit 2
50
+ }
51
+
52
+ wu_id=""
53
+ head_arg=""
54
+ evidence_arg=""
55
+ evidence_file=""
56
+ allowed_signers=""
57
+
58
+ while [ $# -gt 0 ]; do
59
+ case "$1" in
60
+ --head) shift; head_arg="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
61
+ --head=*) head_arg="${1#*=}"; shift ;;
62
+ --evidence) shift; evidence_arg="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
63
+ --evidence=*) evidence_arg="${1#*=}"; shift ;;
64
+ --evidence-file) shift; evidence_file="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
65
+ --evidence-file=*) evidence_file="${1#*=}"; shift ;;
66
+ --allowed-signers) shift; allowed_signers="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
67
+ --allowed-signers=*) allowed_signers="${1#*=}"; shift ;;
68
+ -*) printf 'verify-signoff: unknown option: %s\n' "$1" >&2; usage ;;
69
+ *)
70
+ if [ -z "$wu_id" ]; then wu_id="$1"; shift
71
+ else printf 'verify-signoff: unexpected argument: %s\n' "$1" >&2; usage; fi
72
+ ;;
73
+ esac
74
+ done
75
+
76
+ [ -n "$wu_id" ] || usage
77
+ if [ -n "$evidence_arg" ] && [ -n "$evidence_file" ]; then
78
+ printf 'verify-signoff: pass exactly one of --evidence / --evidence-file, not both\n' >&2
79
+ exit 2
80
+ fi
81
+ if [ -z "$evidence_arg" ] && [ -z "$evidence_file" ]; then
82
+ printf 'verify-signoff: --evidence <hash> or --evidence-file <path> is required\n' >&2
83
+ exit 2
84
+ fi
85
+
86
+ [ -n "$head_arg" ] || head_arg="$(git rev-parse HEAD)"
87
+
88
+ if [ -n "$evidence_file" ]; then
89
+ [ -f "$evidence_file" ] || { printf 'verify-signoff: evidence file not found: %s\n' "$evidence_file" >&2; exit 2; }
90
+ if command -v shasum >/dev/null 2>&1; then
91
+ evidence_arg="$(shasum -a 256 "$evidence_file" | awk '{print $1}')"
92
+ elif command -v sha256sum >/dev/null 2>&1; then
93
+ evidence_arg="$(sha256sum "$evidence_file" | awk '{print $1}')"
94
+ else
95
+ printf 'verify-signoff: need shasum or sha256sum to hash --evidence-file\n' >&2
96
+ exit 2
97
+ fi
98
+ fi
99
+
100
+ if [ -z "$allowed_signers" ]; then
101
+ allowed_signers="$(git config --get gpg.ssh.allowedSignersFile || true)"
102
+ fi
103
+ [ -n "$allowed_signers" ] || { printf 'verify-signoff: no --allowed-signers given and gpg.ssh.allowedSignersFile is unset\n' >&2; exit 2; }
104
+ [ -f "$allowed_signers" ] || { printf 'verify-signoff: allowed-signers file not found: %s\n' "$allowed_signers" >&2; exit 2; }
105
+
106
+ tag_ref="refs/tags/signoff/$wu_id"
107
+
108
+ fail() { # reason
109
+ printf 'reason=%s\n' "$1"
110
+ printf 'verify-signoff: FAIL (%s) for %s\n' "$1" "$wu_id" >&2
111
+ exit 1
112
+ }
113
+
114
+ # --- 1. resolve the tag ------------------------------------------------------
115
+ git rev-parse -q --verify "$tag_ref" >/dev/null 2>&1 || fail no-signoff
116
+
117
+ # --- 2. verify the SSH signature (self-contained: pass format + signers file
118
+ # explicitly so this doesn't depend on the caller's git config, e.g. a scrubbed
119
+ # worker environment that has no signing key but must still be ABLE to verify).
120
+ if ! git -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$allowed_signers" \
121
+ tag -v "signoff/$wu_id" >/dev/null 2>&1; then
122
+ fail bad-signature
123
+ fi
124
+
125
+ # --- 3. parse the message body and compare head:/evidence: ------------------
126
+ # Raw tag object: headers, blank line, message, then (for a signed tag) an
127
+ # embedded "-----BEGIN SSH SIGNATURE-----" block appended after the message.
128
+ # Strip headers up to the first blank line, then stop at the signature marker.
129
+ msg="$(git cat-file -p "$tag_ref" | awk 'f{ if ($0 ~ /-----BEGIN SSH SIGNATURE-----/) exit; print } /^$/{ f=1 }')"
130
+
131
+ tag_head="$(printf '%s\n' "$msg" | sed -n 's/^head: *//p' | head -1)"
132
+ tag_evidence="$(printf '%s\n' "$msg" | sed -n 's/^evidence: *//p' | head -1)"
133
+
134
+ [ "$tag_head" = "$head_arg" ] || fail stale
135
+ [ "$tag_evidence" = "$evidence_arg" ] || fail evidence-mismatch
136
+
137
+ # --- 4. all checks passed -----------------------------------------------------
138
+ printf 'ok\n'
139
+ exit 0
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env sh
2
+ # wu-done — minimal fold stub wiring the `done` predicate to verify-signoff.
3
+ #
4
+ # Contract M0 (D2): "Wherever the program's fold computes work-unit state,
5
+ # `done` is true iff `verify-signoff <wu-id>` exits 0." Forge's own repo has
6
+ # no executable fold (workflow state is derived by the verifying/reviewing
7
+ # SKILLs, not by a code module) — this script IS the fold, scoped to exactly
8
+ # this predicate. A project with a real fold (e.g. pi-forge's package/stream
9
+ # state) wires its own `done` computation to `verify-signoff.sh` the same way
10
+ # this script does; it does not need to shell out to this file specifically.
11
+ #
12
+ # See docs/sign-off-convention.md. `current.human_verified` in a milestone
13
+ # state file is a DERIVED ECHO of this predicate for human readability — it
14
+ # is never the source of truth, and this script never reads or writes it.
15
+ #
16
+ # wu-done.sh <wu-id> [same flags as verify-signoff.sh]
17
+ #
18
+ # stdout: `done=true` or `done=false reason=<code>`. Exit 0 iff done=true —
19
+ # same convention as verify-signoff.sh, so `wu-done.sh <id> && ...` composes.
20
+ set -eu
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
23
+ VERIFY="$SCRIPT_DIR/verify-signoff.sh"
24
+
25
+ [ $# -ge 1 ] || { printf 'usage: wu-done.sh <wu-id> [verify-signoff flags]\n' >&2; exit 2; }
26
+
27
+ # Only stdout is captured (the reason= line); verify-signoff's stderr detail
28
+ # flows straight through to our own stderr so a bad-invocation usage message
29
+ # is never swallowed. The `if` guards the assignment from `set -e` — under
30
+ # errexit a bare `out=$(...); rc=$?` would abort THIS script the instant
31
+ # verify-signoff exits non-zero, before rc=$? ever ran.
32
+ if out="$("$VERIFY" "$@")"; then rc=0; else rc=$?; fi
33
+
34
+ if [ "$rc" -eq 0 ]; then
35
+ printf 'done=true\n'
36
+ exit 0
37
+ fi
38
+
39
+ if [ "$rc" -eq 1 ]; then
40
+ printf 'done=false %s\n' "$out"
41
+ exit 1
42
+ fi
43
+
44
+ # rc 2 (or anything else): bad invocation, not a verification verdict — the
45
+ # fold could not even ask the question, so it must not report false as if it
46
+ # had. verify-signoff already printed the usage error to stderr; just
47
+ # propagate the same exit code.
48
+ exit "$rc"
@@ -86,9 +86,17 @@ linked milestone, emit `stream: implicit` coverage rows, derive `merge_queue` fr
86
86
  hand-edit `active.yml`. Beyond that deterministic write, the Chief adds two
87
87
  **interactive** steps the boot-time rollup omits:
88
88
 
89
- - **Deferred/complete linked milestone**when a stream's `stream.milestone`
90
- points at a `deferred`/`complete` milestone, surface it: the stream should
91
- likely pause offer it.
89
+ - **Complete linked milestone → auto-close (not a prompt).** The rollup's **step
90
+ 0 sweep** (FORGE.md Stream Rollup) already ran: a stream whose
91
+ `stream.milestone` is `complete`, that is not already `closed`, with no live
92
+ worktree, was auto-closed to `status: closed` / `merge.readiness: merged` before
93
+ the join — so it never reaches Merge Cadence as a false "ready to merge". Just
94
+ report it: *"auto-closed stale stream {id} — milestone m-{id} complete, on main."*
95
+ (A `complete` milestone that still has a live worktree is left for forge-boot's
96
+ finalize path.)
97
+ - **Deferred linked milestone** → when a stream's `stream.milestone` points at a
98
+ `deferred` milestone, surface it: the stream should likely **pause** (not close —
99
+ the milestone can resume) — offer it.
92
100
  - **Implicit row needs coordination** → when an active milestone has no stream
93
101
  file (the rollup emits a `stream: implicit` row for coverage), offer to
94
102
  materialize a stream file for it. **Never auto-create.**
@@ -200,7 +208,11 @@ Resume:
200
208
  1. Run the Stream Rollup, then load the stream file and brief.
201
209
  2. Surface status, blockers, owned surfaces, shared surfaces, and next action.
202
210
  3. Set the **stream file's** `status` to `active`, then regenerate `active.yml`.
203
- 4. Run conflict detection before continuing.
211
+ 4. **Relocate into the stream worktree (Native Worktree Entry).** When the resumed stream's file records a `runtime.worktree`, apply the canonical Native Worktree Entry contract (identical to the forge ownership-gate option 1):
212
+ - **If the `EnterWorktree` tool is available**, call `EnterWorktree({path: {runtime.worktree}})` to relocate this session into the stream worktree **in place**, then log `Relocated into worktree {runtime.worktree} ({runtime.branch})` (independent of any prompt, so the cwd move is visible) and continue the resume from inside the worktree.
213
+ - **If `EnterWorktree` is unavailable** (older Claude Code CLI, or a non-Claude-Code adapter), fall back to printing the `cd {path}` instruction (here `cd {runtime.worktree}`) — today's implicit behavior made explicit.
214
+ - Streams with no `runtime.worktree` resume unchanged — no relocation attempted. Exit is always `ExitWorktree({action: "keep"})`; the Chief never lets the native tool remove a worktree (teardown/merge stays the Chief's existing flow).
215
+ 5. Run conflict detection before continuing.
204
216
 
205
217
  ## Delegate Flow
206
218
 
@@ -10,19 +10,21 @@ Read-only. Surface all deferred work across three storage surfaces so user need
10
10
 
11
11
  Three surfaces:
12
12
 
13
- 1. **Milestones** — `.forge/state/index.yml` entries with `status: deferred`. Strategic punts (whole milestone frozen).
13
+ 1. **Milestones** — milestones whose derived `status` is `deferred` (from `.forge/state/milestone-*.yml`; `index.yml` is a render-on-read cache of the same). Strategic punts (whole milestone frozen).
14
14
  2. **Refactor backlog** — `.forge/refactor-backlog.yml` items with `status: deferred`. Code-quality punts.
15
15
  3. **Inside active milestones** — phase/task entries with `status: deferred` or `deferred: true` inside `.forge/state/milestone-*.yml`. Tactical punts within in-flight work.
16
16
 
17
17
  ## Step 1: Read three surfaces
18
18
 
19
- **Surface 1Deferred milestones.** Read `.forge/state/index.yml`. Collect every entry under `milestones:` where `status: deferred`. Pull `id`, `name`, `lifecycle.deferred_at` (if present at top level fall back to `last_updated`), `lifecycle.deferred_reason` (may live in the milestone state file read `.forge/state/milestone-{id}.yml → lifecycle.deferred_at / deferred_reason` for accuracy).
19
+ **Render-on-read (0.53.0).** `.forge/state/index.yml` is a **git-ignored derived cache**, not a committed file it may be **absent** (fresh clone, no boot yet) or **stale**. Never read it as authoritative and never treat its absence as "not a Forge project". Derive each milestone's `id`/`name`/`status` from its source, `.forge/state/milestone-{id}.yml` (the same inputs the `forge` Rollup uses): `status` = `deferred` if `lifecycle.deferred_at` is set and not superseded by a later `resumed_at`, else `complete`/`not_started` from `current.status`, else `active`. (If `index.yml` is present and you trust it fresh, it is a valid fast-path — but the milestone files are the source of truth here.) The project sentinel is `.forge/project.yml` (below).
20
+
21
+ **Surface 1 — Deferred milestones.** Glob `.forge/state/milestone-*.yml` (or read a fresh `.forge/state/index.yml` cache) and collect every milestone whose derived `status` is `deferred`. Pull `id`, `name`, `lifecycle.deferred_at` (fall back to `current.last_updated`), `lifecycle.deferred_reason` (read `.forge/state/milestone-{id}.yml → lifecycle.deferred_at / deferred_reason` for accuracy).
20
22
 
21
23
  **Surface 2 — Deferred refactor items.** Read `.forge/refactor-backlog.yml`. Collect every item with `status: deferred`. Pull `id` (e.g. R-007), `description` (first line, truncate ≤80 chars), `deferred_at`, `deferred_reason`.
22
24
 
23
25
  **Surface 3 — Deferred phases/tasks inside active milestones.** Glob `.forge/state/milestone-*.yml`. For each file:
24
26
 
25
- - Look up that milestone's status in `index.yml`.
27
+ - Determine that milestone's derived status from its `milestone-{id}.yml` (`current.status` / `lifecycle`), not a possibly-absent `index.yml`.
26
28
  - **Only process if `status: active`.** Skip `complete` and `deferred` milestones.
27
29
  - *Why:* a deferred milestone's own `lifecycle.deferred_at` block is already counted by Surface 1. Including it here double-counts. Skipping `complete` avoids historical noise.
28
30
  - Within the file, find phase or task entries with `status: deferred` or `deferred: true`. Pull milestone id, phase/task name, `deferred_at`, `deferred_reason`.
@@ -71,7 +73,9 @@ Then exit.
71
73
 
72
74
  ## Graceful degradation
73
75
 
74
- - Missing `.forge/state/index.yml` → cannot proceed. Print `No forge state found in this project.` exit.
76
+ - Missing `.forge/project.yml` → not a Forge project. Print `No forge state found in this project.` and exit. (Key the sentinel on `project.yml`, a committed file — **not** on `.forge/state/index.yml`, which since 0.53.0 is a git-ignored render-on-read cache and is legitimately absent on a fresh clone until the first `/forge` boot renders it.)
77
+ - Missing/stale `.forge/state/index.yml` → it is a derived cache, not a source. Derive milestone statuses from `.forge/state/milestone-*.yml` directly (or run a `/forge` boot to render the cache). Never error on its absence.
78
+ - No `.forge/state/milestone-*.yml` matches → Surface 1 renders `(none)`. Continue.
75
79
  - Missing `.forge/refactor-backlog.yml` → Surface 2 renders `(none)`. Continue.
76
80
  - No `milestone-*.yml` matches → Surface 3 renders `(none)`. Continue.
77
81
  - Malformed YAML in any source → skip that source, render `(unreadable)` beneath header, continue others.
@@ -20,6 +20,7 @@ Structured conversation: approach, trade-offs, decisions. Clarity, not artifacts
20
20
  - No plans or code
21
21
  - Writes `context.md` progressively (after each confirmed decision, not just at handoff)
22
22
  - No phase/plan required
23
+ - Entered via a worktree already, for new work (R10, `forge/SKILL.md` Step 3) — if this skill is somehow invoked directly on the main checkout for new work, enter one now before writing anything.
23
24
 
24
25
  ## Progressive Persistence
25
26
 
@@ -307,8 +308,9 @@ If no milestone exists (advisory discussion — forge routed here without tier/m
307
308
  - Multi-file, refactor, feature, service changes → **Standard**
308
309
  - Major architectural, multi-subsystem, multi-phase → **Full**
309
310
  3. **Create milestone.**
310
- - Next ID = (max id across existing `.forge/state/milestone-*.yml`) + 1 (or 1 if none)
311
- - Create `milestone-{id}.yml` from `.forge/templates/state/milestone.yml`:
311
+ - Allocate the milestone id via `id=$(.claude/hooks/forge-reserve.sh milestone --milestone m-new)` (ADR-023) — never scan `milestone-*.yml` for max+1 inline (collides across concurrent worktrees; the helper is ledger-backed and scans live + archived milestones).
312
+ - **Write the declaration** (R10/B3' — FORGE.md → Stream Integration Checkpoints, "Producer — publish at intake") — **Standard/Full only; SKIP for Quick** (a quick fix registers no stream, matching context.md M28; it routes to quick-tasking at step 5): copy `.forge/templates/streams/stream.yml` → `.forge/streams/{stream}.yml`, populate `stream.id`, `stream.goal` (one-line summary of the discussion topic), `stream.milestone: m-{id}`, `stream.status: draft`, `stream.tier` = detected tier, `runtime.branch`/`runtime.worktree` from the worktree in use, best-known `ownership.owned_surfaces`/`shared_surfaces` (coarse is fine — claims may go stale as scope evolves; amend later). Commit it — this should be the worktree's first commit if `forge` boot's R10 step already entered one; if this skill is somehow reached before that (direct invoke), enter the worktree now, then commit the declaration. Fast-forward-push it per the FORGE.md mechanic just referenced — do not re-explain the git plumbing here.
313
+ - Create `milestone-{id}.yml` from `.forge/templates/state/milestone.yml` — **worktree-only from here, landing at this milestone's own checkpoint, not pushed immediately**:
312
314
  - `milestone.id` = {id}, `milestone.name` = brief summary of discussion topic
313
315
  - `current.tier` = detected tier
314
316
  - `current.status` = `planning` (Standard) / `architecting` (Full) / `not_started` (Quick)