forge-orkes 0.50.1 → 0.55.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/forge-slice-notify.sh +66 -0
- package/template/.claude/hooks/forge-slice-runner.sh +593 -0
- 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 +11 -3
- 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 +13 -10
- 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.52.0-id-reservation-milestone-refactor.md +45 -0
- package/template/.forge/templates/deferred-issue.md +22 -0
- package/template/.forge/templates/slice-runner/config.yml +62 -0
- package/template/.forge/templates/slice-runner/phase-report.schema.json +51 -0
- package/template/.forge/templates/slice-runner/phase-report.yml +58 -0
|
@@ -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.**
|
|
@@ -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)
|
|
@@ -36,9 +36,9 @@ Run **before the first task begins**. Makes failure causality mechanical — no
|
|
|
36
36
|
1. Run all non-advisory verification commands
|
|
37
37
|
2. Record which tests/checks pass and which fail — this is the baseline
|
|
38
38
|
3. After each commit: failures **not in baseline** = introduced by this task = **must fix under Rule 3**
|
|
39
|
-
4. Failures **present in baseline** = pre-existing →
|
|
39
|
+
4. Failures **present in baseline** = pre-existing → log a file under `.forge/deferred-issues/`, `status: pending` (see Deferred Issues Format)
|
|
40
40
|
|
|
41
|
-
Skip only if re-entering an in-progress execution and `deferred-issues.md` already documents all current failures.
|
|
41
|
+
Skip only if re-entering an in-progress execution and `.forge/deferred-issues/` (or legacy `deferred-issues.md`) already documents all current failures.
|
|
42
42
|
|
|
43
43
|
## Plan Anchor Re-Derivation
|
|
44
44
|
|
|
@@ -63,7 +63,7 @@ Execution-phase operational guidance below supplements the rules — it does not
|
|
|
63
63
|
3 auto-fix attempts on a single task → STOP. Document remaining issues. Move to next task.
|
|
64
64
|
|
|
65
65
|
### Scope Boundary
|
|
66
|
-
Only fix issues DIRECTLY caused by the current task. Pre-existing warnings, tech debt, unrelated bugs → log
|
|
66
|
+
Only fix issues DIRECTLY caused by the current task. Pre-existing warnings, tech debt, unrelated bugs → log a file under `.forge/deferred-issues/`.
|
|
67
67
|
|
|
68
68
|
### Slice Integrity (Execution-Side)
|
|
69
69
|
|
|
@@ -203,14 +203,14 @@ For each command, in order:
|
|
|
203
203
|
Attempt 1:
|
|
204
204
|
1. Read error output
|
|
205
205
|
2. In baseline snapshot?
|
|
206
|
-
- YES (pre-existing) → mark advisory for this session;
|
|
206
|
+
- YES (pre-existing) → mark advisory for this session; log a file under .forge/deferred-issues/; continue
|
|
207
207
|
- NO (introduced by this task) → fix code, stage fixes, amend commit
|
|
208
208
|
3. Re-run command
|
|
209
209
|
4. Pass → next command
|
|
210
210
|
5. Fail → next attempt (up to max_retries)
|
|
211
211
|
|
|
212
212
|
After max_retries exhausted:
|
|
213
|
-
→
|
|
213
|
+
→ Log a file under .forge/deferred-issues/
|
|
214
214
|
→ Log failure in execution summary
|
|
215
215
|
→ Continue to next task
|
|
216
216
|
```
|
|
@@ -219,26 +219,30 @@ After max_retries exhausted:
|
|
|
219
219
|
Verification retries count toward the task's 3-strike limit. 2 strikes used = 1 verification retry max.
|
|
220
220
|
|
|
221
221
|
### Do NOT Fix
|
|
222
|
-
- **Pre-existing failures** present in baseline snapshot → mark advisory;
|
|
222
|
+
- **Pre-existing failures** present in baseline snapshot → mark advisory; log a file under `.forge/deferred-issues/`
|
|
223
223
|
- **Flaky tests** passing on re-run without changes → note in summary, no strike
|
|
224
224
|
- **Unrelated warnings** (deprecation, non-blocking lint) → ignore
|
|
225
225
|
|
|
226
226
|
### Deferred Issues Format
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
**One file per failure** (ADR-023 — no DI-NNN counter). Copy `.forge/templates/deferred-issue.md`
|
|
229
|
+
→ `.forge/deferred-issues/{YYYY-MM-DD}-{milestone}-{slug}.md`, where `{slug}` is a short kebab of
|
|
230
|
+
the failure (e.g. `redis-unavailable`). Distinct filenames mean concurrent worktrees never collide —
|
|
231
|
+
the same anti-collision property as `.forge/state/desire-paths/`.
|
|
229
232
|
|
|
230
233
|
```yaml
|
|
231
|
-
issues
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
status: pending
|
|
234
|
+
# .forge/deferred-issues/2026-04-14-m3-redis-unavailable.md
|
|
235
|
+
type: test_failure # test_failure | lint_failure | build_failure
|
|
236
|
+
command: "npm test"
|
|
237
|
+
summary: "HealthEndpointTest — Redis unavailable in test env"
|
|
238
|
+
first_seen: "2026-04-14"
|
|
239
|
+
milestone: "m3"
|
|
240
|
+
status: pending
|
|
239
241
|
```
|
|
240
242
|
|
|
241
|
-
|
|
243
|
+
**Legacy read (lazy migration):** if `.forge/deferred-issues/` is absent, the old single-file
|
|
244
|
+
`.forge/deferred-issues.md` (DI-NNN `issues[]` entries) is still read — no data loss before migration.
|
|
245
|
+
Never allocate a DI-NNN number; the filename slug is the id.
|
|
242
246
|
|
|
243
247
|
### Quick Tier
|
|
244
248
|
Run all non-advisory verification commands once after commit. 1 retry max.
|
|
@@ -307,7 +311,7 @@ After completing all tasks in a plan:
|
|
|
307
311
|
|
|
308
312
|
## Notes
|
|
309
313
|
[Genuine handoff context only: environment requirements, seed data, external services needed (e.g. "Redis must be running for auth tests").
|
|
310
|
-
Do NOT list test failures here — pre-existing failures belong in deferred-issues
|
|
314
|
+
Do NOT list test failures here — pre-existing failures belong in `.forge/deferred-issues/`; task-introduced failures must be fixed before committing.]
|
|
311
315
|
```
|
|
312
316
|
|
|
313
317
|
## State Updates
|
|
@@ -65,13 +65,13 @@ Cross-machine note: the flag file is machine-local, so a worktree (or the primar
|
|
|
65
65
|
- **Absent registry, but IDs already exist.** `.forge/reservations.yml` missing *and* the tree already holds allocated IDs (`requirements/*.yml` has `FR-`/`NFR-`/`DEF-`, or `.forge/decisions/` has `ADR-NNN`) → one-time nudge: *"No `reservations.yml` yet, but this project already allocates IDs. Concurrent worktrees will collide on the next number. Create it by reserving your next ID via the protocol (`planning`/`architecting` do this), or backfill existing IDs now."* (No IDs anywhere → silent; the first reservation creates the file lazily.)
|
|
66
66
|
- **Backfill scan (un-reserved landed IDs).** When `reservations.yml` exists, diff the IDs actually landed in the tree (`FR-`/`NFR-`/`DEF-` across `requirements/*.yml`; `ADR-NNN` across `.forge/decisions/`) against the ids recorded in `reservations.yml`. Any landed ID with **no** reservation entry → list them once: *"{N} ID(s) are in the tree but not in `reservations.yml` ({ids}) — landed before the protocol or via an un-reserved path. They are still respected (next = max(reserved, in-tree)+1), but appending backfill entries keeps the registry the single audit trail."* Advisory: surface, don't auto-write — backfilling is the operator's call.
|
|
67
67
|
|
|
68
|
-
**Stream Rollup (active.yml is derived).** If `.forge/streams/` has any stream files, regenerate `.forge/streams/active.yml` from them + active milestones per FORGE.md → Stream Rollup, the same way step 1.0 regenerates `index.yml`. **Run the 1.0 State Rollup (below) first** so `index.yml` is fresh — the Stream Rollup reads active milestones from it for implicit-row coverage, so on a boot where a just-merged milestone promotion lands, a stale `index.yml` would silently drop that milestone's coverage row. `active.yml` is derived — never read it as authoritative without rolling up first, and never hand-edit it.
|
|
68
|
+
**Stream Rollup (active.yml is derived).** If `.forge/streams/` has any stream files, regenerate `.forge/streams/active.yml` from them + active milestones per FORGE.md → Stream Rollup, the same way step 1.0 regenerates `index.yml`. **Run the 1.0 State Rollup (below) first** so `index.yml` is fresh — the Stream Rollup reads active milestones from it for implicit-row coverage, so on a boot where a just-merged milestone promotion lands, a stale `index.yml` would silently drop that milestone's coverage row. This includes the rollup's **step 0 completed-stream auto-close sweep** (FORGE.md → Stream Rollup): a milestone-backed stream whose milestone is now `complete`, that is not already `closed`, and has no live worktree, is auto-closed (`status: closed`, `merge.readiness: merged`) *before* the join — so a done-and-merged milestone can never leave a zombie `ready` row that re-nags every boot. Announce each: *"auto-closed stale stream {id} — milestone m-{id} complete, already on main."* (A `complete` milestone that still has a live worktree is left for the finalize path below, not swept.) `active.yml` is derived — never read it as authoritative without rolling up first, and never hand-edit it. Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed), so rendering it is a local-cache write that never lands on `main`; the source writes it still performs (the step-0 auto-close sweep touches *stream files*, which remain committed) run in the main checkout as before. No `streams/` dir → skip.
|
|
69
69
|
|
|
70
|
-
**Ready-to-merge nudge (main checkout / streams without checkpoints).** From the just-regenerated `active.yml
|
|
70
|
+
**Ready-to-merge nudge (main checkout / streams without checkpoints).** From the just-regenerated `active.yml` — already swept of completed-milestone zombies by the rollup's step 0 — scan for streams with `coordination: ready` or a non-empty `merge_queue` **that are not auto-publishing via checkpoints**. Because the sweep ran first, anything surfaced here is genuinely mergeable (open work, not yet on main), so the nudge is truthful: *"{N} stream(s) ready to merge — run `/chief-of-staff` (merge safe) to integrate."* Pointer only — the cadence logic lives in the Chief's Merge Cadence Check; `forge` never merges. Nothing pending → silent.
|
|
71
71
|
|
|
72
72
|
### 1.0 State Rollup (index.yml is derived)
|
|
73
73
|
|
|
74
|
-
`index.yml` is a **derived registry** — regenerate it from the milestone files before reading, and after any milestone CRUD (promote/defer/resume/delete). **Never hand-edit `index.yml`.**
|
|
74
|
+
`index.yml` is a **derived registry** — regenerate it from the milestone files before reading, and after any milestone CRUD (promote/defer/resume/delete). **Never hand-edit `index.yml`.** Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed): rendering it here is a local-cache write, so it never conflicts and never needs the orchestrator-only guard for *safety* — any session may render its own copy. The rollup below is unchanged; only the storage is (see FORGE.md → State Ownership, Derived class).
|
|
75
75
|
|
|
76
76
|
Rollup procedure (deterministic + idempotent):
|
|
77
77
|
1. Glob `.forge/state/milestone-*.yml`.
|
|
@@ -176,16 +176,17 @@ Check `.forge/refactor-backlog.yml`:
|
|
|
176
176
|
**Promotion procedure (effort: standard only):**
|
|
177
177
|
|
|
178
178
|
1. Read backlog item `id` (e.g. `R61-002`). Synthesize milestone id `m-{R-id}` (e.g. `m-R61-002`).
|
|
179
|
-
2.
|
|
179
|
+
2. **Enter the worktree first (R10 — this runs in Step 1, *before* Step 3's routing gate fires, so do not assume a worktree already exists):** if this session is still on the main checkout (`git rev-parse --git-dir` == `--git-common-dir`), `EnterWorktree` now; if R10 already entered one, this is simply the first write in it. Then write the declaration (`.forge/templates/streams/stream.yml` → `.forge/streams/{stream}.yml` — `stream.goal`, `stream.milestone: m-{R-id}`, `stream.status: draft`, `stream.tier: standard`, `runtime.branch`/`runtime.worktree` from the worktree just entered, best-known `ownership.owned_surfaces`/`shared_surfaces`) as the worktree's first commit and fast-forward-push it, per FORGE.md's Stream Integration Checkpoints intake-declaration trigger.
|
|
180
|
+
3. Copy `.forge/templates/state/milestone.yml` → `.forge/state/milestone-{m-R-id}.yml`. Populate:
|
|
180
181
|
- `milestone.id: m-{R-id}`
|
|
181
182
|
- `milestone.name: "Promoted from {R-id}: {backlog item title}"`
|
|
182
183
|
- `milestone.origin: {R-id}`
|
|
183
184
|
- `current.tier: standard`, `current.status: researching`
|
|
184
185
|
- `current.last_updated: {ISO date}`
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
4. Run **Rollup (1.0)** — regenerates `index.yml` from the new milestone file (do not hand-append the registry entry). New milestone id is `m-{R-id}` from step 1; for numeric ids, allocate via `id=$(.claude/hooks/forge-reserve.sh milestone --milestone m-new)` (ADR-023) — never scan `milestone-*.yml` for max+1 inline. The three milestone-id call sites (here, `discussing`, `prototyping`) share this ledger-backed allocator so they cannot disagree, and it scans live **+ archived** milestones so a burned id is never reused.
|
|
187
|
+
5. Append to `.forge/roadmap.yml` — one milestone entry + one phase (`refactor-{R-id}`, `requirements_source: refactor-backlog.yml#{R-id}`, `dependencies: []`).
|
|
188
|
+
6. Update `.forge/refactor-backlog.yml` item: `status: in_progress`, add `promoted_to: m-{R-id}`.
|
|
189
|
+
7. Confirm: *"Promoted {R-id} → milestone m-{R-id}. State + roadmap created. Routing to researching."*
|
|
189
190
|
|
|
190
191
|
```text
|
|
191
192
|
backlog pickup → effort: standard
|
|
@@ -357,6 +358,8 @@ Tier + state → invoke via `Skill` tool. All phases use `Skill()`.
|
|
|
357
358
|
|
|
358
359
|
**CRITICAL: NEVER `EnterPlanMode`.** "Planning" = `Skill(planning)`. Native plan mode writes wrong format, bypasses gates + state.
|
|
359
360
|
|
|
361
|
+
**CRITICAL: New work enters its worktree before anything else (R10).** When routing genuinely NEW work — `current.status: not_started` about to detect tier and start, or any session about to invoke `researching`/`discussing`/`planning`/`quick-tasking`/`prototyping` for a request that has no milestone yet — call `EnterWorktree` (Worktree Convention, FORGE.md) BEFORE invoking that skill. The main checkout is for orchestration and reads. Applies to every tier, including Quick and Prototyping: one `EnterWorktree` call, no heavier ceremony — a Quick fix or a disposable mockup gets a worktree but no milestone/stream declaration (nothing is being registered until, for a prototype, it graduates). A mockup is disposable but not weightless — it writes flag-gated app code + a prototype state file the team shares and may fold into a feature-toggled section — so its initial entry is worktree-first like the rest. Earlier practice held off on worktree isolation until implementation began, on the theory that research/discussion/planning don't touch code — that theory doesn't hold: discussing/planning/researching write `.forge` state too (context.md, plans, research notes), and per P17 those writes left untracked droppings on main that broke other worktrees' landings. No phase may hold off on worktree entry for new work. Milestone/stream intake itself is a deliberate exception with its own mechanic — see FORGE.md → Stream Integration Checkpoints for the declaration/content split (B3'). Pure read-only sessions that start no work need no worktree. This governs the FIRST write of new work; resuming an in-flight milestone already routes through the Live-Worktree Ownership Gate (1.1a) / branch-anchor path (Step 1.1) instead.
|
|
362
|
+
|
|
360
363
|
**Experimental — M10 orchestration backend.** Chief/Streams is the preferred
|
|
361
364
|
user-facing orchestration model. M10 remains an optional backend for streams
|
|
362
365
|
that need worktree isolation, merge-queue discipline, or experimental file
|
|
@@ -364,7 +367,7 @@ claims. Route through `Skill(orchestrating)` first when **all** hold:
|
|
|
364
367
|
- **Selected:** Project Chief chooses M10 for a stream, user explicitly asks for multi-agent/worktree mode, or legacy `orchestration.auto` is enabled.
|
|
365
368
|
- **Installed:** `.claude/skills/orchestrating/` present + `forge-orchestrator` in `.mcp.json` + `.claude/hooks/forge-claim-check.sh` present.
|
|
366
369
|
- **Not opted out:** `orchestration.auto` in `project.yml` ≠ `false` for legacy auto-routing; explicit Project Chief selection can still use M10 when installed.
|
|
367
|
-
- **Tier is Standard or Full.** Quick never auto-orchestrates (a worktree per
|
|
370
|
+
- **Tier is Standard or Full.** Quick never auto-orchestrates — the *orchestration backend* (MCP file-claims, merge queue, bootstrap) is the wrong trade for a typo. (The plain worktree is not the cost: per R10 a Quick fix already runs in one — `EnterWorktree` is ~instant. What Quick skips here is the M10 machinery on top, not the worktree.)
|
|
368
371
|
- **Not already in a session:** active milestone has no `lifecycle.worktree_mode: active|degraded` (don't re-bootstrap inside a live session).
|
|
369
372
|
|
|
370
373
|
`orchestrating` bootstrap stays the safety net — incompatible repo → it refuses, writes `worktree_mode: refused`, and `forge` continues single-agent. Absent install → standard routing. Manual `Skill(orchestrating)` still works for explicit backend control; set `orchestration.auto: false` in `project.yml` to opt out of legacy auto-routing.
|
|
@@ -411,7 +414,7 @@ Where `{source}` = `skills.{name}` | `models.default` | `parent session`. Suppre
|
|
|
411
414
|
|
|
412
415
|
| `current.status` | Route To |
|
|
413
416
|
|-------------------|----------|
|
|
414
|
-
| `not_started` |
|
|
417
|
+
| `not_started` | EnterWorktree (R10), then detect tier, start |
|
|
415
418
|
| `researching` | `Skill(researching)` → discussing |
|
|
416
419
|
| `discussing` | `Skill(discussing)` → planning (or architecting if Full) |
|
|
417
420
|
| `architecting` | `Skill(architecting)` → planning |
|