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.
Files changed (41) hide show
  1. package/bin/create-forge.js +27 -0
  2. package/experimental/m10/README.md +4 -4
  3. package/experimental/m10/install.sh +10 -9
  4. package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
  5. package/package.json +1 -1
  6. package/template/.claude/hooks/forge-context-migrate.sh +297 -0
  7. package/template/.claude/hooks/forge-size-gate.sh +175 -0
  8. package/template/.claude/hooks/forge-state-rollup.sh +135 -2
  9. package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
  10. package/template/.claude/hooks/skill-gate-tiers.txt +6 -0
  11. package/template/.claude/hooks/tests/forge-context-migrate.test.sh +166 -0
  12. package/template/.claude/hooks/tests/forge-size-gate.test.sh +132 -0
  13. package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
  14. package/template/.claude/settings.json +14 -1
  15. package/template/.claude/skills/discussing/SKILL.md +30 -31
  16. package/template/.claude/skills/executing/SKILL.md +3 -3
  17. package/template/.claude/skills/forge/SKILL.md +112 -170
  18. package/template/.claude/skills/forge/references/manual-fallback-procedures.md +54 -0
  19. package/template/.claude/skills/initializing/SKILL.md +20 -1
  20. package/template/.claude/skills/planning/SKILL.md +11 -9
  21. package/template/.claude/skills/researching/SKILL.md +2 -0
  22. package/template/.claude/skills/reviewing/SKILL.md +17 -0
  23. package/template/.claude/skills/upgrading/SKILL.md +22 -2
  24. package/template/.claude/skills/verifying/SKILL.md +2 -2
  25. package/template/.forge/FORGE.md +81 -90
  26. package/template/.forge/bin/new-worktree.sh +61 -0
  27. package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
  28. package/template/.forge/checks/forge-jarvis-awareness.sh +46 -2
  29. package/template/.forge/checks/forge-jarvis-relay.sh +64 -16
  30. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +58 -0
  31. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +50 -0
  32. package/template/.forge/git-hooks/pre-commit +57 -0
  33. package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
  34. package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
  35. package/template/.forge/migrations/0.80.0-context-md-sharding.md +158 -0
  36. package/template/.forge/migrations/0.80.0-size-gate-wiring.md +145 -0
  37. package/template/.forge/templates/constitution.md +1 -1
  38. package/template/.forge/templates/context-milestone.md +30 -0
  39. package/template/.forge/templates/slice-runner/config.yml +7 -1
  40. package/template/.forge/templates/state/desire-path.yml +3 -0
  41. package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
@@ -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/forge-branch-guard.sh` → `<target>/.claude/hooks/` (`chmod +x`)
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 branch-discipline guardrail under `PreToolUse`:
74
+ Also add the primary-checkout guardrail under `PreToolUse`:
75
75
  ```json
76
76
  { "matcher": "Bash",
77
- "hooks": [{ "type": "command", "command": ".claude/hooks/forge-branch-guard.sh" }] }
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 branch-guard protects the shared main HEAD when worktrees are active (ADR-008) — orchestration-agnostic, independent of the claim layer.
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 forge-branch-guard.sh; do
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 branch-discipline guardrail (PreToolUse on Bash). Orchestration-
165
- # agnostic protects the shared main HEAD whenever >1 worktree exists.
166
- echo "==> .claude/settings.json branch-guard hook"
167
- BG_CMD='.claude/hooks/forge-branch-guard.sh'
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 — branch-guard would be added alongside the others"
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: forge-branch-guard.sh hook already registered"
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/forge-branch-guard.sh"}]}]' "$SETTINGS" > "$tmp"
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 branch-guard PreToolUse(Bash) hook entry"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.77.0",
3
+ "version": "0.80.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -0,0 +1,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"