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 +1 -1
- package/template/.claude/hooks/forge-reserve.sh +51 -15
- package/template/.claude/hooks/tests/verify-signoff.test.sh +122 -0
- package/template/.claude/hooks/tests/wu-done.test.sh +68 -0
- package/template/.claude/hooks/verify-signoff.sh +139 -0
- package/template/.claude/hooks/wu-done.sh +48 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +16 -4
- package/template/.claude/skills/deferred/SKILL.md +8 -4
- package/template/.claude/skills/discussing/SKILL.md +4 -2
- package/template/.claude/skills/executing/SKILL.md +21 -17
- package/template/.claude/skills/forge/SKILL.md +17 -11
- package/template/.claude/skills/planning/SKILL.md +3 -1
- package/template/.claude/skills/prototyping/SKILL.md +10 -7
- package/template/.claude/skills/quick-tasking/SKILL.md +3 -1
- package/template/.claude/skills/researching/SKILL.md +2 -0
- package/template/.claude/skills/reviewing/SKILL.md +7 -7
- package/template/.claude/skills/verifying/SKILL.md +8 -7
- package/template/.forge/FORGE.md +14 -7
- package/template/.forge/gitignore +8 -0
- package/template/.forge/migrations/0.50.1-template-releases-scrub.md +91 -0
- package/template/.forge/migrations/0.52.0-id-reservation-milestone-refactor.md +45 -0
- package/template/.forge/releases.yml +13 -106
- package/template/.forge/templates/deferred-issue.md +22 -0
package/package.json
CHANGED
|
@@ -51,8 +51,10 @@ case "$kind" in
|
|
|
51
51
|
def) prefix="DEF" ;;
|
|
52
52
|
fr) prefix="FR" ;;
|
|
53
53
|
nfr) prefix="NFR" ;;
|
|
54
|
-
""
|
|
55
|
-
|
|
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 {
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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]
|
|
123
|
-
| awk -
|
|
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
|
|
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
|
-
- **
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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`
|
|
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
|
-
**
|
|
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
|
-
-
|
|
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/
|
|
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
|
-
-
|
|
311
|
-
-
|
|
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)
|