@ulysses-ai/create-workspace 0.16.0-beta.1 → 0.17.0-beta.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/README.md CHANGED
@@ -89,8 +89,8 @@ Four things, in the order you'll touch them:
89
89
  A scaffolded workspace with:
90
90
 
91
91
  - **14 skills** covering the workflow lifecycle, releases, handoffs, and maintenance
92
- - **8 active rules** + **8 optional `.skip` rules** for behaviors you can opt into
93
- - **10 hooks** for SessionStart, SubagentStart, PreCompact, WorktreeCreate, and the rest of the small set the conventions rely on
92
+ - **9 active rules** + **9 optional `.skip` rules** for behaviors you can opt into
93
+ - **9 hooks** for SessionStart, SubagentStart, PreCompact, and the rest of the small set the conventions rely on
94
94
  - A **`shared-context/`** memory system with three visibility levels: locked (team truths), root (team-visible ephemerals), user-scoped (personal)
95
95
  - Conventions for **multi-repo work sessions** with isolated git worktrees, parallelizable from separate terminals
96
96
 
package/lib/init.mjs CHANGED
@@ -105,6 +105,25 @@ export async function initWorkspace(targetDir) {
105
105
  }
106
106
  }
107
107
 
108
+ // Set up .claudeignore
109
+ const payloadClaudeignore = join(payloadDir, '.claudeignore');
110
+ const claudeignorePath = join(targetDir, '.claudeignore');
111
+ if (existsSync(payloadClaudeignore)) {
112
+ if (existsSync(claudeignorePath)) {
113
+ const existing = readFileSync(claudeignorePath, 'utf-8');
114
+ const template = readFileSync(payloadClaudeignore, 'utf-8');
115
+ const existingLines = new Set(existing.split('\n').map(l => l.trim()));
116
+ const newLines = template.split('\n').filter(l => l.trim() && !existingLines.has(l.trim()));
117
+ if (newLines.length > 0) {
118
+ writeFileSync(claudeignorePath, existing.trimEnd() + '\n\n# From workspace template\n' + newLines.join('\n') + '\n');
119
+ console.log(' Merged template entries into .claudeignore');
120
+ }
121
+ } else {
122
+ cpSync(payloadClaudeignore, claudeignorePath);
123
+ console.log(' Created .claudeignore');
124
+ }
125
+ }
126
+
108
127
  console.log(`
109
128
  Workspace initialized (v${toVersion}).
110
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.16.0-beta.1",
3
+ "version": "0.17.0-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // SessionEnd hook — mark this chat's `ended` timestamp in the session
3
- // tracker and append a small safety-net note to the session.md body.
4
- import { appendFileSync, mkdirSync, existsSync } from 'fs';
3
+ // tracker, append a small safety-net note to the session.md body, and
4
+ // write a disk-durable reflection record if the session.md ## Progress
5
+ // section contains heuristic correction-pattern sentences.
6
+ import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
5
7
  import { join } from 'path';
6
8
  import { execSync } from 'child_process';
7
9
  import {
@@ -71,6 +73,70 @@ if (pointer && sessionId) {
71
73
  } catch {
72
74
  // Non-fatal
73
75
  }
76
+
77
+ // Disk-durable reflection sub-step (BP-10).
78
+ // Read the ## Progress section of session.md (last 2000 chars max),
79
+ // scan for heuristic correction-pattern sentences, and write any
80
+ // candidates to workspace-scratchpad/session-reflect.json. The file
81
+ // is gitignored (workspace-scratchpad/ is in _gitignore). We do NOT
82
+ // emit additionalContext for this — per the canonical known limitation,
83
+ // additionalContext does not always reach Claude. Disk is the primary
84
+ // durable output.
85
+ try {
86
+ const sessionContent = readFileSync(trackerPath, 'utf8');
87
+
88
+ // Extract ## Progress section — find the section and take last 2000 chars.
89
+ const progressMatch = sessionContent.match(/^## Progress\s*\n([\s\S]*?)(?=\n^##|\s*$)/m);
90
+ const progressText = progressMatch
91
+ ? progressMatch[1].slice(-2000)
92
+ : sessionContent.slice(-2000);
93
+
94
+ // Split into sentences on '. ' or '.\n' boundaries.
95
+ const sentences = progressText
96
+ .split(/(?<=\.)\s+|\n/)
97
+ .map(s => s.trim())
98
+ .filter(s => s.length > 10);
99
+
100
+ // Correction-pattern keywords — case-insensitive.
101
+ const correctionPatterns = [
102
+ /actually/i,
103
+ /instead of/i,
104
+ /the right way is/i,
105
+ /i was wrong/i,
106
+ /correction:/i,
107
+ ];
108
+
109
+ const candidates = sentences
110
+ .filter(sentence => correctionPatterns.some(re => re.test(sentence)))
111
+ .map(text => ({ text, source: '## Progress' }));
112
+
113
+ if (candidates.length > 0) {
114
+ if (!existsSync(scratchpadDir)) mkdirSync(scratchpadDir, { recursive: true });
115
+ const reflectPath = join(scratchpadDir, 'session-reflect.json');
116
+
117
+ // Read existing records to append (create-or-append pattern).
118
+ let records = [];
119
+ if (existsSync(reflectPath)) {
120
+ try {
121
+ records = JSON.parse(readFileSync(reflectPath, 'utf8'));
122
+ if (!Array.isArray(records)) records = [records];
123
+ } catch {
124
+ records = [];
125
+ }
126
+ }
127
+
128
+ records.push({
129
+ sessionId: sessionId || `ts-${Date.now()}`,
130
+ date: new Date().toISOString(),
131
+ workSession: pointer.name,
132
+ candidates,
133
+ });
134
+
135
+ writeFileSync(reflectPath, JSON.stringify(records, null, 2), 'utf8');
136
+ }
137
+ } catch {
138
+ // Non-fatal — reflection is best-effort.
139
+ }
74
140
  }
75
141
  }
76
142
  }
@@ -0,0 +1,29 @@
1
+ # Config Review Cadence
2
+
3
+ Opt-in reminder to review `.claude` component files on a regular schedule. Activate by removing the `.skip` extension (`mv config-review.md.skip config-review.md`). Add it back to deactivate.
4
+
5
+ ## When to review
6
+
7
+ Review the files in `.claude/rules/`, `.claude/skills/*/SKILL.md`, `.claude/agents/*.md`, and `.claude/hooks/*.mjs` after each major model release and at least every 180 days. Claude Code evolves quickly — conventions that matched platform behavior six months ago may no longer be accurate, optimal, or even meaningful.
8
+
9
+ The 180-day threshold is not arbitrary: it corresponds to roughly two major Claude model generations. Conventions written for an older model generation can silently mis-steer the newer one without anyone noticing, because the file still runs without error.
10
+
11
+ ## Connection to /maintenance
12
+
13
+ The `/maintenance` skill's **Component age check** (step 7 in the Cleanup section) surfaces which files have drifted past 180 days by reading their frontmatter `updated:` field. Files without an `updated:` field are skipped — the check is incremental and only flags files that have opted in by carrying the field.
14
+
15
+ When `/maintenance` reports stale components, the recommended action is to open each flagged file, read it against the current Claude Code documentation and platform behavior, update the content where needed, and bump `updated:` to today.
16
+
17
+ ## Why this ships as .skip
18
+
19
+ Review cadence is org-specific. A solo maintainer doing active weekly development may want a shorter cadence; a team with a slower release cycle may need a longer one; some workspaces may defer review entirely to a dedicated maintenance session. Making this rule mandatory by default would encode one team's preference as a universal constraint — exactly the failure mode described in `product-bias-risk.md`.
20
+
21
+ Activate the rule only if your team wants the reminder to appear in every session. If 180 days is wrong for your cadence, edit the threshold in the rule body after activating it.
22
+
23
+ ## Activating
24
+
25
+ ```
26
+ mv .claude/rules/config-review.md.skip .claude/rules/config-review.md
27
+ ```
28
+
29
+ Once active, this reminder loads into every session context. To deactivate, rename it back.
@@ -0,0 +1,107 @@
1
+ Activate this rule if the workspace creates PRs, watches CI runs, or interacts with releases from skills. Sibling to `work-item-tracking.md` (which covers issues); together they cover everything a workspace needs to do against a code-hosting forge.
2
+
3
+ # Forge Operations
4
+
5
+ When a workspace has a forge configured, all pull-request, release, and workflow-run operations from skills and scripts go through the adapter at `.claude/scripts/forges/{type}.mjs`. Skills never call `gh` (or `glab`, or any forge CLI) inline.
6
+
7
+ ## Why the abstraction
8
+
9
+ - **Swap by config.** Moving from GitHub to GitLab is a `workspace.json` field change plus an adapter file — not a sweep across six skill files.
10
+ - **Testable.** The adapter takes an injectable `spawnFn`; unit tests mock subprocess calls instead of running them.
11
+ - **One vocabulary.** Skills reason about `forge.prCreate`, `forge.prMerge`, `forge.workflowRunFind`, `forge.workflowRunWatch`, `forge.releaseView` regardless of backend. Failure modes share typed errors (`PrNotFound`, `MergeRejected`, `WorkflowNotFound`, `ReleaseNotFound`) instead of every callsite parsing stderr.
12
+
13
+ ## Configuration
14
+
15
+ `workspace.json` → `workspace.forge`:
16
+
17
+ ```json
18
+ {
19
+ "workspace": {
20
+ "forge": {
21
+ "type": "github"
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ - `type` — identifies the adapter module at `.claude/scripts/forges/{type}.mjs`. `github` is the default and the only fully-implemented adapter today. `gitlab.mjs` ships as a stub that throws `NOT_IMPLEMENTED` with a contribution pointer.
28
+ - `repo` (optional) — adapter-specific. For `github`, the `owner/name` slug to target. When unset or `"auto"`, the adapter resolves the repo from the local git `origin` remote.
29
+
30
+ Absence of `workspace.forge` is treated as `{ type: 'github' }` (back-compat for workspaces that predate the field). Setting `workspace.forge: false` explicitly disables forge operations — every adapter method then throws `FORGE_DISABLED`.
31
+
32
+ ## Adapter interface (for Claude)
33
+
34
+ Import from `.claude/scripts/forges/interface.mjs`:
35
+
36
+ ```javascript
37
+ import { createForge, PrNotFound, MergeRejected, WorkflowNotFound, ReleaseNotFound } from '.claude/scripts/forges/interface.mjs';
38
+ import { readFileSync } from 'node:fs';
39
+
40
+ const ws = JSON.parse(readFileSync('workspace.json', 'utf-8'));
41
+ const forge = createForge(ws.workspace?.forge);
42
+
43
+ // Pull request lifecycle
44
+ const pr = await forge.prCreate({
45
+ title: 'feat: add forge adapter',
46
+ body: 'long-form body',
47
+ draft: false, // omit or false for normal PRs; true for /pause-work drafts
48
+ base: 'main', // optional; defaults to repo default branch
49
+ head: 'feature/forge', // optional; defaults to current branch
50
+ }); // → { id: 'owner/repo#42', url, number }
51
+
52
+ await forge.prMerge({
53
+ id: pr.id,
54
+ strategy: 'squash', // 'merge' | 'squash' | 'rebase'
55
+ deleteBranch: true,
56
+ });
57
+
58
+ const view = await forge.prView({ id: pr.id });
59
+ // → { state, mergeable, mergeStateStatus, reviewDecision, ... }
60
+
61
+ // Releases (lookup only — release creation lives in the workflow tag-push)
62
+ const release = await forge.releaseView({ tag: 'v1.2.3' });
63
+ // → { tag, name, url, publishedAt, isDraft, isPrerelease }
64
+ // throws ReleaseNotFound if the tag has no release
65
+
66
+ // Workflow runs (used by /complete-work to follow the publish workflow)
67
+ const run = await forge.workflowRunFind({
68
+ workflow: 'publish.yml',
69
+ branch: 'v1.2.3',
70
+ limit: 1,
71
+ }); // → { runId, status, conclusion, url } | null
72
+ const result = await forge.workflowRunWatch({
73
+ runId: run.runId,
74
+ exitStatus: true, // true: exit non-zero on workflow failure
75
+ }); // → { exitCode } — does NOT throw on workflow failure
76
+ ```
77
+
78
+ All methods are async. Adapter-detectable failures throw typed errors; raw spawn failures throw `Error`.
79
+
80
+ ## Skill behavior
81
+
82
+ Skills that interact with forge operations:
83
+
84
+ - **`/pause-work`** — creates draft PRs via `forge.prCreate({ draft: true })`.
85
+ - **`/complete-work`** — creates PRs, merges them with `strategy: 'squash'`, and (release sessions only) finds + watches the publish workflow via `workflowRunFind` + `workflowRunWatch`. Uses `releaseView` to investigate existing tag conflicts before re-tagging.
86
+
87
+ Skills outside that list do not call the forge adapter; they either don't touch the forge or they touch it for tracker-setup-specific operations that are deliberately scoped out (see below).
88
+
89
+ ## What this rule does NOT cover
90
+
91
+ - **Issue lifecycle.** Issues, comments, labels, milestones live in `work-item-tracking.md` via the tracker adapter at `.claude/scripts/trackers/{type}.mjs`. The two abstractions are intentionally separate.
92
+ - **Tracker-setup repo configuration.** `/setup-tracker` uses `gh repo view --json hasIssuesEnabled` and `gh api repos/{slug} -X PATCH -f has_issues=true` to inspect and enable the Issues feature on a GitHub repo. Those are GitHub-API-specific setup operations, not the cross-cutting PR/release ops the forge abstraction targets. A GitLab user running `/setup-tracker` would follow a different setup flow entirely, so wrapping these in the forge adapter would create a leaky abstraction. They remain direct `gh` calls.
93
+ - **`gh repo view` as a remote-type probe.** `/complete-work` uses `gh repo view` to detect whether a remote is a GitHub remote (separately from any PR operation that follows). This is a one-line capability check, not an operation that benefits from forge wrapping. It stays direct.
94
+ - **Repo creation.** `gh repo create` (in `/sync-work` and `/workspace-init` setup narratives) is an interactive one-off used when a workspace lacks a remote. No forge adapter method for it — pointing users at a wrapped form when none exists would be worse than the current direct mention.
95
+ - **Manual operator recovery.** `gh run rerun`, `gh run view`, `gh release view` referenced in `/release` recovery guidance are documented for an operator at a terminal investigating a failed publish. The forge adapter is for *skill code*, not the manual recovery prose.
96
+
97
+ ## Migration
98
+
99
+ Existing workspaces predate `workspace.forge`. They continue to work because `createForge(undefined)` defaults to `{ type: 'github' }`. The `/maintenance` audit surfaces a notice when `workspace.tracker.type === 'github-issues'` and `workspace.forge` is unset, suggesting the explicit value — a one-line `workspace.json` addition with no behavior change.
100
+
101
+ A workspace switching to GitLab implements `.claude/scripts/forges/gitlab.mjs` against the interface (the stub file documents the shape) and sets `workspace.forge.type: 'gitlab'`. No skill rewrite is required; the abstraction does the routing.
102
+
103
+ ## What this rule does NOT do
104
+
105
+ - Does not prescribe a specific forge type. Adapter choice is per workspace.
106
+ - Does not replace forge-native features (PR comments, review-requested webhooks, branch protection settings) — those remain UI / direct-CLI territory.
107
+ - Does not promise that every `gh` capability is wrapped. The adapter covers the operations the template's skills actually perform. New operations land via additive interface methods, not by skills going around the adapter.
@@ -107,6 +107,19 @@ The evaluator's "no, awaiting review" response after a gated phase does not bypa
107
107
 
108
108
  `gate: auto` is allowed in the format but discouraged in v1. Earn it after the workflow has been exercised at least once on the work in question.
109
109
 
110
+ ## Gate-budget interaction and turn-budget sizing
111
+
112
+ A goal's `turn_budget` is a backstop that counts every orchestrator turn — including the short "awaiting your gate decision" exchanges that happen at every `gate: review` phase. The `/goal` evaluator re-pings the session whenever it tries to settle without the completion condition being met, and each ping consumes a turn. Concretely: a multi-phase gated goal whose author is away for stretches can spend a meaningful fraction of its budget *idling at gates* rather than advancing work. A run with six `gate: review` phases burned roughly half of a 150-turn budget on gate-idle pings before the actual work completed.
113
+
114
+ This is a structural property of `/goal` + review gates, not a per-goal accident. Plan for it:
115
+
116
+ - **Drop `gate: review` from early phases when the human is expected to be away for stretches.** Research, crossref, spec, and plan phases produce artifacts that the final session→main PR consumes anyway. The integration-branch model (`main` untouched until `/complete-work` opens the final PR for human review) already provides one strong human review point; piling per-phase gates on top of that, with no human watching, just burns budget. Set those phases to `gate: auto` when the run will be unattended.
117
+ - **Keep `gate: review` for phases with irreversible side effects or for phases whose outcome reshapes subsequent phases.** A spec gate that lets the human reprioritize the ranked execution list before `executing-plans` walks it is worth its cost; a research-synthesis gate that just rubber-stamps a matrix the human will see again in the final PR is not.
118
+ - **Size `turn_budget` for the worst case you actually expect.** If every phase is `gate: auto`: budget ≈ (estimated work-turns) × 1.2 (small headroom for retries). If any phases are `gate: review` and the human may be unavailable for hours: multiply the work-turn estimate by **2–3×** to absorb gate-idle pings, or raise the budget mid-goal by editing the goal artifact's `completion_condition` and `turn_budget` fields. The Stop-hook condition string is fixed from the original `/goal` invocation; raising `turn_budget` in the artifact keeps the auditable source-of-truth correct for resume but does not change the running evaluator's text — the user can re-run the (updated) `## Start command` to refresh it after `/goal clear`.
119
+ - **If the goal stops on the backstop mid-work because of gate-idle waste, that is the system working as designed.** `claude --resume` plus re-running the `## Start command` continues from durable phase state; phase artifacts and per-phase `status: complete` markers survive. Do not treat backstop-stop as a failure of the goal.
120
+
121
+ This guidance is workspace-side mitigation only. The underlying friction — the evaluator pinging during gate-idle — is `/goal` harness behavior, not workspace code. A cleaner fix (suspend the evaluator at review gates so it does not re-ping until a new user message arrives) is tracked separately and would obsolete the multiplier above when it lands.
122
+
110
123
  ## Integration branch and per-phase sub-PRs
111
124
 
112
125
  While a `/goal`-driven session is running, the session branch (`feature/{session-name}`) acts as the goal's integration branch. Main is untouched until `/complete-work` opens the final session→main PR for human review. This is the key autonomy boundary: phase agents can merge their own work, repeatedly, throughout the goal — but only into the integration branch, never into main.
@@ -47,6 +47,16 @@ Canonical content is verbatim-loaded into every session via `CLAUDE.md` → `@wo
47
47
 
48
48
  Inflight session state lives inside the session worktree at `work-sessions/{name}/workspace/session.md`, not in `workspace-context/`. Workspace-context is for knowledge that outlives any individual session.
49
49
 
50
+ ## Dynamic context loading (hooks)
51
+
52
+ Two hooks extend static `CLAUDE.md` loading with context that varies per session and per invocation:
53
+
54
+ - **`session-start.mjs`** (`SessionStart` hook): reads the active session pointer from `workspace-scratchpad/` and injects the current session's name, branch, linked work item, and shared context catalog into Claude's context. The injection is conditional — if no session is active, or if the relevant fields are absent from the session frontmatter, nothing is added. This avoids noise in non-session contexts (e.g., a quick launcher query).
55
+
56
+ - **`subagent-start.mjs`** (`SubagentStart` hook): reads every file under `workspace-context/shared/locked/` and injects their content into subagent context. A `subagentContextMaxBytes` field in `workspace.json` (default 10240) acts as a byte-budget fallback — if the total locked content exceeds the budget, files are truncated in reverse-priority order rather than silently dropped. This ensures subagents that never load `CLAUDE.md` still receive canonical team truths.
57
+
58
+ Both hooks are at `.claude/hooks/session-start.mjs` and `.claude/hooks/subagent-start.mjs`. They are registered as Node.js scripts — cross-platform, no shell dependency.
59
+
50
60
  ## Spec and Plan Locations — MANDATORY OVERRIDE
51
61
 
52
62
  **Specs, plans, and goal artifacts MUST be written at the top of the active session's workspace worktree, not to `docs/superpowers/` or any other location.**
@@ -97,3 +107,31 @@ Local-only personal drafts get an additional `local-only-` prefix (e.g., `local-
97
107
  - `workspace-scratchpad/` is for disposable files only — session log, hook debug output, temporary pointers.
98
108
  - Project worktrees are nested inside the workspace worktree's real `repos/` directory — no symlink.
99
109
  - Hand edits to `index.md`, `canonical.md`, or any per-user `team-member/{user}/index.md` are overwritten by `build-workspace-context.mjs`. Update source files (or their `description:` frontmatter) instead.
110
+
111
+ ## Per-repo commands
112
+
113
+ Per-repo test, lint, and build commands belong in `repos/{repo}/CLAUDE.md` under a `## Commands` section. This scopes Claude's command invocations to the specific repo rather than triggering monorepo-wide runs that may time out or produce irrelevant output. The `/workspace-init` skill scaffolds a blank `repos/{repo}/CLAUDE.md` stub with a `## Commands` placeholder — fill it in once the repo is cloned.
114
+
115
+ ## Explore before editing
116
+
117
+ Before modifying files in a large or unfamiliar codebase, use read-only tools to map the affected surface. The workflow: dispatch a researcher-type subagent to read, grep, and navigate the codebase; have it return a summary of the affected files, callers, and dependencies; then edit only after the map is established.
118
+
119
+ The `researcher.md` agent enforces this pattern mechanically via `disallowedTools: [Edit, Write, Bash]` — it can read and search but cannot change anything. Use it for initial exploration, then hand the findings back to the main agent for the actual edit. This avoids partial edits that break callers, catches ripple effects before they happen, and keeps the edit surface as small as possible.
120
+
121
+ ## Launching Claude from a project worktree
122
+
123
+ Claude can be launched from any directory, and it walks up the filesystem loading every `CLAUDE.md` it finds. This means starting `claude` from `work-sessions/{name}/workspace/repos/{repo}/` loads both the per-repo conventions (from `repos/{repo}/CLAUDE.md`, if it exists) and the full workspace conventions (from the workspace `CLAUDE.md` further up the tree) — all without extra configuration.
124
+
125
+ For repo-focused work — debugging a single service, reviewing a specific module, running targeted tests — launching from the project worktree gives Claude a tighter codebase context. It sees the repo's own file tree first and reaches workspace-level conventions by traversal. The session hooks still fire (they read from `workspace-scratchpad/`, which is always relative to the workspace root), and `session.md` and all session artifacts remain at the workspace worktree top.
126
+
127
+ This is purely a launch-point choice; no workspace configuration changes are needed to enable it.
128
+
129
+ ## Grep vs LSP
130
+
131
+ Two complementary search strategies cover different parts of the navigation surface:
132
+
133
+ - **Grep / Ripgrep** — searches file content as text. Fast, requires no server, and works across any file type. Use for free-text pattern search: finding a string literal, locating config values, scanning comments, searching across heterogeneous files. The downside: no language awareness — a search for `_toMs` matches comments, string literals, and variable names alike, producing false positives that require manual filtering.
134
+
135
+ - **LSP tools (`mcp__lsp__*`)** — powered by a running Language Server Protocol server that has indexed the codebase. LSP understands the language's type system and scope rules, so `find-all-references` on `_toMs` returns only actual symbol usages, not textual coincidences. Go-to-definition, rename-symbol, and callers/callees are accurate even across files and module boundaries. The tradeoff: requires a running LSP MCP server configured in `.mcp.json`.
136
+
137
+ The practical rule: reach for Grep first when you don't know where to look or when the pattern is not a symbol. Switch to LSP when you have a specific symbol and need precise cross-file navigation — especially before refactoring or understanding a call graph. The `researcher.md` agent lists the LSP tool among its allowed tools; activating LSP requires adding the appropriate language server to `.mcp.json` (see the MCP servers step in `/workspace-init`).
@@ -11,9 +11,23 @@
11
11
  // Workspace-first removal silently deletes the nested project worktrees'
12
12
  // .git files and leaves orphan worktree records in the project repos.
13
13
  // The safe order keeps both sides of the relationship in sync.
14
+ //
15
+ // State discovery is defensive: the session tracker (session.md) may have
16
+ // been stripped by /complete-work Step 7 before this script runs, leaving
17
+ // no `repos:` or `branch:` to read. When that happens we discover repos
18
+ // from the work-sessions/{name}/workspace/repos/ directory listing and the
19
+ // branch from `git branch --show-current` on the workspace worktree —
20
+ // BEFORE removing anything. Without that, the per-repo loops silently
21
+ // no-op and the script reports success while leaving orphans behind
22
+ // (gh:119).
23
+ //
24
+ // `success: true` means VERIFIED: every project worktree record is gone
25
+ // (no prunable entries left over), every local branch is deleted, and the
26
+ // session folder is removed. The script post-verifies all of these and
27
+ // surfaces any leftover state as an error rather than swallowing it.
14
28
  import '../lib/require-node.mjs';
15
29
  import { execSync } from 'child_process';
16
- import { existsSync } from 'fs';
30
+ import { existsSync, readdirSync, statSync } from 'fs';
17
31
  import { join } from 'path';
18
32
  import {
19
33
  getWorkspaceRoot,
@@ -36,74 +50,198 @@ if (!sessionName) {
36
50
  }
37
51
 
38
52
  const root = getWorkspaceRoot(import.meta.url);
39
- const tracker = readSessionTracker(root, sessionName);
40
- const repos = normalizeRepos(tracker?.repos);
41
- const branch = tracker?.branch;
42
53
  const reposDir = join(root, 'repos');
43
54
  const sessionFolder = sessionFolderPath(root, sessionName);
44
55
  const wsWorktree = join(sessionFolder, 'workspace');
45
56
 
46
57
  const removed = [];
58
+ const skipped = [];
47
59
  const errors = [];
48
60
 
49
- // Step 1: Remove each project worktree FIRST, from its project repo
50
- for (const repo of repos) {
51
- const projWorktree = join(wsWorktree, 'repos', repo);
52
- const repoDir = join(reposDir, repo);
53
- if (existsSync(projWorktree)) {
61
+ // === Discovery: where session.md is silent or missing, fall back to disk ===
62
+ //
63
+ // The tracker may have been stripped before this script runs. Read whatever
64
+ // is still there, then fill gaps from the live worktree state.
65
+
66
+ const tracker = readSessionTracker(root, sessionName);
67
+ let repos = normalizeRepos(tracker?.repos);
68
+ let branch = tracker?.branch || null;
69
+
70
+ // If repos is empty, discover from work-sessions/{name}/workspace/repos/.
71
+ // That directory is the workspace worktree's nested-project-worktrees dir;
72
+ // each entry is one project repo this session checked out.
73
+ if (repos.length === 0) {
74
+ const nestedReposDir = join(wsWorktree, 'repos');
75
+ if (existsSync(nestedReposDir)) {
54
76
  try {
55
- execSync(`git worktree remove "${projWorktree}" --force`, { cwd: repoDir, stdio: 'pipe' });
56
- removed.push(`project worktree ${repo}`);
77
+ const discovered = readdirSync(nestedReposDir).filter((entry) => {
78
+ try {
79
+ return statSync(join(nestedReposDir, entry)).isDirectory();
80
+ } catch {
81
+ return false;
82
+ }
83
+ });
84
+ if (discovered.length > 0) {
85
+ repos = discovered;
86
+ skipped.push({
87
+ step: 'discovery',
88
+ reason: `Tracker missing repos; discovered ${discovered.length} from ${nestedReposDir}: ${discovered.join(', ')}`,
89
+ });
90
+ }
57
91
  } catch (err) {
58
- errors.push(`Failed to remove ${repo} worktree: ${err.message}`);
92
+ skipped.push({ step: 'discovery', reason: `Failed to list ${nestedReposDir}: ${err.message}` });
59
93
  }
60
94
  }
61
95
  }
62
96
 
63
- // Step 2: Remove the workspace worktree AFTER project worktrees are gone
97
+ // If branch is missing, ask the workspace worktree itself.
98
+ if (!branch && existsSync(wsWorktree)) {
99
+ try {
100
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: wsWorktree, stdio: 'pipe', encoding: 'utf-8' }).trim();
101
+ if (branch) {
102
+ skipped.push({ step: 'discovery', reason: `Tracker missing branch; discovered from worktree: ${branch}` });
103
+ }
104
+ } catch (err) {
105
+ skipped.push({ step: 'discovery', reason: `Failed to read branch from ${wsWorktree}: ${err.message}` });
106
+ }
107
+ }
108
+
109
+ // === Step 1: Remove each project worktree FIRST, from its project repo ===
110
+ for (const repo of repos) {
111
+ const projWorktree = join(wsWorktree, 'repos', repo);
112
+ const repoDir = join(reposDir, repo);
113
+ if (!existsSync(projWorktree)) {
114
+ skipped.push({ step: 'remove-project-worktree', repo, reason: `${projWorktree} does not exist` });
115
+ continue;
116
+ }
117
+ if (!existsSync(repoDir)) {
118
+ errors.push(`Cannot remove ${repo} worktree: source clone missing at ${repoDir}`);
119
+ continue;
120
+ }
121
+ try {
122
+ execSync(`git worktree remove "${projWorktree}" --force`, { cwd: repoDir, stdio: 'pipe' });
123
+ removed.push(`project worktree ${repo}`);
124
+ } catch (err) {
125
+ errors.push(`Failed to remove ${repo} worktree: ${err.message.trim()}`);
126
+ }
127
+ }
128
+
129
+ // === Step 2: Remove the workspace worktree AFTER project worktrees are gone ===
64
130
  if (existsSync(wsWorktree)) {
65
131
  try {
66
132
  execSync(`git worktree remove "${wsWorktree}" --force`, { cwd: root, stdio: 'pipe' });
67
133
  removed.push('workspace worktree');
68
134
  } catch (err) {
69
- errors.push(`Failed to remove workspace worktree: ${err.message}`);
135
+ errors.push(`Failed to remove workspace worktree: ${err.message.trim()}`);
70
136
  }
137
+ } else {
138
+ skipped.push({ step: 'remove-workspace-worktree', reason: `${wsWorktree} does not exist` });
71
139
  }
72
140
 
73
- // Step 3: Prune each project repo to mop up orphans from any prior misuses
141
+ // === Step 3: Prune each project repo to mop up orphans ===
74
142
  for (const repo of repos) {
75
143
  const repoDir = join(reposDir, repo);
144
+ if (!existsSync(repoDir)) continue;
76
145
  try {
77
146
  execSync('git worktree prune', { cwd: repoDir, stdio: 'pipe' });
78
- } catch {
79
- // Non-fatal — prune is a safety net
147
+ } catch (err) {
148
+ // Prune is a safety net, but if it fails on a repo we touched, surface
149
+ // it — verification below will catch leftover orphans either way.
150
+ errors.push(`Prune failed in ${repo}: ${err.message.trim()}`);
80
151
  }
81
152
  }
82
153
 
83
- // Step 4: Delete local branches
154
+ // === Step 4: Delete local branches ===
84
155
  if (branch) {
85
156
  for (const repo of repos) {
86
157
  const repoDir = join(reposDir, repo);
158
+ if (!existsSync(repoDir)) continue;
87
159
  try {
88
- execSync(`git branch -D "${branch}"`, { cwd: repoDir, stdio: 'pipe' });
160
+ execSync(`git branch --list "${branch}"`, { cwd: repoDir, stdio: 'pipe', encoding: 'utf-8' });
89
161
  } catch {
90
- // Non-fatal branch may already be gone or refuse to delete
162
+ continue; // Repo broken; verification will catch downstream impact.
163
+ }
164
+ const exists = execSync(`git branch --list "${branch}"`, { cwd: repoDir, encoding: 'utf-8' }).trim();
165
+ if (!exists) continue; // Already gone (e.g., gh pr merge --delete-branch did it).
166
+ try {
167
+ execSync(`git branch -D "${branch}"`, { cwd: repoDir, stdio: 'pipe' });
168
+ } catch (err) {
169
+ errors.push(`Failed to delete branch ${branch} in ${repo}: ${err.message.trim()}`);
91
170
  }
92
171
  }
172
+ // Same for the workspace repo (root).
93
173
  try {
94
- execSync(`git branch -D "${branch}"`, { cwd: root, stdio: 'pipe' });
174
+ const exists = execSync(`git branch --list "${branch}"`, { cwd: root, encoding: 'utf-8' }).trim();
175
+ if (exists) {
176
+ try {
177
+ execSync(`git branch -D "${branch}"`, { cwd: root, stdio: 'pipe' });
178
+ } catch (err) {
179
+ errors.push(`Failed to delete branch ${branch} in workspace repo: ${err.message.trim()}`);
180
+ }
181
+ }
95
182
  } catch {
96
- // Non-fatal
183
+ // workspace root not a git repo — unusual, skip.
97
184
  }
98
185
  }
99
186
 
100
- // Step 5: Delete the whole work-sessions/{name}/ folder. The session.md,
101
- // specs, plans, and any local-only artifacts vanish. Their content was
102
- // archived into release notes by /complete-work before this script ran.
103
- deleteSessionFolder(root, sessionName);
187
+ // === Step 5: Delete the whole work-sessions/{name}/ folder ===
188
+ try {
189
+ deleteSessionFolder(root, sessionName);
190
+ } catch (err) {
191
+ errors.push(`Failed to delete session folder ${sessionFolder}: ${err.message.trim()}`);
192
+ }
193
+
194
+ // === Post-verification: success means VERIFIED, not "no try/catch threw" ===
195
+ //
196
+ // Without these checks, an empty repos list (the gh:119 root cause) lets
197
+ // every silent skip add up to a "success" output while leaving orphans
198
+ // behind. The verification turns silent skips into honest errors.
199
+
200
+ if (existsSync(sessionFolder)) {
201
+ errors.push(`Session folder still present after cleanup: ${sessionFolder}`);
202
+ }
203
+
204
+ const wsPath = wsWorktree; // canonical path the worktree had
205
+ for (const repo of repos) {
206
+ const repoDir = join(reposDir, repo);
207
+ if (!existsSync(repoDir)) continue;
208
+ let wtList = '';
209
+ try {
210
+ wtList = execSync('git worktree list --porcelain', { cwd: repoDir, encoding: 'utf-8' });
211
+ } catch (err) {
212
+ errors.push(`Could not list worktrees in ${repo}: ${err.message.trim()}`);
213
+ continue;
214
+ }
215
+ if (wtList.includes('prunable')) {
216
+ errors.push(`Prunable worktree record remains in ${repo} after cleanup (gh:119 symptom)`);
217
+ }
218
+ if (wtList.includes(wsPath)) {
219
+ errors.push(`${repo} still has a worktree record referencing the session path`);
220
+ }
221
+ if (branch) {
222
+ const branchStill = execSync(`git branch --list "${branch}"`, { cwd: repoDir, encoding: 'utf-8' }).trim();
223
+ if (branchStill) {
224
+ errors.push(`Branch ${branch} still present in ${repo} after cleanup`);
225
+ }
226
+ }
227
+ }
228
+
229
+ if (branch) {
230
+ try {
231
+ const branchStill = execSync(`git branch --list "${branch}"`, { cwd: root, encoding: 'utf-8' }).trim();
232
+ if (branchStill) {
233
+ errors.push(`Branch ${branch} still present in workspace repo after cleanup`);
234
+ }
235
+ } catch {
236
+ // not a git repo — already noted
237
+ }
238
+ }
104
239
 
105
240
  console.log(JSON.stringify({
106
241
  success: errors.length === 0,
107
242
  removed,
243
+ skipped: skipped.length > 0 ? skipped : undefined,
108
244
  errors: errors.length > 0 ? errors : undefined,
109
245
  }));
246
+
247
+ if (errors.length > 0) process.exit(1);