@erclx/aitk 3.48.0 → 3.48.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.48.0",
4
+ "version": "3.48.1",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -14,7 +14,7 @@ Without this skill, linked worktrees accumulate past the point where any of them
14
14
  - Resolve merge state per branch from the pull request first, and fall back to local ancestry when no pull request exists
15
15
  - Match the ancestry fallback against the branch name alone. `git branch --merged` decorates the current branch and every branch checked out in a linked worktree, which is the whole set this skill enumerates.
16
16
  - Remove the worktree and its local branch together, since either one left alone is the state the skill exists to prevent
17
- - Exclude four kinds of row from the remove set: the main root, the current session's worktree, any dirty tree, and any worktree registered from outside the path prefix `claude-worktree` creates under
17
+ - Exclude five kinds of row from the remove set: the main root, the current session's worktree, any dirty tree, any worktree registered from outside the path prefix `claude-worktree` creates under, and any worktree a live session is occupying
18
18
  - Give every skipped row a one-word reason, so the skip is a decision the user can overturn rather than a silence
19
19
  - Pick exactly one mode. Listing and removing are different requests and inferring both from one invocation removes worktrees the user meant to read about.
20
20
 
@@ -22,6 +22,7 @@ Without this skill, linked worktrees accumulate past the point where any of them
22
22
 
23
23
  - Enter or create a worktree. The description states the boundary so the model routes entry elsewhere rather than discovering it here.
24
24
  - Remove a worktree with uncommitted work, whatever its merge state
25
+ - Remove a worktree a live session is occupying, whatever its merge state
25
26
  - Remove the worktree the session is currently running in
26
27
  - Emit anything after the result line
27
28
 
@@ -55,6 +55,10 @@ Determine current: the row whose `path` equals `git rev-parse --show-toplevel`.
55
55
 
56
56
  Determine provenance: a non-main row is `foreign` when its `path` does not start with `<MAIN_ROOT>/.claude/worktrees/`. That prefix is the folder `claude-worktree` creates under, a convention of that skill rather than a fact this one owns. A tree an operator registered by hand anywhere else on disk reads as `foreign`. Step 1 has already marked the main row `main`, so it never reaches this test.
57
57
 
58
+ Determine occupancy: run `aitk sessions list --json` once for the whole enumeration, never once per row. Resolve each enumerated row's `path` and each live session's `worktree` field with `realpath` before comparing, since a session registered from a second clone of this repository reports a path under that clone rather than under `MAIN_ROOT`, and a raw string compare would hold nothing back. A row is `occupied` when a resolved `worktree` from any live session equals its resolved `path`. A resolved session `worktree` outside `MAIN_ROOT` names a different checkout, so it clears no row here and marks none as occupied.
59
+
60
+ The roster is unreadable when the command exits non-zero, when its JSON carries a `reason` field instead of a populated `sessions` array, or when `sessions` is non-empty but no entry carries a `worktree` key at all, which is the shape an older binary prints and cannot be told apart from a roster answering that nothing is occupied. `list` leaves the Notes column silent on that failure rather than changing behavior beyond it. `cleanup` refuses instead, stated in its own section below.
61
+
58
62
  ## `list` mode
59
63
 
60
64
  Print the enumeration as a table, then stop. `list` has no final command.
@@ -64,7 +68,7 @@ Print the enumeration as a table, then stop. `list` has no final command.
64
68
  | -- | ---------------------------------- | ----------------- | -------- | ------ | -------- |
