@infinitedusky/indusk-mcp 1.36.2 → 1.37.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 (62) hide show
  1. package/admin/.next/BUILD_ID +1 -1
  2. package/admin/.next/build-manifest.json +3 -3
  3. package/admin/.next/fallback-build-manifest.json +3 -3
  4. package/admin/.next/prerender-manifest.json +3 -3
  5. package/admin/.next/server/app/_global-error.html +1 -1
  6. package/admin/.next/server/app/_global-error.rsc +1 -1
  7. package/admin/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/admin/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/admin/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/admin/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/admin/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/admin/.next/server/chunks/ssr/[root-of-the-server]__0p.51_4._.js.map +1 -1
  13. package/admin/.next/server/middleware-build-manifest.js +3 -3
  14. package/admin/.next/server/pages/500.html +1 -1
  15. package/admin/.next/server/server-reference-manifest.js +1 -1
  16. package/admin/.next/server/server-reference-manifest.json +1 -1
  17. package/admin/.next/trace +1 -1
  18. package/admin/.next/trace-build +1 -1
  19. package/dist/bin/cli.js +42 -4
  20. package/dist/bin/commands/init.js +42 -10
  21. package/dist/bin/commands/telemetry.js +7 -1
  22. package/dist/bin/commands/update.js +104 -2
  23. package/dist/bin/commands/workbench.d.ts +76 -0
  24. package/dist/bin/commands/workbench.js +438 -0
  25. package/dist/bin/commands/worktree.d.ts +22 -6
  26. package/dist/bin/commands/worktree.js +154 -111
  27. package/dist/lib/config.d.ts +59 -0
  28. package/dist/lib/hook-migration.d.ts +42 -0
  29. package/dist/lib/hook-migration.js +94 -0
  30. package/dist/lib/path-segment.d.ts +23 -0
  31. package/dist/lib/path-segment.js +41 -0
  32. package/dist/lib/plan-parser.js +5 -13
  33. package/dist/lib/stray-state-audit.d.ts +3 -2
  34. package/dist/lib/stray-state-audit.js +14 -20
  35. package/dist/lib/telemetry/daemon.d.ts +18 -0
  36. package/dist/lib/telemetry/daemon.js +18 -2
  37. package/dist/lib/verify/git.d.ts +11 -4
  38. package/dist/lib/verify/git.js +11 -4
  39. package/dist/lib/verify/roots.d.ts +40 -0
  40. package/dist/lib/verify/roots.js +54 -0
  41. package/dist/lib/verify/verify.js +17 -2
  42. package/dist/lib/worktree/layout.d.ts +56 -0
  43. package/dist/lib/worktree/layout.js +136 -0
  44. package/dist/lib/worktree/repos.d.ts +46 -0
  45. package/dist/lib/worktree/repos.js +100 -0
  46. package/dist/lib/worktree/shareable.d.ts +47 -0
  47. package/dist/lib/worktree/shareable.js +301 -0
  48. package/dist/lib/worktree/sync.d.ts +62 -0
  49. package/dist/lib/worktree/sync.js +174 -0
  50. package/extensions/worktree/hooks/on_enable.sh +17 -10
  51. package/extensions/worktree/scripts/lib/workbench-helpers.sh +84 -0
  52. package/extensions/worktree/scripts/preflight.sh +4 -8
  53. package/extensions/worktree/scripts/refresh-worktree.sh +4 -8
  54. package/extensions/worktree/scripts/setup-worktree.sh +40 -16
  55. package/extensions/worktree/skill.md +8 -6
  56. package/hooks/_hook-paths.js +78 -15
  57. package/hooks/workbench-sync.js +119 -0
  58. package/package.json +109 -109
  59. package/skills/catchup.md +17 -0
  60. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_buildManifest.js +0 -0
  61. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_clientMiddlewareManifest.js +0 -0
  62. /package/admin/.next/static/{b30fOo0tUhHOxiaUT2sKQ → pg-wvx75mxUJvPhSjSoUe}/_ssgManifest.js +0 -0
@@ -15,6 +15,23 @@
15
15
  # Caller is responsible for calling _resolve_workbench_root first
16
16
  # and cd'ing or passing the path explicitly via WORKBENCH_ROOT env.
17
17
  #
