@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 +2 -2
- package/lib/init.mjs +19 -0
- package/package.json +1 -1
- package/template/.claude/hooks/session-end.mjs +68 -2
- package/template/.claude/rules/config-review.md.skip +29 -0
- package/template/.claude/rules/forge-operations.md +107 -0
- package/template/.claude/rules/goal-driven-work.md +13 -0
- package/template/.claude/rules/workspace-structure.md +38 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +164 -26
- package/template/.claude/scripts/forges/github.mjs +210 -0
- package/template/.claude/scripts/forges/gitlab.mjs +19 -0
- package/template/.claude/scripts/forges/interface.mjs +113 -0
- package/template/.claude/settings.json +5 -13
- package/template/.claude/skills/complete-work/SKILL.md +67 -37
- package/template/.claude/skills/maintenance/SKILL.md +32 -6
- package/template/.claude/skills/pause-work/SKILL.md +24 -7
- package/template/.claude/skills/workspace-init/SKILL.md +32 -0
- package/template/.claudeignore +3 -0
- package/template/CLAUDE.md.tmpl +1 -0
- package/template/CODEBASE.md.tmpl +13 -0
- package/template/repo-claude.md.tmpl +10 -0
- package/template/workspace.json.tmpl +2 -1
- package/template/.claude/hooks/worktree-create.mjs +0 -53
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
|
-
- **
|
|
93
|
-
- **
|
|
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,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// SessionEnd hook — mark this chat's `ended` timestamp in the session
|
|
3
|
-
// tracker
|
|
4
|
-
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
92
|
+
skipped.push({ step: 'discovery', reason: `Failed to list ${nestedReposDir}: ${err.message}` });
|
|
59
93
|
}
|
|
60
94
|
}
|
|
61
95
|
}
|
|
62
96
|
|
|
63
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
160
|
+
execSync(`git branch --list "${branch}"`, { cwd: repoDir, stdio: 'pipe', encoding: 'utf-8' });
|
|
89
161
|
} catch {
|
|
90
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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);
|