forge-orkes 0.77.0 → 0.80.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/bin/create-forge.js +27 -0
- package/experimental/m10/README.md +4 -4
- package/experimental/m10/install.sh +10 -9
- package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
- package/package.json +1 -1
- package/template/.claude/hooks/forge-context-migrate.sh +297 -0
- package/template/.claude/hooks/forge-size-gate.sh +175 -0
- package/template/.claude/hooks/forge-state-rollup.sh +135 -2
- package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
- package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
- package/template/.claude/hooks/tests/forge-context-migrate.test.sh +166 -0
- package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
- package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
- package/template/.claude/settings.json +14 -1
- package/template/.claude/skills/discussing/SKILL.md +30 -31
- package/template/.claude/skills/executing/SKILL.md +3 -3
- package/template/.claude/skills/forge/SKILL.md +112 -170
- package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
- package/template/.claude/skills/initializing/SKILL.md +20 -1
- package/template/.claude/skills/planning/SKILL.md +11 -9
- package/template/.claude/skills/researching/SKILL.md +2 -0
- package/template/.claude/skills/reviewing/SKILL.md +17 -0
- package/template/.claude/skills/upgrading/SKILL.md +22 -2
- package/template/.claude/skills/verifying/SKILL.md +2 -2
- package/template/.forge/FORGE.md +81 -90
- package/template/.forge/bin/new-worktree.sh +61 -0
- package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
- package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
- package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
- package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -0
- package/template/.forge/git-hooks/pre-commit +57 -0
- package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
- package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
- package/template/.forge/migrations/0.80.0-context-md-sharding.md +158 -0
- package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
- package/template/.forge/templates/constitution.md +1 -1
- package/template/.forge/templates/context-milestone.md +30 -0
- package/template/.forge/templates/slice-runner/config.yml +7 -1
- package/template/.forge/templates/state/desire-path.yml +3 -0
- package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
package/bin/create-forge.js
CHANGED
|
@@ -124,6 +124,24 @@ function makeHookScriptsExecutable(hooksDir) {
|
|
|
124
124
|
try { walk(hooksDir); } catch { /* best-effort */ }
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Make the tracked git hooks under .forge/git-hooks/ executable (issue #21 / ADR-031).
|
|
129
|
+
* These are git hooks (pre-commit, …) with NO .sh extension, so makeHookScriptsExecutable
|
|
130
|
+
* (which only chmods *.sh) misses them — chmod them explicitly. Best-effort; never throws.
|
|
131
|
+
* NOTE: this function deliberately does NO `git config core.hooksPath` — per-clone wiring
|
|
132
|
+
* (with the Husky/lefthook conflict warning) is owned by the initializing/upgrading skills,
|
|
133
|
+
* not the installer, which stays git-config-free and runs unattended in CI.
|
|
134
|
+
*/
|
|
135
|
+
function makeGitHooksExecutable(forgeDir) {
|
|
136
|
+
const gitHooksDir = path.join(forgeDir, 'git-hooks');
|
|
137
|
+
if (!fs.existsSync(gitHooksDir)) return;
|
|
138
|
+
for (const entry of fs.readdirSync(gitHooksDir, { withFileTypes: true })) {
|
|
139
|
+
if (entry.isFile() && !entry.name.startsWith('.')) {
|
|
140
|
+
try { fs.chmodSync(path.join(gitHooksDir, entry.name), 0o755); } catch { /* best-effort */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
127
145
|
/**
|
|
128
146
|
* Additively sync a framework-managed `.claude/` dir into an existing project on
|
|
129
147
|
* upgrade (ADR-017/018). Copies/updates the template's files, but NEVER deletes
|
|
@@ -498,6 +516,7 @@ async function install() {
|
|
|
498
516
|
const srcForge = path.join(templateDir, '.forge');
|
|
499
517
|
const destForge = path.join(targetDir, '.forge');
|
|
500
518
|
const forgeCount = copyDirRecursive(srcForge, destForge);
|
|
519
|
+
makeGitHooksExecutable(destForge); // extensionless git hooks (.forge/git-hooks/pre-commit)
|
|
501
520
|
console.log(` Installed .forge/templates/ (${forgeCount} files)`);
|
|
502
521
|
|
|
503
522
|
// npm strips files literally named `.gitignore` from the published tarball, so
|
|
@@ -858,6 +877,14 @@ async function upgrade() {
|
|
|
858
877
|
// protected set (.forge/checks/**) can guard them. Additive for the same
|
|
859
878
|
// reason as above: never delete a project-local extra check.
|
|
860
879
|
upgradeAdditiveDir('.forge/checks', results);
|
|
880
|
+
// 2-git-hooks. Sync the tracked git hooks (issue #21 / ADR-031: the primary-checkout
|
|
881
|
+
// weld pre-commit backstop). Tracked at .forge/git-hooks/ for the same reason as
|
|
882
|
+
// .forge/checks/ — .claude/hooks/ is commonly gitignored, and a git hook must be
|
|
883
|
+
// tracked to run for humans/CI. Additive; then chmod the extensionless hooks
|
|
884
|
+
// (upgradeAdditiveDir only +x's *.sh). Per-clone core.hooksPath wiring is the
|
|
885
|
+
// initializing/upgrading skills' job, NOT the installer's — see makeGitHooksExecutable.
|
|
886
|
+
upgradeAdditiveDir('.forge/git-hooks', results);
|
|
887
|
+
makeGitHooksExecutable(path.join(targetDir, '.forge'));
|
|
861
888
|
|
|
862
889
|
// 2a. Confirm stale framework-file removals before deleting (R066a). Defaults
|
|
863
890
|
// to yes (cleanup is the normal case); auto-confirms when non-interactive
|
|
@@ -55,7 +55,7 @@ Idempotent — re-running emits warnings instead of errors.
|
|
|
55
55
|
If you'd rather see exactly what changes:
|
|
56
56
|
|
|
57
57
|
1. Copy `source/mcp-server/` → `<target>/.forge/.mcp-server/`
|
|
58
|
-
2. Copy `source/hooks/forge-claim-check.sh`, `source/hooks/forge-claim-check-doctor.sh`, `source/hooks/forge-session-id.sh`, and `source/hooks/
|
|
58
|
+
2. Copy `source/hooks/forge-claim-check.sh`, `source/hooks/forge-claim-check-doctor.sh`, `source/hooks/forge-session-id.sh`, and `source/hooks/protect-primary-checkout.sh` → `<target>/.claude/hooks/` (`chmod +x`)
|
|
59
59
|
3. Copy `source/skills/orchestrating/` → `<target>/.claude/skills/orchestrating/`
|
|
60
60
|
4. Merge `forge-orchestrator` entry into `<target>/.mcp.json`:
|
|
61
61
|
```json
|
|
@@ -71,12 +71,12 @@ If you'd rather see exactly what changes:
|
|
|
71
71
|
{ "hooks": [{ "type": "command", "command": ".claude/hooks/forge-session-id.sh" }] }
|
|
72
72
|
]
|
|
73
73
|
```
|
|
74
|
-
Also add the
|
|
74
|
+
Also add the primary-checkout guardrail under `PreToolUse`:
|
|
75
75
|
```json
|
|
76
76
|
{ "matcher": "Bash",
|
|
77
|
-
"hooks": [{ "type": "command", "command": ".claude/hooks/
|
|
77
|
+
"hooks": [{ "type": "command", "command": ".claude/hooks/protect-primary-checkout.sh" }] }
|
|
78
78
|
```
|
|
79
|
-
The SessionStart hook captures the Claude session id (claim identity); without it the enforcement gate fail-opens (ADR-007). The
|
|
79
|
+
The SessionStart hook captures the Claude session id (claim identity); without it the enforcement gate fail-opens (ADR-007). The primary-checkout guard welds the primary checkout to the default branch — all branch work happens in worktrees (issue #21, ADR-031) — orchestration-agnostic, independent of the claim layer. It supersedes the old forge-branch-guard.sh (ADR-008) and folds in its hard-reset protection.
|
|
80
80
|
6. `cd <target>/.forge/.mcp-server && npm install`
|
|
81
81
|
|
|
82
82
|
## Verify
|
|
@@ -72,7 +72,7 @@ echo
|
|
|
72
72
|
# Step 3: copy hooks
|
|
73
73
|
echo "==> Hooks → .claude/hooks/"
|
|
74
74
|
do_or_say "mkdir -p '$TARGET/.claude/hooks'"
|
|
75
|
-
for h in forge-claim-check.sh forge-claim-check-doctor.sh forge-session-id.sh
|
|
75
|
+
for h in forge-claim-check.sh forge-claim-check-doctor.sh forge-session-id.sh protect-primary-checkout.sh; do
|
|
76
76
|
if [[ -f "$TARGET/.claude/hooks/$h" ]]; then
|
|
77
77
|
say "skip: $h already exists"
|
|
78
78
|
else
|
|
@@ -161,21 +161,22 @@ else
|
|
|
161
161
|
fi
|
|
162
162
|
echo
|
|
163
163
|
|
|
164
|
-
# Step 6c: register
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
# Step 6c: register the primary-checkout branch weld (PreToolUse on Bash). Welds the
|
|
165
|
+
# primary checkout to the default branch — all branch work happens in worktrees (issue
|
|
166
|
+
# #21). Orchestration-agnostic; supersedes the old forge-branch-guard.sh.
|
|
167
|
+
echo "==> .claude/settings.json primary-checkout guard hook"
|
|
168
|
+
BG_CMD='.claude/hooks/protect-primary-checkout.sh'
|
|
168
169
|
if [[ ! -f "$SETTINGS" ]]; then
|
|
169
|
-
say "[dry-run] settings.json not yet created —
|
|
170
|
+
say "[dry-run] settings.json not yet created — primary-checkout guard would be added alongside the others"
|
|
170
171
|
elif jq -e --arg cmd "$BG_CMD" '.hooks.PreToolUse[]?.hooks[]? | select(.command == $cmd)' "$SETTINGS" >/dev/null 2>&1; then
|
|
171
|
-
say "skip:
|
|
172
|
+
say "skip: protect-primary-checkout.sh hook already registered"
|
|
172
173
|
else
|
|
173
174
|
if [[ $DRY_RUN -eq 0 ]]; then
|
|
174
175
|
tmp=$(mktemp)
|
|
175
|
-
jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":".claude/hooks/
|
|
176
|
+
jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":".claude/hooks/protect-primary-checkout.sh"}]}]' "$SETTINGS" > "$tmp"
|
|
176
177
|
mv "$tmp" "$SETTINGS"
|
|
177
178
|
fi
|
|
178
|
-
say "appended
|
|
179
|
+
say "appended primary-checkout guard PreToolUse(Bash) hook entry"
|
|
179
180
|
fi
|
|
180
181
|
echo
|
|
181
182
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Weld the primary checkout to the default branch — deny branch create/switch there.
|
|
3
|
+
#
|
|
4
|
+
# PreToolUse(Bash) hook. Exit 2 (+ JSON permissionDecision:deny) = block. Exit 0 = allow.
|
|
5
|
+
#
|
|
6
|
+
# WHY: the primary checkout's HEAD is shared mutable state across every concurrent
|
|
7
|
+
# Claude session running in that directory. One session running `git checkout -b`
|
|
8
|
+
# silently redirects every OTHER session's subsequent commits onto its branch — an
|
|
9
|
+
# invisible clobber that surfaces later as wrong-branch commits, contaminated PRs,
|
|
10
|
+
# stranded milestone state, or dropped history (issue #21: 3 confirmed incidents —
|
|
11
|
+
# m85's 12-commit drop, m62's stranded phase commits, a Quick-tier checkout -b that
|
|
12
|
+
# contaminated a concurrent PR). Branch work belongs in worktrees, which each own
|
|
13
|
+
# their own HEAD.
|
|
14
|
+
#
|
|
15
|
+
# Scope: fires ONLY in the primary checkout (git-dir == git-common-dir). Linked
|
|
16
|
+
# worktrees are unaffected (their git-dir differs). Unlike the dormant
|
|
17
|
+
# forge-branch-guard.sh this supersedes, the branch weld is UNCONDITIONAL — it fires
|
|
18
|
+
# even when no sibling worktree exists yet, because the second session's worktree is
|
|
19
|
+
# typically created AFTER the offending `checkout -b`. This hook also folds in the
|
|
20
|
+
# one distinct protection the old guard provided: denying a hard-reset in the primary
|
|
21
|
+
# checkout while sibling worktrees are branched off it (the history-rewrite class).
|
|
22
|
+
#
|
|
23
|
+
# Default branch is resolved dynamically (main/master + init.defaultBranch), never
|
|
24
|
+
# hardcoded — Forge ships to repos on any default branch.
|
|
25
|
+
#
|
|
26
|
+
# Fails OPEN (exit 0 / allow) on any malfunction (no jq, empty command, non-git cwd,
|
|
27
|
+
# git probe error) — a guard must never wedge the agent on its own failure.
|
|
28
|
+
#
|
|
29
|
+
# Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1
|
|
30
|
+
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
emit_deny() {
|
|
34
|
+
local reason="${1//\"/\\\"}"
|
|
35
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
|
|
36
|
+
exit 2
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
|
|
40
|
+
|
|
41
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
42
|
+
|
|
43
|
+
INPUT=$(cat)
|
|
44
|
+
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
|
|
45
|
+
[ -z "$COMMAND" ] && exit 0
|
|
46
|
+
|
|
47
|
+
# Normalize the cheapest whitespace-substitution evasion (`git checkout$IFS-b`) to a
|
|
48
|
+
# real space before matching. A string matcher on an un-expanded shell command can
|
|
49
|
+
# never be complete against expansion — the pre-commit backstop is the sound layer —
|
|
50
|
+
# but closing the trivial $IFS trick keeps the PreToolUse layer honest.
|
|
51
|
+
COMMAND=$(printf '%s' "$COMMAND" | sed -E 's/\$\{?IFS\}?/ /g')
|
|
52
|
+
|
|
53
|
+
# Git allows global options BEFORE the subcommand: `git -c k=v checkout`,
|
|
54
|
+
# `git --no-pager switch`, `git -C <path> -p checkout`, etc. The guard must tolerate
|
|
55
|
+
# ANY run of them, not just a single `-C <path>` — otherwise `git -c x=y checkout -b`
|
|
56
|
+
# sails past (issue #21 regression). GITPRE matches `git` + zero-or-more global options
|
|
57
|
+
# (a `-x`/`--long`/`--long=val`/`-C <path>`/`-c <kv>` token, each optionally taking the
|
|
58
|
+
# next word as its value) up to the subcommand.
|
|
59
|
+
GITPRE='git[[:space:]]+((-[A-Za-z]|--[A-Za-z-]+)(=[^[:space:]]+)?[[:space:]]+([^-][^[:space:]]*[[:space:]]+)?)*'
|
|
60
|
+
|
|
61
|
+
# Fast path: only inspect commands that touch checkout/switch/reset at all. Anything
|
|
62
|
+
# else exits immediately WITHOUT any git probe (keeps the hook cheap on every Bash call).
|
|
63
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch|reset)([[:space:]]|\$)" || exit 0
|
|
64
|
+
|
|
65
|
+
# Resolve which repo dir the command targets: honor `git -C <path>`, else the hook cwd.
|
|
66
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
|
|
67
|
+
[ -z "$CWD" ] && CWD=$(pwd)
|
|
68
|
+
# Honor a GLOBAL `git -C <path>` (before the subcommand) only. Isolate the pre-subcommand
|
|
69
|
+
# segment first so `switch -C`/`checkout -C` (create-force, AFTER the subcommand) is never
|
|
70
|
+
# mistaken for a target-dir option.
|
|
71
|
+
PRE=$(printf '%s' "$COMMAND" | sed -E 's/[[:space:]](checkout|switch|reset)[[:space:]].*$//')
|
|
72
|
+
GC_PATH=$(printf '%s' "$PRE" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' | head -1 | awk '{print $2}' || true)
|
|
73
|
+
# A flag-shaped -C value (e.g. `-C --foo`) is not a real target dir → ignore it.
|
|
74
|
+
case "$GC_PATH" in -*) GC_PATH="" ;; esac
|
|
75
|
+
TARGET_DIR="${GC_PATH:-$CWD}"
|
|
76
|
+
|
|
77
|
+
GIT_DIR=$(git -C "$TARGET_DIR" rev-parse --git-dir 2>/dev/null) || exit 0
|
|
78
|
+
COMMON_DIR=$(git -C "$TARGET_DIR" rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
79
|
+
GIT_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
|
|
80
|
+
COMMON_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
|
|
81
|
+
|
|
82
|
+
# In a linked worktree git-dir != git-common-dir → branch ops are fine there.
|
|
83
|
+
[ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
|
|
84
|
+
|
|
85
|
+
# ── Default-branch set (dynamic — mirrors block-dangerous-commands.sh) ───────────
|
|
86
|
+
# Seed main+master, add the configured init.defaultBranch. A switch/checkout TO any
|
|
87
|
+
# of these names is "returning home" and always allowed.
|
|
88
|
+
DEFAULT_BRANCHES="main master"
|
|
89
|
+
GIT_DEFAULT=$(git -C "$TARGET_DIR" config --get init.defaultBranch 2>/dev/null || true)
|
|
90
|
+
[ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
|
|
91
|
+
is_default_branch() {
|
|
92
|
+
for b in $DEFAULT_BRANCHES; do [ "$1" = "$b" ] && return 0; done
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
DENY_MSG="Blocked: the primary checkout's HEAD stays on the default branch — branch create/switch here silently redirects every other concurrent session's commits onto your branch (recurring clobber, issue #21). Do branch work in a worktree: bash .forge/bin/new-worktree.sh <slug> (or git worktree add). To operate on an existing worktree from here, use git -C <worktree-path>. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
97
|
+
|
|
98
|
+
# ── 1) Branch creation / orphan / detach / track: always deny in primary checkout ──
|
|
99
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}checkout[[:space:]]+((-[a-zA-Z]*[bB][a-zA-Z]*|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
100
|
+
emit_deny "$DENY_MSG"
|
|
101
|
+
fi
|
|
102
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}switch[[:space:]]+((-[cC]|--create|--force-create|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
103
|
+
emit_deny "$DENY_MSG"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# ── 2) hard/keep/merge reset in the primary checkout while worktrees are branched ──
|
|
107
|
+
# Folded from the superseded forge-branch-guard.sh: a reset that rewrites HEAD +
|
|
108
|
+
# working tree here can drop verified work committed under sibling worktrees. Gated
|
|
109
|
+
# on >1 worktree (unlike the branch weld, which is unconditional).
|
|
110
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}reset[[:space:]]+(--hard|--keep|--merge)([[:space:]]|\$)"; then
|
|
111
|
+
wt_count=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l | tr -d ' ')
|
|
112
|
+
if [ "${wt_count:-1}" -gt 1 ]; then
|
|
113
|
+
emit_deny "Refusing to move the shared primary HEAD via reset — $wt_count worktrees are branched off it (the 'burned twice' history-rewrite failure, issue #21). Reset inside a worktree, or stop the other sessions first. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
114
|
+
fi
|
|
115
|
+
exit 0 # single-worktree hard-reset is the operator's own business — allow.
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# ── 3) checkout/switch to a ref other than the default branch ───────────────────
|
|
119
|
+
# Only checkout/switch move HEAD to a ref. A reset that reached this far (--soft /
|
|
120
|
+
# --mixed, or a hard/keep/merge reset in a single-worktree repo) is not a branch move
|
|
121
|
+
# off the default → allow it and stop.
|
|
122
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch)([[:space:]]|\$)" || exit 0
|
|
123
|
+
|
|
124
|
+
# `git checkout <ref> -- <paths>` and `git checkout -- <paths>` never move HEAD →
|
|
125
|
+
# allow anything containing a ` -- ` pathspec separator.
|
|
126
|
+
printf '%s' "$COMMAND" | grep -qE '[[:space:]]--([[:space:]]|$)' && exit 0
|
|
127
|
+
|
|
128
|
+
# Extract the first non-flag argument after checkout/switch. Strip everything up to and
|
|
129
|
+
# including the subcommand keyword — whatever git global options preceded it (`-c`,
|
|
130
|
+
# `--no-pager`, `-C <path>`, …) are consumed by the greedy prefix. The trailing separator
|
|
131
|
+
# is optional so a BARE `git checkout` (no target) yields empty ARGS → allowed below.
|
|
132
|
+
ARGS=$(printf '%s' "$COMMAND" | sed -E 's/.*[[:space:]](checkout|switch)([[:space:]]+|$)/ /' | head -1)
|
|
133
|
+
FIRST_REF=""
|
|
134
|
+
for word in $ARGS; do
|
|
135
|
+
case "$word" in
|
|
136
|
+
-*) continue ;; # flags (creation flags already denied above)
|
|
137
|
+
\;|\&\&|\|\||\||\&) break ;; # end of this git command
|
|
138
|
+
*) FIRST_REF="$word"; break ;;
|
|
139
|
+
esac
|
|
140
|
+
done
|
|
141
|
+
|
|
142
|
+
# Bare `git checkout`/`git switch` (no target) is a no-op/error → allow.
|
|
143
|
+
[ -z "$FIRST_REF" ] && exit 0
|
|
144
|
+
|
|
145
|
+
# Returning home (default branch) or file-restore forms → allow. Any other ref → deny.
|
|
146
|
+
case "$FIRST_REF" in
|
|
147
|
+
HEAD|.) exit 0 ;; # file restore forms
|
|
148
|
+
*) is_default_branch "$FIRST_REF" && exit 0 || emit_deny "$DENY_MSG" ;;
|
|
149
|
+
esac
|
package/package.json
CHANGED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-context-migrate — one-time tool: split a monolithic `.forge/context.md`
|
|
3
|
+
# into `.forge/context/{id}.md` (one file per milestone) + `.forge/context-log.md`
|
|
4
|
+
# (Amendment Log + Needs Resolution + any content with no resolvable milestone
|
|
5
|
+
# id) + `.forge/context/_shared.md` (Deferred/Discretion entries that are
|
|
6
|
+
# genuinely cross-cutting, i.e. carry no milestone-id prefix). Part of the
|
|
7
|
+
# context.md size-gate fix (issue #37, ADR-033) — context.md becomes a
|
|
8
|
+
# derived index (forge-state-rollup.sh renders it); this tool produces the
|
|
9
|
+
# per-milestone source files that index reads.
|
|
10
|
+
#
|
|
11
|
+
# forge-context-migrate.sh [--source <path>] [--dest <path>] [--dry-run]
|
|
12
|
+
#
|
|
13
|
+
# --source <path> the monolithic context.md to read. Default:
|
|
14
|
+
# <repo-root>/.forge/context.md (resolved via git from
|
|
15
|
+
# the CWD).
|
|
16
|
+
# --dest <path> the directory context/ + context-log.md are written
|
|
17
|
+
# under (i.e. the ".forge"-equivalent dir — outputs
|
|
18
|
+
# land at <dest>/context/{id}.md, <dest>/context-log.md,
|
|
19
|
+
# <dest>/context/_shared.md). Default: <repo-root>/.forge.
|
|
20
|
+
# --dry-run parse and report what WOULD be written; touches no
|
|
21
|
+
# files on disk.
|
|
22
|
+
#
|
|
23
|
+
# NEVER touches --source. This tool is a pure "read old, write new" step —
|
|
24
|
+
# deleting/replacing the monolithic file is a separate, explicit step so the
|
|
25
|
+
# tool stays testable without mutating the thing it reads.
|
|
26
|
+
#
|
|
27
|
+
# Classification rules (see phase 95 plan-01 task 1):
|
|
28
|
+
# - `## Locked Decisions` sub-headings (`### ...`) whose first word resolves
|
|
29
|
+
# to a milestone id (`M{N}`, `m-{N}`, `m{N}` — normalized to `m-{N}`) each
|
|
30
|
+
# become `context/{id}.md`. A heading with no resolvable id (e.g. a
|
|
31
|
+
# drafting block, or a pointer-stub heading like "Historical Decisions")
|
|
32
|
+
# is NOT sharded — it lands in context-log.md verbatim.
|
|
33
|
+
# - `## Deferred Ideas` / `## Discretion Areas` top-level bullets whose text
|
|
34
|
+
# starts with a resolvable milestone-id prefix append to that milestone's
|
|
35
|
+
# context/{id}.md (created if absent) under a `## Deferred` / `##
|
|
36
|
+
# Discretion` heading. An entry with no prefix is genuinely cross-cutting
|
|
37
|
+
# and goes to context/_shared.md instead — never guessed onto a milestone.
|
|
38
|
+
# - `## Needs Resolution` and `## Amendment Log` are never milestone-scoped
|
|
39
|
+
# — copied verbatim into context-log.md.
|
|
40
|
+
# - Anything else (top-of-file preamble, unrecognized `## ` sections) is
|
|
41
|
+
# dropped or logged to context-log.md's "Other Sections" — never silently
|
|
42
|
+
# lost, but never invented a milestone home either.
|
|
43
|
+
#
|
|
44
|
+
# Idempotency: re-running against an already-migrated tree is a no-op per id
|
|
45
|
+
# — if `<dest>/context/{id}.md` already exists on disk, that id is SKIPPED
|
|
46
|
+
# entirely (Locked + Deferred + Discretion content for that id alike), one
|
|
47
|
+
# skip line printed per id, and the existing file is never touched. Likewise
|
|
48
|
+
# context-log.md and context/_shared.md are left alone if they already exist.
|
|
49
|
+
#
|
|
50
|
+
# Portable: POSIX sh + awk. No network calls.
|
|
51
|
+
|
|
52
|
+
set -u
|
|
53
|
+
|
|
54
|
+
SCRIPT_NAME="forge-context-migrate"
|
|
55
|
+
|
|
56
|
+
source=""
|
|
57
|
+
dest=""
|
|
58
|
+
dry_run=0
|
|
59
|
+
|
|
60
|
+
while [ $# -gt 0 ]; do
|
|
61
|
+
case "$1" in
|
|
62
|
+
--source) shift; source="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
63
|
+
--source=*) source="${1#*=}"; shift ;;
|
|
64
|
+
--dest) shift; dest="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
65
|
+
--dest=*) dest="${1#*=}"; shift ;;
|
|
66
|
+
--dry-run) dry_run=1; shift ;;
|
|
67
|
+
-h|--help)
|
|
68
|
+
printf 'Usage: %s [--source <path>] [--dest <path>] [--dry-run]\n' "$SCRIPT_NAME"
|
|
69
|
+
exit 0
|
|
70
|
+
;;
|
|
71
|
+
*) printf '%s: unknown argument: %s\n' "$SCRIPT_NAME" "$1" >&2; exit 2 ;;
|
|
72
|
+
esac
|
|
73
|
+
done
|
|
74
|
+
|
|
75
|
+
if [ -z "$source" ] || [ -z "$dest" ]; then
|
|
76
|
+
root="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
77
|
+
if [ -z "$root" ]; then
|
|
78
|
+
printf '%s: not in a git repo — pass --source and --dest explicitly\n' "$SCRIPT_NAME" >&2
|
|
79
|
+
exit 1
|
|
80
|
+
fi
|
|
81
|
+
[ -z "$source" ] && source="$root/.forge/context.md"
|
|
82
|
+
[ -z "$dest" ] && dest="$root/.forge"
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
if [ ! -f "$source" ]; then
|
|
86
|
+
printf '%s: source file not found: %s\n' "$SCRIPT_NAME" "$source" >&2
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# --- idempotency inputs: which ids/artifacts already exist at dest ----------
|
|
91
|
+
|
|
92
|
+
existing_ids=" "
|
|
93
|
+
if [ -d "$dest/context" ]; then
|
|
94
|
+
for f in "$dest"/context/*.md; do
|
|
95
|
+
[ -f "$f" ] || continue
|
|
96
|
+
base="$(basename "$f" .md)"
|
|
97
|
+
[ "$base" = "_shared" ] && continue
|
|
98
|
+
existing_ids="$existing_ids$base "
|
|
99
|
+
done
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
skip_log=0
|
|
103
|
+
[ -f "$dest/context-log.md" ] && skip_log=1
|
|
104
|
+
|
|
105
|
+
skip_shared=0
|
|
106
|
+
[ -f "$dest/context/_shared.md" ] && skip_shared=1
|
|
107
|
+
|
|
108
|
+
# --- write target dirs (real run only — dry-run touches nothing) -----------
|
|
109
|
+
|
|
110
|
+
if [ "$dry_run" -eq 0 ]; then
|
|
111
|
+
mkdir -p "$dest/context"
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# --- awk parser/writer -------------------------------------------------------
|
|
115
|
+
# Buffers everything in memory (context.md is well under a few hundred KB)
|
|
116
|
+
# then writes/report at END. Program is written to a scratch file so the
|
|
117
|
+
# shell wrapper stays a plain single-quoted awk invocation.
|
|
118
|
+
|
|
119
|
+
awk_prog="$(mktemp 2>/dev/null || mktemp -t forge-context-migrate)"
|
|
120
|
+
trap 'rm -f "$awk_prog"' EXIT INT TERM
|
|
121
|
+
|
|
122
|
+
cat > "$awk_prog" <<'AWKEOF'
|
|
123
|
+
function extract_id(line, w, arr, n, tok, digits) {
|
|
124
|
+
w = line
|
|
125
|
+
sub(/^### /, "", w)
|
|
126
|
+
sub(/^- /, "", w)
|
|
127
|
+
sub(/^~~/, "", w)
|
|
128
|
+
n = split(w, arr, /[ \t]/)
|
|
129
|
+
if (n < 1) return ""
|
|
130
|
+
tok = arr[1]
|
|
131
|
+
gsub(/[:,.]+$/, "", tok)
|
|
132
|
+
if (tok ~ /^[Mm]-?[0-9]+$/) {
|
|
133
|
+
digits = tok
|
|
134
|
+
gsub(/[^0-9]/, "", digits)
|
|
135
|
+
return "m-" digits
|
|
136
|
+
}
|
|
137
|
+
return ""
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function add_id(id) {
|
|
141
|
+
if (!(id in seen_id)) {
|
|
142
|
+
seen_id[id] = 1
|
|
143
|
+
n_ids++
|
|
144
|
+
id_order[n_ids] = id
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
BEGIN {
|
|
149
|
+
section = ""
|
|
150
|
+
cur_state = ""
|
|
151
|
+
cur_id = ""
|
|
152
|
+
cur_noid_key = ""
|
|
153
|
+
n_ids = 0
|
|
154
|
+
n_noid = 0
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/^## / {
|
|
158
|
+
cur_state = ""
|
|
159
|
+
cur_id = ""
|
|
160
|
+
if ($0 ~ /^## Locked Decisions/) { section = "LOCKED"; cur_state = "LOCKED_PRE"; next }
|
|
161
|
+
if ($0 ~ /^## Deferred Ideas/) { section = "DEFERRED"; next }
|
|
162
|
+
if ($0 ~ /^## Discretion Areas/) { section = "DISCRETION"; next }
|
|
163
|
+
if ($0 ~ /^## Needs Resolution/) { section = "NEEDS_RESOLUTION"; next }
|
|
164
|
+
if ($0 ~ /^## Amendment Log/) { section = "AMENDMENT"; next }
|
|
165
|
+
section = "OTHER"
|
|
166
|
+
other_buf = other_buf $0 "\n"
|
|
167
|
+
next
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
section == "" { next }
|
|
171
|
+
|
|
172
|
+
section == "LOCKED" {
|
|
173
|
+
if ($0 ~ /^### /) {
|
|
174
|
+
id = extract_id($0)
|
|
175
|
+
if (id == "") {
|
|
176
|
+
n_noid++
|
|
177
|
+
cur_noid_key = "NOID" n_noid
|
|
178
|
+
noid_order[n_noid] = cur_noid_key
|
|
179
|
+
noid_body[cur_noid_key] = $0 "\n"
|
|
180
|
+
cur_state = "LOCKED_NOID"
|
|
181
|
+
cur_id = ""
|
|
182
|
+
} else {
|
|
183
|
+
add_id(id)
|
|
184
|
+
locked_body[id] = $0 "\n"
|
|
185
|
+
cur_state = "LOCKED_BLOCK"
|
|
186
|
+
cur_id = id
|
|
187
|
+
}
|
|
188
|
+
next
|
|
189
|
+
}
|
|
190
|
+
if (cur_state == "LOCKED_PRE") { pre_locked_buf = pre_locked_buf $0 "\n"; next }
|
|
191
|
+
if (cur_state == "LOCKED_BLOCK") { locked_body[cur_id] = locked_body[cur_id] $0 "\n"; next }
|
|
192
|
+
if (cur_state == "LOCKED_NOID") { noid_body[cur_noid_key] = noid_body[cur_noid_key] $0 "\n"; next }
|
|
193
|
+
next
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
section == "DEFERRED" || section == "DISCRETION" {
|
|
197
|
+
if ($0 ~ /^- /) {
|
|
198
|
+
id = extract_id($0)
|
|
199
|
+
if (id == "") {
|
|
200
|
+
if (section == "DEFERRED") { cur_state = "SHARED_DEF"; shared_deferred = shared_deferred $0 "\n" }
|
|
201
|
+
else { cur_state = "SHARED_DISC"; shared_discretion = shared_discretion $0 "\n" }
|
|
202
|
+
cur_id = ""
|
|
203
|
+
} else {
|
|
204
|
+
add_id(id)
|
|
205
|
+
if (section == "DEFERRED") { deferred_body[id] = deferred_body[id] $0 "\n"; cur_state = "DEF_ID" }
|
|
206
|
+
else { discretion_body[id] = discretion_body[id] $0 "\n"; cur_state = "DISC_ID" }
|
|
207
|
+
cur_id = id
|
|
208
|
+
}
|
|
209
|
+
next
|
|
210
|
+
}
|
|
211
|
+
if (cur_state == "DEF_ID") { deferred_body[cur_id] = deferred_body[cur_id] $0 "\n"; next }
|
|
212
|
+
if (cur_state == "DISC_ID") { discretion_body[cur_id] = discretion_body[cur_id] $0 "\n"; next }
|
|
213
|
+
if (cur_state == "SHARED_DEF") { shared_deferred = shared_deferred $0 "\n"; next }
|
|
214
|
+
if (cur_state == "SHARED_DISC") { shared_discretion = shared_discretion $0 "\n"; next }
|
|
215
|
+
next
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
section == "NEEDS_RESOLUTION" { needs_resolution_buf = needs_resolution_buf $0 "\n"; next }
|
|
219
|
+
section == "AMENDMENT" { amendment_buf = amendment_buf $0 "\n"; next }
|
|
220
|
+
section == "OTHER" { other_buf = other_buf $0 "\n"; next }
|
|
221
|
+
|
|
222
|
+
END {
|
|
223
|
+
for (i = 1; i <= n_ids; i++) {
|
|
224
|
+
id = id_order[i]
|
|
225
|
+
if (index(existing, " " id " ") > 0) {
|
|
226
|
+
print "SKIP: " id " — context/" id ".md already exists, not overwritten" > "/dev/stderr"
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
content = ""
|
|
230
|
+
if (id in locked_body) content = content locked_body[id]
|
|
231
|
+
if (id in deferred_body) content = content "\n## Deferred\n\n" deferred_body[id]
|
|
232
|
+
if (id in discretion_body) content = content "\n## Discretion\n\n" discretion_body[id]
|
|
233
|
+
fname = dest "/context/" id ".md"
|
|
234
|
+
if (dryrun == "1") {
|
|
235
|
+
print "DRY-RUN: would create " fname
|
|
236
|
+
} else {
|
|
237
|
+
printf "%s", content > fname
|
|
238
|
+
close(fname)
|
|
239
|
+
print "CREATED: " fname
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (skip_log == "1") {
|
|
244
|
+
print "SKIP: context-log.md already exists, not overwritten" > "/dev/stderr"
|
|
245
|
+
} else {
|
|
246
|
+
log_out = "# Context Log\n\n"
|
|
247
|
+
log_out = log_out "Migrated from the monolithic `.forge/context.md` (ADR-033). Per-milestone\n"
|
|
248
|
+
log_out = log_out "decisions now live in `.forge/context/{id}.md`; the rendered `.forge/context.md`\n"
|
|
249
|
+
log_out = log_out "is a derived index (forge-state-rollup.sh) — this file carries the content\n"
|
|
250
|
+
log_out = log_out "that never was milestone-scoped.\n\n---\n\n"
|
|
251
|
+
if (pre_locked_buf != "" || n_noid > 0) {
|
|
252
|
+
log_out = log_out "## Locked Decisions (No Milestone ID)\n\n"
|
|
253
|
+
if (pre_locked_buf != "") log_out = log_out pre_locked_buf "\n"
|
|
254
|
+
for (i = 1; i <= n_noid; i++) log_out = log_out noid_body[noid_order[i]] "\n"
|
|
255
|
+
}
|
|
256
|
+
if (other_buf != "") log_out = log_out "## Other Sections\n\n" other_buf "\n"
|
|
257
|
+
log_out = log_out "## Needs Resolution\n\n"
|
|
258
|
+
log_out = log_out (needs_resolution_buf != "" ? needs_resolution_buf : "(none)\n")
|
|
259
|
+
log_out = log_out "\n---\n\n## Amendment Log\n\n" amendment_buf
|
|
260
|
+
fname = dest "/context-log.md"
|
|
261
|
+
if (dryrun == "1") {
|
|
262
|
+
print "DRY-RUN: would create " fname
|
|
263
|
+
} else {
|
|
264
|
+
printf "%s", log_out > fname
|
|
265
|
+
close(fname)
|
|
266
|
+
print "CREATED: " fname
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (shared_deferred == "" && shared_discretion == "") {
|
|
271
|
+
# nothing genuinely cross-cutting — do not create an empty file
|
|
272
|
+
} else if (skip_shared == "1") {
|
|
273
|
+
print "SKIP: context/_shared.md already exists, not overwritten" > "/dev/stderr"
|
|
274
|
+
} else {
|
|
275
|
+
shared_out = "# Shared / Cross-Cutting Context\n\n"
|
|
276
|
+
shared_out = shared_out "Deferred Ideas and Discretion Areas entries from `.forge/context.md` with no\n"
|
|
277
|
+
shared_out = shared_out "resolvable milestone-id prefix — genuinely cross-cutting, not owned by one\n"
|
|
278
|
+
shared_out = shared_out "milestone.\n\n---\n\n"
|
|
279
|
+
if (shared_deferred != "") shared_out = shared_out "## Deferred Ideas\n\n" shared_deferred "\n"
|
|
280
|
+
if (shared_discretion != "") shared_out = shared_out "## Discretion Areas\n\n" shared_discretion "\n"
|
|
281
|
+
fname = dest "/context/_shared.md"
|
|
282
|
+
if (dryrun == "1") {
|
|
283
|
+
print "DRY-RUN: would create " fname
|
|
284
|
+
} else {
|
|
285
|
+
printf "%s", shared_out > fname
|
|
286
|
+
close(fname)
|
|
287
|
+
print "CREATED: " fname
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
print "forge-context-migrate: " n_ids " milestone id(s) processed"
|
|
292
|
+
}
|
|
293
|
+
AWKEOF
|
|
294
|
+
|
|
295
|
+
awk -v dest="$dest" -v dryrun="$dry_run" -v existing="$existing_ids" \
|
|
296
|
+
-v skip_log="$skip_log" -v skip_shared="$skip_shared" \
|
|
297
|
+
-f "$awk_prog" "$source"
|