18
+ # _read_workbench_repos
19
+ # Echoes the declared repo names, one per line, in declared order.
20
+ # DELIBERATE PORT of readWorkbenchRepos() in
21
+ # src/lib/worktree/repos.ts — bash cannot import the TS module, so
22
+ # the reduction lives in two places and they change together.
23
+ # `worktree.repos[]` is canonical; the legacy `worktree.wrapped_repo`
24
+ # reduces to a one-element list. Names that are not clean path
25
+ # segments are dropped and duplicates collapse to first occurrence,
26
+ # because every caller joins these into a filesystem path.
27
+ #
28
+ # _resolve_workbench_repo [requested]
29
+ # Echoes the one repo the caller means. With `requested` it must
30
+ # match a declared repo. Without it, a single declared repo is
31
+ # implied; with several, this FAILS and names the candidates on
32
+ # stderr rather than picking one — putting a worktree in the wrong
33
+ # repo looks exactly like success until someone reads the branch.
34
+ #
18
35
  # _read_worktree_config <repo> <jq-filter>
19
36
  # Reads a value from .indusk/worktree-configs/<repo>.json using the
20
37
  # provided jq filter. Echoes the value or empty string if config
@@ -78,6 +95,73 @@ _read_workbench_field() {
78
95
  jq -r ".worktree.${field} // \"\"" "$config" 2>/dev/null || echo ""
79
96
  }
80
97
 