65
69
  ```
66
70
 
67
- Notes column shows the first that applies of `current`, `foreign`, `dirty`, or empty. Show paths relative to `MAIN_ROOT` (`.claude/worktrees/<name>`). A `foreign` row sits outside `MAIN_ROOT`, so print its path in full.
71
+ Notes column shows the first that applies of `current`, `occupied`, `foreign`, `dirty`, or empty. Show paths relative to `MAIN_ROOT` (`.claude/worktrees/<name>`). A `foreign` row sits outside `MAIN_ROOT`, so print its path in full.
68
72
 
69
73
  After the table, append a one-line hint:
70
74
 
@@ -73,15 +77,18 @@ After the table, append a one-line hint:
73
77
 
74
78
  ## `cleanup` mode
75
79
 
80
+ If Enumeration marked the roster unreadable, stop: `❌ Session roster unreadable, so occupancy cannot be checked. Resolve the aitk sessions list failure, then re-run cleanup.` Hold every non-main row rather than computing the remove set from the other five tests alone, since removing a tree a live session holds is the failure this skill exists to prevent and a sweep that removes nothing costs one re-run.
81
+
76
82
  From the enumeration, include a worktree in the remove set when all hold:
77
83
 
78
84
  - Not the main row.
79
85
  - Not the current session's worktree.
86
+ - Not occupied by a live session.
80
87
  - Not a `foreign` tree.
81
88
  - State is `merged` or `merged (local)`.
82
89
  - Working tree is clean.
83
90
 
84
- Every other non-main row goes to the skip set with a one-word reason, the first that applies in this order: `current`, `foreign`, `dirty`, `open`, `closed`, `unmerged`. A `foreign` tree that is also dirty reports `foreign`, since `dirty` is a state the operator can clear and `foreign` is not. Leading with the transient reason invites a commit or stash that changes nothing, after which the row reports `foreign` and holds back anyway.
91
+ Every other non-main row goes to the skip set with a one-word reason, the first that applies in this order: `current`, `occupied`, `foreign`, `dirty`, `open`, `closed`, `unmerged`. A `foreign` tree that is also dirty reports `foreign`, since `dirty` is a state the operator can clear and `foreign` is not. Leading with the transient reason invites a commit or stash that changes nothing, after which the row reports `foreign` and holds back anyway.
85
92
 
86
93
  ### Preview
87
94
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.48.0",
4
+ "version": "3.48.1",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -10,15 +10,44 @@
10
10
  # reports no writes at all. `scripts/eval/run.sh` keeps its fixture outside the
11
11
  # repository for the same reason.
12
12
  #
13
- # Twin of `SANDBOX_DIR` in `src/commands/sandbox.ts`. The exec boundary rules out
13
+ # Mints a short random per-run identifier the first time it is asked for, then
14
+ # holds it in AITK_SANDBOX_RUN_ID for the rest of this process. A direct call
15
+ # (not `$(...)`) exports it into the caller's own shell, which is what lets
16
+ # `run.sh` mint once and have manage-sandbox.sh and the check it shells out to
17
+ # both inherit the same id as ordinary children. A call already carrying the
18
+ # variable, inherited from such a parent, reuses it rather than minting a new one.
19
+ #
20
+ # Twin of `mintSandboxRunId` in `src/commands/sandbox.ts`.
21
+ mint_sandbox_run_id() {
22
+ if [ -z "${AITK_SANDBOX_RUN_ID:-}" ]; then
23
+ AITK_SANDBOX_RUN_ID="$(date +%s)-$$-$RANDOM"
24
+ fi
25
+ export AITK_SANDBOX_RUN_ID
26
+ }
27
+
28
+ # The base every per-run tree nests under, with no run id appended. Split out
29
+ # of `resolve_sandbox_dir` so a caller that needs to recognize any run's tree,
30
+ # such as `require_project_root` in `scripts/lib/ui.sh`, tests against this
31
+ # prefix instead of a single resolved path that changes on every call.
32
+ sandbox_dir_prefix() {
33
+ printf '%s/aitk/sandbox\n' "${XDG_STATE_HOME:-$HOME/.local/state}"
34
+ }
35
+
36
+ # Twin of `sandboxTree` in `src/commands/sandbox.ts`. The exec boundary rules out
14
37
  # a shared constant, so a change to the default lands on both sides.
38
+ #
39
+ # A bare fall-through with no per-run component is one path per machine, so
40
+ # two sessions resolving the default at once would provision over each other
41
+ # with neither told. `mint_sandbox_run_id` is what makes two such sessions
42
+ # land on two different trees rather than one.
15
43
  resolve_sandbox_dir() {
16
44
  if [ -n "${AITK_SANDBOX_DIR:-}" ]; then
17
45
  printf '%s\n' "$AITK_SANDBOX_DIR"
18
46
  return 0
19
47
  fi
20
48
 
21
- printf '%s/aitk/sandbox\n' "${XDG_STATE_HOME:-$HOME/.local/state}"
49
+ mint_sandbox_run_id
50
+ printf '%s-%s\n' "$(sandbox_dir_prefix)" "$AITK_SANDBOX_RUN_ID"
22
51
  }
23
52
 
24
53
  # Collapses repeated separators, folds `.` and `..` segments, and strips every
package/scripts/lib/ui.sh CHANGED
@@ -115,9 +115,15 @@ guard_root() {
115
115
  require_project_root() {
116
116
  local GREEN RED YELLOW WHITE GREY NC
117
117
  set_palette 2
118
- local sandbox
119
- sandbox="$(resolve_sandbox_dir)"
120
- if [[ "$PWD" == "$sandbox" || "$PWD" == "$sandbox"/* ]]; then
118
+ local sandbox in_sandbox=0
119
+ if [ -n "${AITK_SANDBOX_DIR:-}" ]; then
120
+ sandbox="$AITK_SANDBOX_DIR"
121
+ [[ "$PWD" == "$sandbox" || "$PWD" == "$sandbox"/* ]] && in_sandbox=1
122
+ else
123
+ sandbox="$(sandbox_dir_prefix)"
124
+ [[ "$PWD" == "$sandbox" || "$PWD" == "$sandbox"/* || "$PWD" == "$sandbox"-* ]] && in_sandbox=1
125
+ fi
126
+ if [ "$in_sandbox" -eq 1 ]; then
121
127
  echo -e "${GREY}┌${NC}" >&2
122
128
  log_error "Execution restricted: Command cannot be run from inside the sandbox environment."
123
129
  fi
@@ -1,5 +1,4 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
- import { homedir } from 'node:os'
3
2
  import { join } from 'node:path'
4
3
  import type { Command } from 'commander'
5
4
  import { execScript } from '@/exec'
@@ -22,6 +21,7 @@ import {
22
21
  type RunEnvelope,
23
22
  type Verdict,
24
23
  } from '@/sandbox/expect'
24
+ import { sandboxTree } from '@/sandbox/tree'
25
25
  import {
26
26
  frameError,
27
27
  intro,
@@ -64,29 +64,6 @@ function reportAbsentScenarioTree(): boolean {
64
64
  return true
65
65
  }
66
66
 
67
- /**
68
- * The provisioned tree, as opposed to `SANDBOX_DIR` above, which holds the
69
- * scenario scripts. It sits outside the toolkit worktree so the toolkit's own
70
- * `CLAUDE.md` stays off the ancestor chain of the session `run.sh` spawns with
71
- * cwd here.
72
- *
73
- * Twin of `resolve_sandbox_dir` in `scripts/lib/sandbox-path.sh`. The exec
74
- * boundary rules out a shared constant, so a change to the default lands on both
75
- * sides.
76
- */
77
- function sandboxTree(): string {
78
- const override = process.env.AITK_SANDBOX_DIR
79
- if (override !== undefined && override !== '') return override
80
-
81
- const state = process.env.XDG_STATE_HOME
82
- const base =
83
- state !== undefined && state !== ''
84
- ? state
85
- : join(homedir(), '.local', 'state')
86
-
87
- return join(base, 'aitk', 'sandbox')
88
- }
89
-
90
67
  /**
91
68
  * Holds fixture content for scenarios rather than scenarios of its own.
92
69
  * Twin of the `-not -name fixtures` filter in `scripts/manage-sandbox.sh`.
@@ -0,0 +1,47 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Mints a short per-run identifier the first time it is asked for and holds it
7
+ * in `AITK_SANDBOX_RUN_ID` for the rest of this process, so a script that
8
+ * spawns a child inheriting `process.env` — `run.sh` calling `manage-sandbox.sh`
9
+ * and then `aitk sandbox check` — resolves the same tree in every one of them.
10
+ * A process that already carries the variable, inherited from such a parent,
11
+ * reuses it rather than minting a new one.
12
+ *
13
+ * Twin of `mint_sandbox_run_id` in `scripts/lib/sandbox-path.sh`.
14
+ */
15
+ export function mintSandboxRunId(): string {
16
+ const existing = process.env.AITK_SANDBOX_RUN_ID
17
+ if (existing !== undefined && existing !== '') return existing
18
+
19
+ const id = randomBytes(4).toString('hex')
20
+ process.env.AITK_SANDBOX_RUN_ID = id
21
+ return id
22
+ }
23
+
24
+ /**
25
+ * The provisioned tree's path, split out of `src/commands/sandbox.ts` so the
26
+ * per-run default is unit-testable on its own rather than only through the
27
+ * command's registration.
28
+ *
29
+ * Twin of `resolve_sandbox_dir` in `scripts/lib/sandbox-path.sh`. The exec
30
+ * boundary rules out a shared constant, so a change to the default lands on
31
+ * both sides. The fall-through used to be one path per machine, so two
32
+ * sessions each resolving the default at once provisioned over each other
33
+ * with neither told; `mintSandboxRunId` gives the path a per-run component
34
+ * instead, which is what makes two such sessions land on two different trees.
35
+ */
36
+ export function sandboxTree(): string {
37
+ const override = process.env.AITK_SANDBOX_DIR
38
+ if (override !== undefined && override !== '') return override
39
+
40
+ const state = process.env.XDG_STATE_HOME
41
+ const base =
42
+ state !== undefined && state !== ''
43
+ ? state
44
+ : join(homedir(), '.local', 'state')
45
+
46
+ return join(base, 'aitk', `sandbox-${mintSandboxRunId()}`)
47
+ }