98
+ _read_workbench_repos() {
99
+ local root="${WORKBENCH_ROOT:-}"
100
+ if [[ -z "$root" ]]; then
101
+ echo "Error: _read_workbench_repos: WORKBENCH_ROOT must be set" >&2
102
+ return 1
103
+ fi
104
+ local config="$root/.indusk/config.json"
105
+ if [[ ! -f "$config" ]]; then
106
+ echo "Error: _read_workbench_repos: $config not found" >&2
107
+ return 1
108
+ fi
109
+ # The reduction, in jq. `unique` is deliberately NOT used: it sorts, and
110
+ # declared order is meaningful (the first repo is the implied one at N=1).
111
+ jq -r '
112
+ (.worktree // {}) as $w
113
+ | (
114
+ if ($w.repos | type) == "array" then $w.repos
115
+ elif ($w.wrapped_repo | type) == "string" then [{ name: $w.wrapped_repo }]
116
+ else []
117
+ end
118
+ )
119
+ | map(select(type == "object"))
120
+ | map(.name)
121
+ | map(select(type == "string"))
122
+ | map(select(. != "" and . != "." and . != ".." and (contains("/") | not) and (contains("\\") | not)))
123
+ | reduce .[] as $n ([]; if (index($n) == null) then . + [$n] else . end)
124
+ | .[]
125
+ ' "$config" 2>/dev/null || echo ""
126
+ }
127
+
128
+ _resolve_workbench_repo() {
129
+ local requested="${1:-}"
130
+ local repos=()
131
+ while IFS= read -r line; do
132
+ [[ -n "$line" ]] && repos+=("$line")
133
+ done < <(_read_workbench_repos)
134
+
135
+ if [[ ${#repos[@]} -eq 0 ]]; then
136
+ echo "Error: this project is not a workbench (set worktree.shape=\"workbench\" and worktree.repos[] in .indusk/config.json, or run \`indusk init --workbench\`)." >&2
137
+ return 1
138
+ fi
139
+
140
+ local joined
141
+ joined="$(printf '%s, ' "${repos[@]}")"
142
+ joined="${joined%, }"
143
+
144
+ if [[ -n "$requested" ]]; then
145
+ local r
146
+ for r in "${repos[@]}"; do
147
+ if [[ "$r" == "$requested" ]]; then
148
+ echo "$r"
149
+ return 0
150
+ fi
151
+ done
152
+ echo "Error: no declared repo named \"$requested\". This workbench declares: ${joined}." >&2
153
+ return 1
154
+ fi
155
+
156
+ if [[ ${#repos[@]} -eq 1 ]]; then
157
+ echo "${repos[0]}"
158
+ return 0
159
+ fi
160
+
161
+ echo "Error: this workbench declares more than one repo, so the repo must be named: ${joined}." >&2
162
+ return 1
163
+ }
164
+
81
165
  _read_worktree_config() {
82
166
  local repo="$1"
83
167
  local filter="$2"
@@ -53,20 +53,16 @@ fi
53
53
  WORKBENCH_ROOT="$(_resolve_workbench_root)"
54
54
  export WORKBENCH_ROOT
55
55
 
56
- WRAPPED_REPO="$(_read_workbench_field wrapped_repo)"
57
- if [[ -z "$WRAPPED_REPO" ]]; then
58
- echo "Error: .indusk/config.json missing worktree.wrapped_repo" >&2
59
- exit 1
60
- fi
56
+ REPO="$(_resolve_workbench_repo "${REPO_ARG:-}")"
61
57
 
62
- CONFIG_FILE="$WORKBENCH_ROOT/.indusk/worktree-configs/${WRAPPED_REPO}.json"
58
+ CONFIG_FILE="$WORKBENCH_ROOT/.indusk/worktree-configs/${REPO}.json"
63
59
  if [[ ! -f "$CONFIG_FILE" ]]; then
64
60
  echo "Error: no worktree config at $CONFIG_FILE" >&2
65
61
  exit 1
66
62
  fi
67
63
 
68
64
  # Reject preflighting the trunk — preflight is a feature-branch concept.
69
- if [[ "$SLUG" == "$WRAPPED_REPO" ]]; then
65
+ if [[ "$SLUG" == "$REPO" ]]; then
70
66
  echo "Error: preflight cannot target the trunk; pick a worktree slug" >&2
71
67
  exit 1
72
68
  fi
@@ -86,7 +82,7 @@ for entry in "$WORKBENCH_ROOT"/*; do
86
82
  name="$(basename "$entry")"
87
83
  _is_reserved_name "$name" && continue
88
84
  # Skip the trunk symlink — preflight is for feature worktrees.
89
- [[ "$name" == "$WRAPPED_REPO" ]] && continue
85
+ [[ "$name" == "$REPO" ]] && continue
90
86
  if [[ "$name" == "$SLUG" ]]; then
91
87
  exact_paths+=("$entry")
92
88
  elif [[ "$name" == *"-$SLUG" ]]; then
@@ -33,17 +33,13 @@ ARG="${1:?Usage: refresh-worktree.sh <slug|--all>}"
33
33
  WORKBENCH_ROOT="$(_resolve_workbench_root)"
34
34
  export WORKBENCH_ROOT
35
35
 
36
- WRAPPED_REPO="$(_read_workbench_field wrapped_repo)"
37
- if [[ -z "$WRAPPED_REPO" ]]; then
38
- echo "Error: .indusk/config.json missing worktree.wrapped_repo" >&2
39
- exit 1
40
- fi
36
+ REPO="$(_resolve_workbench_repo "${REPO_ARG:-}")"
41
37
 
42
38
  SIBLING_PARENT_RAW="$(_read_workbench_field sibling_parent)"
43
39
  SIBLING_PARENT="$(_expand_path "$SIBLING_PARENT_RAW")"
44
- CLIENT_ROOT="$SIBLING_PARENT/$WRAPPED_REPO"
40
+ CLIENT_ROOT="$SIBLING_PARENT/$REPO"
45
41
 
46
- CONFIG_FILE="$WORKBENCH_ROOT/.indusk/worktree-configs/${WRAPPED_REPO}.json"
42
+ CONFIG_FILE="$WORKBENCH_ROOT/.indusk/worktree-configs/${REPO}.json"
47
43
  if [[ ! -f "$CONFIG_FILE" ]]; then
48
44
  echo "Error: no worktree config at $CONFIG_FILE" >&2
49
45
  exit 1
@@ -59,7 +55,7 @@ refresh_one() {
59
55
  echo " SKIP: $slug — directory not found"
60
56
  return 0
61
57
  fi
62
- if [[ "$slug" == "$WRAPPED_REPO" ]]; then
58
+ if [[ "$slug" == "$REPO" ]]; then
63
59
  return 0 # trunk symlink, not a worktree
64
60
  fi
65
61
  # Check this is actually a git worktree (has a .git file pointing into
@@ -3,16 +3,21 @@
3
3
  # single-repo workbench.
4
4
  #
5
5
  # Usage:
6
- # setup-worktree.sh <slug> [base-branch]
6
+ # setup-worktree.sh [--repo <name>] [--worktrees-dir <dir>] <slug> [base-branch]
7
7
  #
8
8
  # Examples:
9
9
  # setup-worktree.sh feat-autoops-cancel-polish
10
- # setup-worktree.sh feat-autoops-reschedule main
10
+ # setup-worktree.sh --repo avoca-next feat-thing main
11
+ #
12
+ # `--repo` names which declared repo to branch from. It is optional when the
13
+ # workbench declares exactly one; with several it is REQUIRED, and omitting it
14
+ # fails naming the candidates rather than defaulting to the first.
11
15
  #
12
16
  # What it does:
13
17
  # 1. Resolves the workbench root (walks up from cwd to find
14
18
  # .indusk/config.json with worktree.shape == "workbench").
15
- # 2. Reads .indusk/config.json for worktree.wrapped_repo + worktree.sibling_parent.
19
+ # 2. Resolves which declared repo to use (worktree.repos[], legacy
20
+ # wrapped_repo reduced) + worktree.sibling_parent.
16
21
  # 3. Reads .indusk/worktree-configs/<repo>.json for trunk_branch, base_branch,
17
22
  # copy_files[], append_files[], apply_commits[].
18
23
  # 4. Creates a worktree at <workbench>/<slug>, branched off <base-branch>
@@ -33,17 +38,27 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
38
  # shellcheck source=lib/workbench-helpers.sh
34
39
  source "$SCRIPT_DIR/lib/workbench-helpers.sh"
35
40
 
36
- SLUG="${1:?Usage: setup-worktree.sh <slug> [base-branch]}"
41
+ REPO_ARG=""
42
+ WORKTREES_DIR=""
43
+ # Flags in any order; both optional. `--worktrees-dir` is passed by the TS
44
+ # wrapper when the repo DECLARES a worktrees location. Absent, worktrees land
45
+ # at the workbench root — today's flat layout, unchanged.
46
+ while [[ "${1:-}" == --* ]]; do
47
+ case "$1" in
48
+ --repo) REPO_ARG="${2:?--repo requires a repo name}"; shift 2 ;;
49
+ --worktrees-dir) WORKTREES_DIR="${2:?--worktrees-dir requires a directory}"; shift 2 ;;
50
+ *) echo "Error: unknown flag $1" >&2; exit 1 ;;
51
+ esac
52
+ done
53
+ SLUG="${1:?Usage: setup-worktree.sh [--repo <name>] <slug> [base-branch]}"
37
54
  BASE_BRANCH_ARG="${2:-}"
38
55
 
39
56
  WORKBENCH_ROOT="$(_resolve_workbench_root)"
40
57
  export WORKBENCH_ROOT
41
58
 
42
- WRAPPED_REPO="$(_read_workbench_field wrapped_repo)"
43
- if [[ -z "$WRAPPED_REPO" ]]; then
44
- echo "Error: .indusk/config.json missing worktree.wrapped_repo (required in single-repo workbench mode)" >&2
45
- exit 1
46
- fi
59
+ # One resolver for "which repo" — refuses rather than guessing when the
60
+ # workbench declares several and the caller named none.
61
+ REPO="$(_resolve_workbench_repo "$REPO_ARG")"
47
62
 
48
63
  SIBLING_PARENT_RAW="$(_read_workbench_field sibling_parent)"
49
64
  if [[ -z "$SIBLING_PARENT_RAW" ]]; then
@@ -52,31 +67,40 @@ if [[ -z "$SIBLING_PARENT_RAW" ]]; then
52
67
  fi
53
68
  SIBLING_PARENT="$(_expand_path "$SIBLING_PARENT_RAW")"
54
69
 
55
- CLIENT_ROOT="$SIBLING_PARENT/$WRAPPED_REPO"
70
+ CLIENT_ROOT="$SIBLING_PARENT/$REPO"
56
71
  if [[ ! -d "$CLIENT_ROOT/.git" ]]; then
57
72
  echo "Error: $CLIENT_ROOT is not a git repo" >&2
58
73
  exit 1
59
74
  fi
60
75
 
61
- CONFIG_FILE="$WORKBENCH_ROOT/.indusk/worktree-configs/${WRAPPED_REPO}.json"
76
+ CONFIG_FILE="$WORKBENCH_ROOT/.indusk/worktree-configs/${REPO}.json"
62
77
  if [[ ! -f "$CONFIG_FILE" ]]; then
63
78
  echo "Error: no worktree config at $CONFIG_FILE" >&2
64
79
  echo "Run 'indusk extensions enable worktree' to scaffold the starter, or hand-create the file." >&2
65
80
  exit 1
66
81
  fi
67
82
 
68
- TRUNK_BRANCH="$(_read_worktree_config "$WRAPPED_REPO" '.trunk_branch // "main"')"
69
- CONFIG_BASE_BRANCH="$(_read_worktree_config "$WRAPPED_REPO" '.base_branch // .trunk_branch // "main"')"
83
+ TRUNK_BRANCH="$(_read_worktree_config "$REPO" '.trunk_branch // "main"')"
84
+ CONFIG_BASE_BRANCH="$(_read_worktree_config "$REPO" '.base_branch // .trunk_branch // "main"')"
70
85
  BASE_BRANCH="${BASE_BRANCH_ARG:-$CONFIG_BASE_BRANCH}"
71
86
 
72
87
  # Reject slug collisions with the wrapped repo's name (resolution would be
73
88
  # ambiguous; the trunk lives at that path).
74
- if [[ "$SLUG" == "$WRAPPED_REPO" ]]; then
89
+ if [[ -z "$WORKTREES_DIR" && "$SLUG" == "$REPO" ]]; then
90
+ # Only a flat layout can collide — a declared worktrees directory puts the
91
+ # worktree somewhere the trunk can never be.
75
92
  echo "Error: slug '$SLUG' collides with the wrapped repo name; pick a different slug" >&2
76
93
  exit 1
77
94
  fi
78
95
 
79
- WORKTREE_PATH="$WORKBENCH_ROOT/$SLUG"
96
+ # A declared location is a single clean segment (guarded on the TS side before
97
+ # it ever reaches here), so this join cannot escape the workbench.
98
+ if [[ -n "$WORKTREES_DIR" ]]; then
99
+ WORKTREE_PATH="$WORKBENCH_ROOT/$WORKTREES_DIR/$SLUG"
100
+ mkdir -p "$WORKBENCH_ROOT/$WORKTREES_DIR"
101
+ else
102
+ WORKTREE_PATH="$WORKBENCH_ROOT/$SLUG"
103
+ fi
80
104
  if [[ -e "$WORKTREE_PATH" ]]; then
81
105
  echo "Error: worktree already exists at $WORKTREE_PATH" >&2
82
106
  echo "To remove: git -C $CLIENT_ROOT worktree remove --force $WORKTREE_PATH" >&2
@@ -91,7 +115,7 @@ BRANCH_NAME="$SLUG"
91
115
 
92
116
  echo "Setting up worktree:"
93
117
  echo " Workbench: $WORKBENCH_ROOT"
94
- echo " Wrapped repo: $WRAPPED_REPO"
118
+ echo " Wrapped repo: $REPO"
95
119
  echo " Slug: $SLUG"
96
120
  echo " Branch: $BRANCH_NAME (off $BASE_BRANCH)"
97
121
  echo " Trunk: $TRUNK_BRANCH"
@@ -23,10 +23,10 @@ A workbench is an indusk project with this shape:
23
23
  ```
24
24
  my-workbench/ # the indusk project
25
25
  ├── .indusk/ # single source of truth — plans, eval, highlights, worktree-configs
26
- │ ├── config.json # `worktree.shape: "workbench"` + `worktree.wrapped_repo` + `worktree.sibling_parent`
26
+ │ ├── config.json # `worktree.shape: "workbench"` + `worktree.repos[]` (legacy `wrapped_repo` reduces) + `worktree.sibling_parent`
27
27
  │ └── worktree-configs/
28
28
  │ └── <repo>.json # per-wrapped-repo config (copy_files, apply_commits, preflight, etc.)
29
- ├── <repo> # symlink → canonical clone (the trunk; name matches `wrapped_repo`)
29
+ ├── <repo> # symlink → canonical clone (one trunk per declared repo)
30
30
  ├── <slug-1>/ # active worktree (git worktree add'd from the trunk)
31
31
  ├── <slug-2>/ # another active worktree
32
32
  ├── ce.json # LEGACY composable.env config (deprecated — doppler is the default env layer); optional `composeProjectName: "<repo>"`
@@ -35,7 +35,9 @@ my-workbench/ # the indusk project
35
35
 
36
36
  The wrapped repo's canonical clone lives elsewhere on disk (typically `~/code/<area>/<repo>`); the workbench's `<repo>` symlink points to it. The clone keeps its own `.git/`; the workbench keeps the `.indusk/`. Worktrees are created via `git worktree add` from inside the trunk symlink and land as siblings of the trunk.
37
37
 
38
- **Trunk vs worktrees**: identified by config (`worktree.wrapped_repo` names the trunk) and structurally (the trunk is a symlink; worktrees are real directories). Worktree slugs must not collide with the trunk's name.
38
+ **Declared layout**: a repo entry may carry `path` (where its checkout lives) and `worktrees` (where its worktrees go), both relative to the workbench and both single clean segments. Absent means flat — the workbench root — so an existing workbench is untouched and nesting is opt-in per repo. `worktree list` then groups structurally from the declared directory rather than asking git who owns what, and anything on disk it cannot place renders under **Unattributed**, naming the worktrees inside it: declarations add structure and can never subtract.
39
+
40
+ **Trunk vs worktrees**: a workbench declares its repos in `worktree.repos[]` (the legacy `worktree.wrapped_repo` reduces to a one-element list, so existing workbenches need no edit). Each declared repo gets its own trunk symlink at the workbench root; worktrees are real directories beside them. `indusk worktree list` renders one block per repo and attributes each worktree to its owning repo by asking git for `--git-common-dir` — not by guessing from the slug, because a wrong attribution reads exactly like a right one. Worktree slugs must not collide with any declared repo name. `docs/` is reserved at the workbench root (internal engagement docs), alongside `scripts/` and `env/`.
39
41
 
40
42
  ## When to enable
41
43
 
@@ -55,9 +57,9 @@ Four state-management commands, all run from the workbench root:
55
57
 
56
58
  ### `indusk worktree create <slug> [base-branch]`
57
59
 
58
- Creates a new worktree at `<workbench-root>/<slug>/`, branched off `<base-branch>` (default per `.indusk/worktree-configs/<wrapped_repo>.json`'s `base_branch`). Applies the config's `copy_files[]` and `append_files[]` declarations. Applies any `apply_commits[]` entries as **upstream-file-overlay** (full-file replacement via `git show <sha>:<file>` followed by `git update-index --skip-worktree`), NOT cherry-pick — the overlay files are invisible to `git status` / `git diff` / `git commit -a`. After setup, env is **auto-provisioned** (doppler extension, if configured) and the config's **`post_create[]`** shell commands run in order inside the new worktree — `pnpm install`, build, anything — so `create` yields a *runnable* worktree in one shot, not a bare checkout. A `post_create` command that exits non-zero stops the rest and prints what to re-run. Idempotent: invoking twice with the same `<slug>` exits non-zero with "worktree already exists at <path>".
60
+ Creates a new worktree at `<workbench-root>/<slug>/`, branched off `<base-branch>` (default per `.indusk/worktree-configs/<repo>.json`'s `base_branch`, keyed by the resolved repo). Applies the config's `copy_files[]` and `append_files[]` declarations. Applies any `apply_commits[]` entries as **upstream-file-overlay** (full-file replacement via `git show <sha>:<file>` followed by `git update-index --skip-worktree`), NOT cherry-pick — the overlay files are invisible to `git status` / `git diff` / `git commit -a`. After setup, env is **auto-provisioned** (doppler extension, if configured) and the config's **`post_create[]`** shell commands run in order inside the new worktree — `pnpm install`, build, anything — so `create` yields a *runnable* worktree in one shot, not a bare checkout. A `post_create` command that exits non-zero stops the rest and prints what to re-run. Idempotent: invoking twice with the same `<slug>` exits non-zero with "worktree already exists at <path>".
59
61
 
60
- The `<repo>` argument from the multi-repo design is dropped — single-repo workbenches know their wrapped repo from `worktree.wrapped_repo` in `.indusk/config.json`.
62
+ The `<repo>` argument is BACK (versioned-workbench): `indusk worktree create [repo] <slug> [base-branch]`. It is optional when the workbench declares exactly one repo; with several it is required, and omitting it fails naming the candidates rather than picking the first. A leading token counts as a repo only when the workbench actually declares it, so existing single-repo invocations keep their meaning.
61
63
 
62
64
  ### `indusk worktree refresh <slug>`
63
65
 
@@ -93,7 +95,7 @@ Examples (workbench is wrapping `numero`):
93
95
  - `pnpm wt numero lint` — cd to `<workbench>/numero/` (the trunk symlink), run `pnpm lint`
94
96
  - `pnpm wt cancel-polish ce dc:up local` — cd to `<workbench>/cancel-polish/`, run `pnpm ce dc:up local` (ce reads the worktree's env)
95
97
 
96
- The trunk is addressable by its repo name (`pnpm wt numero ...`). No `pnpm wt trunk` alias — keeps the surface minimal; the repo name is already in `worktree.wrapped_repo` config and stable.
98
+ Each trunk is addressable by its repo name (`pnpm wt numero ...`). No `pnpm wt trunk` alias — keeps the surface minimal; the names are already in `worktree.repos[]` and stable.
97
99
 
98
100
  ### Composing with composable.env (legacy — deprecated in favor of doppler)
99
101
 
@@ -107,18 +107,24 @@ function findGitPathFromCwd(cwd) {
107
107
  * against it fails even though the commit DID happen in a real git repo
108
108
  * inside the workbench.
109
109
  *
110
- * The fallback reads `${statePath}/.indusk/config.json` for the
111
- * `worktree.wrapped_repo` field. If present and the path exists, returns
112
- * the realpath of `${statePath}/${wrapped_repo}`. Otherwise returns null
113
- * (caller logs an actionable skip message naming `statePath`).
114
- *
115
- * Trade-off accepted: if the user committed in a SIBLING WORKTREE rather
116
- * than the wrapped repo, this resolves `gitPath` to the wrapped repo
117
- * (giving the eval agent the wrapped repo's HEAD, which may not be the
118
- * commit just made). That's a known incompleteness — Phase N+1 work
119
- * could read `tool_input.command` for a `cd <worktree>` prefix to
120
- * disambiguate. Until then, the wrapped-repo fallback is strictly better
121
- * than the pre-1.31.10 behavior of silently bailing every commit.
110
+ * The fallback reads `${statePath}/.indusk/config.json` for the declared repo
111
+ * set — `worktree.repos[]`, with the legacy `worktree.wrapped_repo` reducing
112
+ * to a one-element list. **Deliberate port of `lib/worktree/repos.ts`'s
113
+ * reduction**: a hook cannot import TypeScript, so the rule lives in two
114
+ * places and they must change together.
115
+ *
116
+ * With exactly ONE declared repo the behaviour is unchanged from 1.31.10.
117
+ *
118
+ * With MORE THAN ONE (versioned-workbench D6) the fallback is ambiguous, so it
119
+ * does not guess: it returns null, and the caller can name every declared repo
120
+ * via `declaredReposAt`. Picking the first would attribute a commit to the
121
+ * wrong repo, and a wrong attribution is indistinguishable from a right one in
122
+ * the eval record — a silent gap is recoverable, a confident wrong answer is
123
+ * not.
124
+ *
125
+ * Trade-off accepted (single-repo case, unchanged): if the user committed in a
126
+ * SIBLING WORKTREE rather than the wrapped repo, this resolves `gitPath` to the
127
+ * wrapped repo, whose HEAD may not be the commit just made.
122
128
  *
123
129
  * @param {string | null} statePath
124
130
  * @returns {string | null}
@@ -133,9 +139,11 @@ function findGitPathFromWorkbenchConfig(statePath) {
133
139
  } catch {
134
140
  return null;
135
141
  }
136
- const wrappedRepo = config?.worktree?.wrapped_repo;
137
- if (!wrappedRepo || typeof wrappedRepo !== "string") return null;
138
- const candidate = resolve(statePath, wrappedRepo);
142
+ const declared = declaredRepoNames(config);
143
+ // Ambiguous: more than one repo could hold this commit. Refuse rather than
144
+ // attribute it to whichever happens to be declared first.
145
+ if (declared.length !== 1) return null;
146
+ const candidate = resolve(statePath, declared[0]);
139
147
  if (!existsSync(candidate)) return null;
140
148
  try {
141
149
  return realpathSync(candidate);
@@ -144,6 +152,61 @@ function findGitPathFromWorkbenchConfig(statePath) {
144
152
  }
145
153
  }
146
154
 
155
+ /**
156
+ * The declared repo names, singular reduced into plural.
157
+ *
158
+ * Deliberate port of `readWorkbenchRepos` in `src/lib/worktree/repos.ts` —
159
+ * hooks are plain JS and cannot import the TS module. Change both together.
160
+ * Name-only: the hook never needs remotes.
161
+ *
162
+ * @param {unknown} config
163
+ * @returns {string[]}
164
+ */
165
+ function declaredRepoNames(config) {
166
+ const worktree = config && typeof config === "object" ? config.worktree : null;
167
+ if (!worktree || typeof worktree !== "object") return [];
168
+ const raw = Array.isArray(worktree.repos)
169
+ ? worktree.repos
170
+ : typeof worktree.wrapped_repo === "string"
171
+ ? [{ name: worktree.wrapped_repo }]
172
+ : [];
173
+ const seen = new Set();
174
+ const names = [];
175
+ for (const entry of raw) {
176
+ if (!entry || typeof entry !== "object") continue;
177
+ const name = entry.name;
178
+ if (typeof name !== "string") continue;
179
+ const clean =
180
+ name.trim() !== "" &&
181
+ name !== "." &&
182
+ name !== ".." &&
183
+ !name.includes("/") &&
184
+ !name.includes("\\");
185
+ if (!clean || seen.has(name)) continue;
186
+ seen.add(name);
187
+ names.push(name);
188
+ }
189
+ return names;
190
+ }
191
+
192
+ /**
193
+ * Every declared repo name at a workbench root, so a caller that refuses can
194
+ * SAY what it could not choose between.
195
+ *
196
+ * @param {string | null} statePath
197
+ * @returns {string[]}
198
+ */
199
+ export function declaredReposAt(statePath) {
200
+ if (!statePath) return [];
201
+ const configPath = resolve(statePath, ".indusk/config.json");
202
+ if (!existsSync(configPath)) return [];
203
+ try {
204
+ return declaredRepoNames(JSON.parse(readFileSync(configPath, "utf-8")));
205
+ } catch {
206
+ return [];
207
+ }
208
+ }
209
+
147
210
  /**
148
211
  * Resolve both the InDusk state path and the git path for a given cwd. Either
149
212
  * may be null:
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * workbench-sync.js — PostToolUse hook (Edit|Write).
4
+ *
5
+ * The trigger half of workbench sync. The mechanism lives in
6
+ * `lib/worktree/sync.ts` and is shared with `indusk workbench sync`; this only
7
+ * decides WHEN to call it. Keeping the two apart means the trigger can become
8
+ * a watcher daemon later without touching the mechanism.
9
+ *
10
+ * THREE GUARDS, and the first is the important one:
11
+ *
12
+ * 1. WORKBENCH ONLY. A normal-mode project keeps `.indusk/` inside its own
13
+ * product repo, so auto-committing there would commit a developer's
14
+ * half-finished source on every edit. dusk itself is such a project. This
15
+ * hook must be inert anywhere `worktree.shape` is not "workbench".
16
+ * 2. DEBOUNCED. Edit/Write fires constantly; a push per keystroke-burst is
17
+ * pointless network traffic. The stamp lives under the GITDIR, never the
18
+ * working tree — per-machine state that must not become a tracked file
19
+ * (the worktree extension keeps its overlay state the same way).
20
+ * 3. NEVER BLOCKS. Always exits 0. A sync failure must not fail a developer's
21
+ * edit, and an unreachable remote is a normal condition, not an error.
22
+ */
23
+
24
+ import { spawnSync } from "node:child_process";
25
+ import { existsSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { resolveStateAndGitPaths } from "./_hook-paths.js";
28
+
29
+ /** Seconds between syncs. Edits burst; the remote does not need every burst. */
30
+ const DEBOUNCE_SECONDS = 20;
31
+
32
+ function isWorkbench(root) {
33
+ try {
34
+ const cfg = JSON.parse(readFileSync(join(root, ".indusk", "config.json"), "utf-8"));
35
+ return cfg?.worktree?.shape === "workbench";
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Stamp path — under the gitdir when there is one, which keeps it untracked by
43
+ * construction rather than by a rule.
44
+ *
45
+ * CHICKEN-AND-EGG: a workbench that has never been git-initialized has no
46
+ * gitdir, and the only thing that git-inits a workbench is `sync` itself. A
47
+ * null here used to mean "not due", so exactly the workbenches that most need
48
+ * their first sync were the ones that never got one. The fallback is inside
49
+ * `.indusk/`, which every workbench has and which the managed ignore covers.
50
+ */
51
+ function stampPath(root) {
52
+ const r = spawnSync("git", ["rev-parse", "--absolute-git-dir"], { cwd: root, encoding: "utf-8" });
53
+ if (r.status === 0) return join(r.stdout.trim(), "indusk-sync-stamp");
54
+ return join(root, ".indusk", "sync-stamp");
55
+ }
56
+
57
+ function dueForSync(stamp) {
58
+ if (!stamp) return true;
59
+ if (!existsSync(stamp)) return true;
60
+ try {
61
+ return (Date.now() - statSync(stamp).mtimeMs) / 1000 >= DEBOUNCE_SECONDS;
62
+ } catch {
63
+ return true;
64
+ }
65
+ }
66
+
67
+ function touch(stamp) {
68
+ try {
69
+ if (existsSync(stamp)) {
70
+ const now = new Date();
71
+ utimesSync(stamp, now, now);
72
+ } else {
73
+ writeFileSync(stamp, "workbench sync debounce stamp\n");
74
+ }
75
+ } catch {
76
+ // A stamp we cannot write means we sync more often than intended —
77
+ // noisy, never wrong. Silence is the correct response.
78
+ }
79
+ }
80
+
81
+ async function main() {
82
+ let event = {};
83
+ try {
84
+ event = JSON.parse(readFileSync(0, "utf-8") || "{}");
85
+ } catch {
86
+ process.exit(0);
87
+ }
88
+
89
+ const { statePath } = resolveStateAndGitPaths(event.cwd ?? process.cwd());
90
+ if (!statePath) process.exit(0);
91
+
92
+ // Guard 1 — the load-bearing one.
93
+ if (!isWorkbench(statePath)) process.exit(0);
94
+
95
+ // Guard 2.
96
+ const stamp = stampPath(statePath);
97
+ if (!dueForSync(stamp)) process.exit(0);
98
+ touch(stamp);
99
+
100
+ // Guard 3: spawn detached and never wait. A sync must not add latency to a
101
+ // developer's edit, and its outcome must not decide whether the edit stood.
102
+ const bin = process.env.INDUSK_BIN ?? "indusk";
103
+ try {
104
+ const child = spawnSync(bin, ["workbench", "sync"], {
105
+ cwd: statePath,
106
+ encoding: "utf-8",
107
+ timeout: 25_000,
108
+ });
109
+ if (child.status !== 0 && child.stderr) {
110
+ // Reported, not raised. The hook still exits 0.
111
+ process.stderr.write(`[workbench-sync] ${child.stderr.trim().split("\n")[0]}\n`);
112
+ }
113
+ } catch {
114
+ // ignore
115
+ }
116
+ process.exit(0);
117
+ }
118
+
119
+ main();