@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.19.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 +3 -3
- package/lib/init.mjs +4 -1
- package/lib/payload.mjs +18 -1
- package/lib/payload.test.mjs +55 -0
- package/lib/scaffold.mjs +23 -6
- package/lib/scaffold.test.mjs +59 -0
- package/package.json +1 -1
- package/template/CLAUDE.md.tmpl +19 -2
- package/template/{.claude → _claude}/hooks/_utils.mjs +1 -1
- package/template/_claude/hooks/repo-write-detection.mjs +204 -0
- package/template/{.claude → _claude}/hooks/session-start.mjs +35 -1
- package/template/_claude/hooks/subagent-start.mjs +111 -0
- package/template/{.claude → _claude}/lib/session-frontmatter.mjs +28 -0
- package/template/{.claude → _claude}/rules/coherent-revisions.md +1 -1
- package/template/_claude/rules/forge-operations.md +57 -0
- package/template/_claude/rules/git-conventions.md +39 -0
- package/template/_claude/rules/goal-driven-work.md +24 -0
- package/template/_claude/rules/honest-pushback.md +56 -0
- package/template/_claude/rules/memory-guidance.md +66 -0
- package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +1 -1
- package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
- package/template/_claude/rules/work-item-tracking.md +48 -0
- package/template/_claude/rules/workspace-structure.md +79 -0
- package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +86 -30
- package/template/_claude/scripts/chat-record.mjs +315 -0
- package/template/_claude/scripts/cleanup-work-session.mjs +436 -0
- package/template/_claude/scripts/context-footprint.mjs +391 -0
- package/template/{.claude → _claude}/scripts/forges/github.mjs +46 -0
- package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +3 -2
- package/template/{.claude → _claude}/scripts/forges/interface.mjs +13 -0
- package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +21 -2
- package/template/_claude/scripts/migrate-sessions.mjs +1571 -0
- package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +7 -2
- package/template/_claude/scripts/task-pr.mjs +447 -0
- package/template/_claude/scripts/task-worktree.mjs +525 -0
- package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
- package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
- package/template/_claude/scripts/workspace-diagnostics.mjs +654 -0
- package/template/{.claude → _claude}/skills/braindump/SKILL.md +12 -4
- package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +5 -5
- package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +452 -0
- package/template/_claude/skills/context-placement/SKILL.md +202 -0
- package/template/{.claude/rules/goal-driven-work.md → _claude/skills/goal-driven-work/SKILL.md} +46 -19
- package/template/{.claude → _claude}/skills/handoff/SKILL.md +12 -4
- package/template/{.claude → _claude}/skills/maintenance/SKILL.md +56 -17
- package/template/_claude/skills/migrate-sessions/SKILL.md +70 -0
- package/template/{.claude → _claude}/skills/pause-work/SKILL.md +9 -1
- package/template/_claude/skills/release/SKILL.md +91 -0
- package/template/{.claude → _claude}/skills/start-work/SKILL.md +89 -7
- package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +3 -1
- package/template/{.claude → _claude}/skills/workspace-update/SKILL.md +4 -0
- package/template/_gitignore +9 -0
- package/template/workspace.json.tmpl +4 -3
- package/template/.claude/hooks/repo-write-detection.mjs +0 -107
- package/template/.claude/hooks/subagent-start.mjs +0 -44
- package/template/.claude/rules/forge-operations.md +0 -107
- package/template/.claude/rules/git-conventions.md +0 -34
- package/template/.claude/rules/honest-pushback.md +0 -56
- package/template/.claude/rules/memory-guidance.md +0 -109
- package/template/.claude/rules/work-item-tracking.md +0 -90
- package/template/.claude/rules/workspace-structure.md +0 -137
- package/template/.claude/scripts/cleanup-work-session.mjs +0 -247
- package/template/.claude/skills/complete-work/SKILL.md +0 -498
- package/template/.claude/skills/release/SKILL.md +0 -151
- /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/implementer.md +0 -0
- /package/template/{.claude → _claude}/agents/researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
- /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/freshness.mjs +0 -0
- /package/template/{.claude → _claude}/lib/registry-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
- /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
- /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
- /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-claude-md-freshness-include.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
- /package/template/{.claude → _claude}/settings.json +0 -0
- /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
- /package/template/{.mcp.json → _mcp.json} +0 -0
package/README.md
CHANGED
|
@@ -74,11 +74,11 @@ Rules say what's safe. Skills say how to do the recurring things. Hooks notice w
|
|
|
74
74
|
|
|
75
75
|
Four things, in the order you'll touch them:
|
|
76
76
|
|
|
77
|
-
1. **A workflow lifecycle that survives chat boundaries.** `/start-work` provisions a session — branch, worktree, tracker — atomically. `/pause-work` and `/sync-work` checkpoint mid-stream. `/complete-work` rebases,
|
|
77
|
+
1. **A workflow lifecycle that survives chat boundaries.** `/start-work` provisions a session — branch, worktree, tracker — atomically. `/pause-work` and `/sync-work` checkpoint mid-stream. `/complete-work` rebases, opens PRs, merges, and tears down. The same session resumes cleanly in a fresh chat.
|
|
78
78
|
|
|
79
79
|
2. **Parallel work sessions you can run from separate terminals.** Each session lives in its own folder under `work-sessions/{name}/` with its own workspace worktree and nested project worktrees. Two sessions can't collide on a branch or a working directory.
|
|
80
80
|
|
|
81
|
-
3. **Multi-repo support with versioning across repos.** A workspace wraps your project repos rather than replacing them. Each session can span one repo or many. `/release`
|
|
81
|
+
3. **Multi-repo support with versioning across repos.** A workspace wraps your project repos rather than replacing them. Each session can span one repo or many. `/release` cuts a versioned release per repo — bump, tag, forge release with notes generated from merged PRs.
|
|
82
82
|
|
|
83
83
|
4. **Shared context with a locked layer that stays in the window.** `shared-context/locked/` is loaded every turn and injected into subagents. Team truths arrive in the model's context window without anyone remembering to paste them.
|
|
84
84
|
|
|
@@ -88,7 +88,7 @@ Four things, in the order you'll touch them:
|
|
|
88
88
|
|
|
89
89
|
A scaffolded workspace with:
|
|
90
90
|
|
|
91
|
-
- **
|
|
91
|
+
- **17 skills** covering the workflow lifecycle, releases, handoffs, and maintenance
|
|
92
92
|
- **9 active rules** + **9 optional `.skip` rules** for behaviors you can opt into
|
|
93
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)
|
package/lib/init.mjs
CHANGED
|
@@ -20,7 +20,10 @@ export async function initWorkspace(targetDir) {
|
|
|
20
20
|
const { toVersion, payloadDir } = stagePayload(targetDir, { action: 'init' });
|
|
21
21
|
console.log(` Staged template payload (v${toVersion})`);
|
|
22
22
|
|
|
23
|
-
// Install only the bootstrap skills needed to complete initialization
|
|
23
|
+
// Install only the bootstrap skills needed to complete initialization.
|
|
24
|
+
// The payload keeps live names (.claude/, .mcp.json) — stagePayload()
|
|
25
|
+
// maps them back from the template's inert names so the staged layout
|
|
26
|
+
// stays readable by skills already installed in the workspace.
|
|
24
27
|
const bootstrapSkills = ['workspace-init', 'workspace-update'];
|
|
25
28
|
const payloadSkills = join(payloadDir, '.claude', 'skills');
|
|
26
29
|
const targetSkills = join(targetDir, '.claude', 'skills');
|
package/lib/payload.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// lib/payload.mjs
|
|
2
|
-
import { existsSync, cpSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
2
|
+
import { existsSync, cpSync, rmSync, mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
|
|
3
3
|
import { join, dirname } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
|
|
@@ -22,6 +22,23 @@ export function stagePayload(targetDir, { action, fromVersion = null }) {
|
|
|
22
22
|
// Copy template to payload directory
|
|
23
23
|
cpSync(TEMPLATE_DIR, payloadDir, { recursive: true });
|
|
24
24
|
|
|
25
|
+
// The template stores .claude/ and .mcp.json under the inert names
|
|
26
|
+
// _claude/ and _mcp.json (Claude Code protects the live names from
|
|
27
|
+
// headless edits). The staged payload is an on-disk format shared with
|
|
28
|
+
// the /workspace-update skill ALREADY INSTALLED in the workspace, which
|
|
29
|
+
// reads .workspace-update/.claude/... — so it must keep the live names
|
|
30
|
+
// exactly as older package versions staged them. _gitignore stays inert:
|
|
31
|
+
// the skills have always merged it under that name.
|
|
32
|
+
for (const [from, to] of [
|
|
33
|
+
['_claude', '.claude'],
|
|
34
|
+
['_mcp.json', '.mcp.json'],
|
|
35
|
+
]) {
|
|
36
|
+
const src = join(payloadDir, from);
|
|
37
|
+
if (existsSync(src)) {
|
|
38
|
+
renameSync(src, join(payloadDir, to));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
// Write manifest
|
|
26
43
|
const manifest = {
|
|
27
44
|
action,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for payload.mjs
|
|
3
|
+
// Run: node lib/payload.test.mjs
|
|
4
|
+
//
|
|
5
|
+
// The staged .workspace-update/ payload is an on-disk format shared with
|
|
6
|
+
// the /workspace-update skill already installed in existing workspaces,
|
|
7
|
+
// which reads .workspace-update/.claude/... and .workspace-update/.mcp.json.
|
|
8
|
+
// These tests pin that layout even though template/ itself stores the
|
|
9
|
+
// protected paths under the inert names _claude/ and _mcp.json.
|
|
10
|
+
import { stagePayload, cleanPayload } from './payload.mjs';
|
|
11
|
+
import { mkdtempSync, rmSync, existsSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { tmpdir } from 'os';
|
|
14
|
+
|
|
15
|
+
let failed = 0;
|
|
16
|
+
let passed = 0;
|
|
17
|
+
function check(label, ok) {
|
|
18
|
+
if (ok) { passed++; } else {
|
|
19
|
+
failed++;
|
|
20
|
+
console.error(` FAIL: ${label}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), 'payload-test-'));
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const { payloadDir, toVersion } = stagePayload(root, { action: 'init' });
|
|
28
|
+
|
|
29
|
+
check('payload staged at .workspace-update/', existsSync(payloadDir));
|
|
30
|
+
check('manifest written', existsSync(join(payloadDir, '.manifest.json')));
|
|
31
|
+
check('returns the package version', typeof toVersion === 'string' && toVersion.length > 0);
|
|
32
|
+
|
|
33
|
+
// The payload keeps the live names older workspaces' skills expect
|
|
34
|
+
check('.claude/ staged under live name', existsSync(join(payloadDir, '.claude')));
|
|
35
|
+
check('.claude/skills/workspace-update/SKILL.md staged', existsSync(join(payloadDir, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
36
|
+
check('.claude/settings.json staged', existsSync(join(payloadDir, '.claude', 'settings.json')));
|
|
37
|
+
check('.mcp.json staged under live name', existsSync(join(payloadDir, '.mcp.json')));
|
|
38
|
+
check('_gitignore stays inert (skills merge it under that name)', existsSync(join(payloadDir, '_gitignore')));
|
|
39
|
+
|
|
40
|
+
// The template's inert names must not leak into the payload
|
|
41
|
+
check('no _claude/ in payload', !existsSync(join(payloadDir, '_claude')));
|
|
42
|
+
check('no _mcp.json in payload', !existsSync(join(payloadDir, '_mcp.json')));
|
|
43
|
+
|
|
44
|
+
// cleanPayload removes the staging directory
|
|
45
|
+
cleanPayload(root);
|
|
46
|
+
check('cleanPayload removes .workspace-update/', !existsSync(payloadDir));
|
|
47
|
+
} finally {
|
|
48
|
+
rmSync(root, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (failed > 0) {
|
|
52
|
+
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
console.log(`${passed} checks passed`);
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
|
|
1
|
+
import { cpSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, rmSync, statSync } from 'fs';
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
|
|
@@ -26,11 +26,28 @@ export async function scaffold(answers) {
|
|
|
26
26
|
// scripts and hooks first need them — we do NOT pre-create them here.
|
|
27
27
|
mkdirSync(join(directory, 'workspace-context', 'locked'), { recursive: true });
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// The template stores some files under inert names — _gitignore,
|
|
30
|
+
// _claude/, _mcp.json — because Claude Code treats .claude/ directories
|
|
31
|
+
// and .mcp.json files as protected paths and would block automated edits
|
|
32
|
+
// to the template. Install each under its live name in the target.
|
|
33
|
+
const inertNames = [
|
|
34
|
+
{ from: '_gitignore', to: '.gitignore' },
|
|
35
|
+
{ from: '_claude', to: '.claude' },
|
|
36
|
+
{ from: '_mcp.json', to: '.mcp.json' },
|
|
37
|
+
];
|
|
38
|
+
for (const { from, to } of inertNames) {
|
|
39
|
+
const src = join(directory, from);
|
|
40
|
+
const dest = join(directory, to);
|
|
41
|
+
if (!existsSync(src)) continue;
|
|
42
|
+
if (existsSync(dest) && statSync(src).isDirectory()) {
|
|
43
|
+
// Pre-populated target: merge into the existing directory — template
|
|
44
|
+
// files overwrite same-named ones, extra local files survive — then
|
|
45
|
+
// drop the inert copy.
|
|
46
|
+
cpSync(src, dest, { recursive: true });
|
|
47
|
+
rmSync(src, { recursive: true });
|
|
48
|
+
} else {
|
|
49
|
+
renameSync(src, dest);
|
|
50
|
+
}
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
// Process CLAUDE.md template
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// End-to-end test for scaffold.mjs
|
|
3
|
+
// Run: node lib/scaffold.test.mjs
|
|
4
|
+
//
|
|
5
|
+
// The template stores .gitignore, .claude/, and .mcp.json under the inert
|
|
6
|
+
// names _gitignore, _claude/, and _mcp.json (Claude Code protects the live
|
|
7
|
+
// names from headless edits). scaffold() must install every one under its
|
|
8
|
+
// live name and leave no inert leftovers behind.
|
|
9
|
+
import { scaffold } from './scaffold.mjs';
|
|
10
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { tmpdir } from 'os';
|
|
13
|
+
|
|
14
|
+
let failed = 0;
|
|
15
|
+
let passed = 0;
|
|
16
|
+
function check(label, ok) {
|
|
17
|
+
if (ok) { passed++; } else {
|
|
18
|
+
failed++;
|
|
19
|
+
console.error(` FAIL: ${label}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const directory = mkdtempSync(join(tmpdir(), 'scaffold-test-'));
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await scaffold({
|
|
27
|
+
name: 'demo',
|
|
28
|
+
directory,
|
|
29
|
+
repos: [],
|
|
30
|
+
userName: 'tester',
|
|
31
|
+
activateRules: [],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Live names installed
|
|
35
|
+
check('.claude/ directory installed', existsSync(join(directory, '.claude')));
|
|
36
|
+
check('.claude/settings.json installed', existsSync(join(directory, '.claude', 'settings.json')));
|
|
37
|
+
check('.claude/settings.local.json written', existsSync(join(directory, '.claude', 'settings.local.json')));
|
|
38
|
+
check('.claude/rules/ installed', existsSync(join(directory, '.claude', 'rules')));
|
|
39
|
+
check('.claude/rules/coherent-revisions.md installed', existsSync(join(directory, '.claude', 'rules', 'coherent-revisions.md')));
|
|
40
|
+
check('.mcp.json installed', existsSync(join(directory, '.mcp.json')));
|
|
41
|
+
check('.gitignore installed', existsSync(join(directory, '.gitignore')));
|
|
42
|
+
|
|
43
|
+
// .mcp.json is the template's payload, unmodified
|
|
44
|
+
const mcp = JSON.parse(readFileSync(join(directory, '.mcp.json'), 'utf8'));
|
|
45
|
+
check('.mcp.json parses with mcpServers object', typeof mcp.mcpServers === 'object' && mcp.mcpServers !== null);
|
|
46
|
+
|
|
47
|
+
// Inert names are gone
|
|
48
|
+
check('no _claude/ leftover', !existsSync(join(directory, '_claude')));
|
|
49
|
+
check('no _mcp.json leftover', !existsSync(join(directory, '_mcp.json')));
|
|
50
|
+
check('no _gitignore leftover', !existsSync(join(directory, '_gitignore')));
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(directory, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (failed > 0) {
|
|
56
|
+
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
console.log(`${passed} checks passed`);
|
package/package.json
CHANGED
package/template/CLAUDE.md.tmpl
CHANGED
|
@@ -8,6 +8,7 @@ This is a claude-workspace. All conventions are defined in .claude/rules/.
|
|
|
8
8
|
- Each session is a self-contained folder at `work-sessions/{name}/` containing the worktree, nested project worktrees, and the session tracker
|
|
9
9
|
- From root: only `local-only-*` and `workspace-scratchpad/` are writable
|
|
10
10
|
- Team knowledge lives in `workspace-context/`
|
|
11
|
+
- Run `/rename` to label this chat before `/clear` — keeps the chat findable in your CLI history after the context is wiped
|
|
11
12
|
|
|
12
13
|
## Workspace Config
|
|
13
14
|
@workspace.json
|
|
@@ -25,11 +26,27 @@ This is a claude-workspace. All conventions are defined in .claude/rules/.
|
|
|
25
26
|
- `/braindump [name]` — capture discussion/reasoning
|
|
26
27
|
- `/aside [--quick] <thought>` — capture a drive-by idea without interrupting current work
|
|
27
28
|
- `/pause-work` — suspend work, push, draft PR
|
|
28
|
-
- `/complete-work` — finalize
|
|
29
|
+
- `/complete-work` — finalize the work — rebase, PR, merge, close the issue
|
|
29
30
|
- `/promote` — move personal memory to shared context
|
|
30
|
-
- `/release [version]` —
|
|
31
|
+
- `/release [version]` — cut a versioned release: bump, tag, forge release with generated notes
|
|
31
32
|
- `/sync-work` — push branches without ceremony
|
|
32
33
|
- `/workspace-update` — apply template updates (runs maintenance before/after)
|
|
33
34
|
- `/setup-tracker` — wire this workspace to an issue tracker (GitHub Issues shipped; others pluggable)
|
|
34
35
|
- `/maintenance [audit|cleanup]` — workspace health checks and cleanup
|
|
35
36
|
- `/build-docs-site` — build a comprehensive Docusaurus documentation site for a project
|
|
37
|
+
|
|
38
|
+
## Compact Instructions
|
|
39
|
+
|
|
40
|
+
When auto-compacting context, preserve in the summary:
|
|
41
|
+
- Decisions made in this session and their rationale
|
|
42
|
+
- Constraints and conventions established or confirmed
|
|
43
|
+
- Active task list state and the next concrete step
|
|
44
|
+
- File paths touched and the nature of the change
|
|
45
|
+
- Errors hit and what was tried
|
|
46
|
+
|
|
47
|
+
Drop from the summary:
|
|
48
|
+
- Verbose tool output already reflected in committed code
|
|
49
|
+
- Exploratory reads that did not lead to a change
|
|
50
|
+
- Conversational asides
|
|
51
|
+
|
|
52
|
+
Files in `workspace-context/`, the session tracker (`session.md`) or chat drawer (`workspace-scratchpad/chats/{chat}/`), and any `design-*.md`/`plan-*.md` survive compaction — they're on disk. Re-read them on demand instead of trying to keep their contents in summary.
|
|
@@ -150,7 +150,7 @@ export function createSessionTracker(root, sessionName, fields, body) {
|
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
152
|
* Delete the entire work-sessions/{name}/ folder. Used by /complete-work
|
|
153
|
-
* after the session is finalized and
|
|
153
|
+
* after the session is finalized and its artifacts promoted or discarded.
|
|
154
154
|
* Caller is responsible for any git bookkeeping (branch deletes, prunes).
|
|
155
155
|
*/
|
|
156
156
|
export function deleteSessionFolder(root, sessionName) {
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook — enforce workspace root write restrictions and detect
|
|
3
|
+
// out-of-session repo writes.
|
|
4
|
+
//
|
|
5
|
+
// New layout paths:
|
|
6
|
+
// Workspace worktree: work-sessions/{name}/workspace/
|
|
7
|
+
// Project worktree: work-sessions/{name}/workspace/repos/{repo}/
|
|
8
|
+
// Bare clone: repos/{repo}/ (at workspace root)
|
|
9
|
+
// Task worktree: repos/{repo}/.claude/worktrees/{slug}/ (gh:132)
|
|
10
|
+
// Workspace task worktree: {root}/.claude/worktrees/{slug}/ (gh:146)
|
|
11
|
+
import { join, basename, resolve, relative, sep, isAbsolute, dirname } from 'path';
|
|
12
|
+
import { realpathSync } from 'fs';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import {
|
|
15
|
+
getWorkspaceRoot,
|
|
16
|
+
readStdin,
|
|
17
|
+
respond,
|
|
18
|
+
getActiveSessionPointer,
|
|
19
|
+
readSessionTracker,
|
|
20
|
+
readJSON,
|
|
21
|
+
getWorkspacePaths,
|
|
22
|
+
} from './_utils.mjs';
|
|
23
|
+
|
|
24
|
+
// .native resolves Windows 8.3 short names; the plain fallback covers
|
|
25
|
+
// filesystems where the native binding is unavailable.
|
|
26
|
+
function realPath(p) {
|
|
27
|
+
try { return realpathSync.native(p); } catch { /* fall through */ }
|
|
28
|
+
try { return realpathSync(p); } catch { /* fall through */ }
|
|
29
|
+
return resolve(p);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Realpaths BOTH sides: Node resolves the module URL through symlinks
|
|
33
|
+
// (import.meta.url is the real path) while argv[1] stays exactly as the
|
|
34
|
+
// host passed it — a bare comparison silently disables the hook whenever
|
|
35
|
+
// the hook is reached through a symlinked workspace.
|
|
36
|
+
function isMainModule(metaUrl) {
|
|
37
|
+
if (!process.argv[1]) return false;
|
|
38
|
+
try {
|
|
39
|
+
return realPath(fileURLToPath(metaUrl)) === realPath(process.argv[1]);
|
|
40
|
+
} catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// realpath the deepest existing ancestor and reattach the missing tail:
|
|
44
|
+
// the file being written may not exist yet, but the worktree directory it
|
|
45
|
+
// lands in does. When no ancestor exists at all, no symlink can be in
|
|
46
|
+
// play, so the resolved input is returned as-is.
|
|
47
|
+
function realPathDeepest(p) {
|
|
48
|
+
let cur = p;
|
|
49
|
+
const tail = [];
|
|
50
|
+
for (;;) {
|
|
51
|
+
try { return join(realpathSync.native(cur), ...tail); } catch { /* climb */ }
|
|
52
|
+
const parent = dirname(cur);
|
|
53
|
+
if (parent === cur) return resolve(p);
|
|
54
|
+
tail.unshift(basename(cur));
|
|
55
|
+
cur = parent;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// relative() output is "inside" when it is a plain descent — not '', '..',
|
|
60
|
+
// a '..'-prefixed climb, or a cross-volume absolute path (Windows drives).
|
|
61
|
+
function isDescent(rel) {
|
|
62
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True when filePath sits inside repos/{repo}/.claude/worktrees/{slug}/ —
|
|
67
|
+
* the task model's project work area (gh:132) — or inside
|
|
68
|
+
* {root}/.claude/worktrees/{slug}/, the workspace repo's own task
|
|
69
|
+
* worktrees (gh:146). Task chats run at the workspace root and edit there
|
|
70
|
+
* by design, so these writes are legitimate and must not trip the "you're
|
|
71
|
+
* on main" repo/template warnings. The worktrees directory itself and any
|
|
72
|
+
* other {root}/.claude/... path stay warnable.
|
|
73
|
+
*
|
|
74
|
+
* Pure path arithmetic (realpath + relative + segment split), so it is
|
|
75
|
+
* sep-safe on Windows, symlink-safe on macOS, and never stats the file
|
|
76
|
+
* itself.
|
|
77
|
+
*/
|
|
78
|
+
export function isTaskWorktreeWrite(root, filePath) {
|
|
79
|
+
if (!root || !filePath) return false;
|
|
80
|
+
const rootReal = realPath(resolve(root));
|
|
81
|
+
const fileReal = realPathDeepest(resolve(filePath));
|
|
82
|
+
|
|
83
|
+
// The workspace repo's own worktrees: {root}/.claude/worktrees/{slug}/…
|
|
84
|
+
const relRoot = relative(rootReal, fileReal);
|
|
85
|
+
if (isDescent(relRoot)) {
|
|
86
|
+
const rootParts = relRoot.split(sep);
|
|
87
|
+
if (rootParts.length >= 3 && rootParts[0] === '.claude' && rootParts[1] === 'worktrees') return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Project worktrees: repos/{repo}/.claude/worktrees/{slug}/…
|
|
91
|
+
const rel = relative(realPath(join(rootReal, 'repos')), fileReal);
|
|
92
|
+
if (!isDescent(rel)) return false;
|
|
93
|
+
const parts = rel.split(sep);
|
|
94
|
+
return parts.length >= 4 && parts[1] === '.claude' && parts[2] === 'worktrees';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
const root = getWorkspaceRoot(import.meta.url);
|
|
99
|
+
const input = await readStdin();
|
|
100
|
+
const toolName = input.tool_name || '';
|
|
101
|
+
|
|
102
|
+
if (!['Bash', 'Edit', 'Write'].includes(toolName)) {
|
|
103
|
+
respond();
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const toolInput = input.tool_input || {};
|
|
108
|
+
const paths = [toolInput.file_path, toolInput.command, toolInput.path]
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.join(' ')
|
|
111
|
+
.replace(/\\/g, '/');
|
|
112
|
+
|
|
113
|
+
// If we're in a workspace worktree, check for out-of-session repo writes
|
|
114
|
+
const pointer = getActiveSessionPointer(root);
|
|
115
|
+
if (pointer) {
|
|
116
|
+
const mainRoot = pointer.rootPath || root;
|
|
117
|
+
const config = readJSON(join(mainRoot, 'workspace.json'));
|
|
118
|
+
const tracker = readSessionTracker(mainRoot, pointer.name);
|
|
119
|
+
|
|
120
|
+
if (tracker && config?.repos) {
|
|
121
|
+
// Find references to repos inside work-sessions/{name}/workspace/repos/{repo}/
|
|
122
|
+
// and also the workspace-root repos/{repo}/ for direct writes.
|
|
123
|
+
const wtMatch = paths.match(/work-sessions\/[^/\s]+\/workspace\/repos\/([^/\s]+)/);
|
|
124
|
+
const cloneMatch = paths.match(/(?:^|\s|\/)repos\/([^/\s]+)/);
|
|
125
|
+
const targetRepo = wtMatch ? wtMatch[1] : (cloneMatch ? cloneMatch[1] : null);
|
|
126
|
+
if (targetRepo) {
|
|
127
|
+
const sessionRepos = tracker.repos || [];
|
|
128
|
+
if (config.repos[targetRepo] && !sessionRepos.includes(targetRepo)) {
|
|
129
|
+
respond(`You're about to write to ${targetRepo}, which isn't part of this session. Consider adding it first so changes land on the session branch.`);
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
respond();
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// We're at the main workspace root — restrict writes
|
|
140
|
+
|
|
141
|
+
const { scratchpadDir } = getWorkspacePaths(root);
|
|
142
|
+
const scratchpadName = scratchpadDir.slice(root.length + 1); // "workspace-scratchpad"
|
|
143
|
+
|
|
144
|
+
// Allow writes to the workspace scratchpad
|
|
145
|
+
if (paths.includes(scratchpadName)) {
|
|
146
|
+
respond();
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Allow writes to local-only-* files
|
|
151
|
+
const filePathArg = toolInput.file_path || '';
|
|
152
|
+
if (basename(filePathArg).startsWith('local-only-')) {
|
|
153
|
+
respond();
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Task-model writes: repos/{repo}/.claude/worktrees/{slug}/ is the task
|
|
158
|
+
// model's work area at the root (gh:132). Those tokens are stripped from
|
|
159
|
+
// what the warning checks judge — an Edit/Write inside a worktree leaves
|
|
160
|
+
// nothing to judge and is exempt, while a Bash command that ALSO touches
|
|
161
|
+
// the source clone or template files is judged on, and warned about,
|
|
162
|
+
// everything else it names.
|
|
163
|
+
const taskTokens = toolName === 'Bash'
|
|
164
|
+
? String(toolInput.command || '').split(/\s+/).map((t) => t.replace(/^["']+|["']+$/g, ''))
|
|
165
|
+
: [toolInput.file_path, toolInput.path].filter(Boolean);
|
|
166
|
+
const judged = taskTokens
|
|
167
|
+
.filter((t) => !isTaskWorktreeWrite(root, t))
|
|
168
|
+
.join(' ')
|
|
169
|
+
.replace(/\\/g, '/');
|
|
170
|
+
|
|
171
|
+
// For Bash commands, check if the command targets allowed paths
|
|
172
|
+
if (toolName === 'Bash') {
|
|
173
|
+
const cmd = toolInput.command || '';
|
|
174
|
+
if (/^\s*(git|ls|cat|head|tail|grep|rg|find|echo|pwd|cd|which|node\s+-c)\b/.test(cmd)) {
|
|
175
|
+
respond();
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
if (cmd.includes(scratchpadName) || cmd.includes('local-only-')) {
|
|
179
|
+
respond();
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
// Allow helper script invocations from the workspace root
|
|
183
|
+
if (/node\s+.*\.claude\/scripts\//.test(cmd)) {
|
|
184
|
+
respond();
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check if this write targets repos/, workspace-context/, work-sessions/, or template files
|
|
190
|
+
const isRepoWrite = /(?:^|[\s/])repos\//.test(judged) || judged.includes('work-sessions/');
|
|
191
|
+
const isContextWrite = judged.includes('workspace-context/') && !basename(filePathArg).startsWith('local-only-');
|
|
192
|
+
const isTemplateWrite = judged.includes('.claude/') && !judged.includes(scratchpadName);
|
|
193
|
+
|
|
194
|
+
if (isRepoWrite || isContextWrite || isTemplateWrite) {
|
|
195
|
+
respond("You're on main. All work should happen in a workspace worktree. Run /start-work to create or resume a work session.");
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
respond();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (isMainModule(import.meta.url)) {
|
|
203
|
+
await main();
|
|
204
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
sessionFolderPath,
|
|
17
17
|
timeAgo,
|
|
18
18
|
} from './_utils.mjs';
|
|
19
|
+
import { reconcile, readSessionRegistry, resolveChatName } from '../scripts/chat-record.mjs';
|
|
19
20
|
|
|
20
21
|
const root = getWorkspaceRoot(import.meta.url);
|
|
21
22
|
const input = await readStdin();
|
|
@@ -34,6 +35,40 @@ if (!config) {
|
|
|
34
35
|
|
|
35
36
|
lines.push(`Workspace: ${config.workspace?.name || 'unnamed'}`);
|
|
36
37
|
|
|
38
|
+
// Keep this chat's record in step with its identity (gh:132). The record is
|
|
39
|
+
// keyed on sessionId and filed under the chat name, so a rename moves the file
|
|
40
|
+
// and its drawer rather than orphaning them.
|
|
41
|
+
//
|
|
42
|
+
// No live set is passed, deliberately: the registry lists running processes,
|
|
43
|
+
// not chats that exist, so a chat the operator merely closed is
|
|
44
|
+
// indistinguishable from one that is gone. Pruning here would delete the scope
|
|
45
|
+
// and open tasks of every chat not currently open.
|
|
46
|
+
if (chatId) {
|
|
47
|
+
try {
|
|
48
|
+
const registry = readSessionRegistry();
|
|
49
|
+
const me = registry.find((r) => r.sessionId === chatId);
|
|
50
|
+
// The registry only knows names for chats it has seen. An unnamed chat
|
|
51
|
+
// keeps whatever name its record already carries rather than being
|
|
52
|
+
// relabeled with its raw UUID; only a truly new chat falls back to the id.
|
|
53
|
+
const name = resolveChatName(root, { sessionId: chatId, registryName: me?.name });
|
|
54
|
+
const res = reconcile(root, { sessionId: chatId, name });
|
|
55
|
+
if (res.renamed) {
|
|
56
|
+
lines.push(`Chat renamed: ${res.renamed.from} -> ${res.renamed.to} (record and drawer moved)`);
|
|
57
|
+
}
|
|
58
|
+
// Name the record so a skill can find its own chat's state without
|
|
59
|
+
// guessing (gh:132) — /start-work's task flow keys off this line.
|
|
60
|
+
lines.push(`Chat record: ${name}`);
|
|
61
|
+
} catch {
|
|
62
|
+
// The chat record is a convenience, not a precondition for a session.
|
|
63
|
+
// A failure here must never stop Claude from starting.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Name the workspace root so skills can address it without deriving it
|
|
67
|
+
// from git internals — which resolve to the source clone, not the
|
|
68
|
+
// launcher, when a chat runs from inside a task worktree (gh:132).
|
|
69
|
+
lines.push(`Workspace root: ${root}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
// If we're inside a workspace worktree, its .claude/.active-session.json
|
|
38
73
|
// tells us which session this is.
|
|
39
74
|
const pointer = getActiveSessionPointer(root);
|
|
@@ -117,7 +152,6 @@ if (trackers.length > 0) {
|
|
|
117
152
|
// Surface team-shared workspace context (secondary)
|
|
118
153
|
// Only scan shared/ — locked/ is now a sub-dir of shared/ and is included
|
|
119
154
|
// naturally. team-member/ is per-user (loaded via CLAUDE.local.md).
|
|
120
|
-
// release-notes/ is operational, not knowledge.
|
|
121
155
|
if (existsSync(sharedDir)) {
|
|
122
156
|
const entries = [];
|
|
123
157
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SubagentStart hook — give subagents the workspace's canonical truths.
|
|
3
|
+
//
|
|
4
|
+
// Subagents do not load CLAUDE.md, so without this they start with no team context.
|
|
5
|
+
// They do, however, get a full model context window and their own file tools, so the
|
|
6
|
+
// right shape is not "paste everything" — it is: inline the short constraints that
|
|
7
|
+
// should frame every task, and hand over a pointer for the long reference material.
|
|
8
|
+
// That follows the just-in-time guidance and keeps the injection stable as canon grows.
|
|
9
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
|
|
10
|
+
import { join, basename, relative, sep } from 'path';
|
|
11
|
+
import { getWorkspaceRoot, readJSON, respond } from './_utils.mjs';
|
|
12
|
+
import {
|
|
13
|
+
readDescription,
|
|
14
|
+
gitIgnoredPaths,
|
|
15
|
+
stripFrontmatter,
|
|
16
|
+
isLocalOnlyName,
|
|
17
|
+
} from '../scripts/build-workspace-context.mjs';
|
|
18
|
+
|
|
19
|
+
const root = getWorkspaceRoot(import.meta.url);
|
|
20
|
+
const config = readJSON(join(root, 'workspace.json'));
|
|
21
|
+
const lockedRel = 'workspace-context/shared/locked';
|
|
22
|
+
const lockedDir = join(root, 'workspace-context', 'shared', 'locked');
|
|
23
|
+
|
|
24
|
+
// Per-file ceiling for inlining. Files above it become pointers regardless of headroom,
|
|
25
|
+
// so one long document cannot crowd out every short constraint.
|
|
26
|
+
const inlineMax = config?.workspace?.subagentInlineMaxBytes || 8192;
|
|
27
|
+
// Total ceiling as a backstop. Overflow demotes the largest inlined files to pointers.
|
|
28
|
+
const totalMax = config?.workspace?.subagentContextMaxBytes || 32768;
|
|
29
|
+
|
|
30
|
+
if (!existsSync(lockedDir)) {
|
|
31
|
+
respond();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let names = [];
|
|
36
|
+
try {
|
|
37
|
+
names = readdirSync(lockedDir).filter((f) => f.endsWith('.md') && f !== '.keep').sort();
|
|
38
|
+
} catch {
|
|
39
|
+
respond();
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// canonical.md excludes gitignored files; this path must match it. Without the filter a
|
|
44
|
+
// local-only-*.md dropped into shared/locked/ is broadcast to every subagent.
|
|
45
|
+
const relPaths = names.map((n) => relative(root, join(lockedDir, n)).split(sep).join('/'));
|
|
46
|
+
let ignored = new Set();
|
|
47
|
+
try {
|
|
48
|
+
ignored = gitIgnoredPaths(root, relPaths);
|
|
49
|
+
} catch {
|
|
50
|
+
/* filter unavailable — fall through with nothing ignored */
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const entries = [];
|
|
54
|
+
for (let i = 0; i < names.length; i++) {
|
|
55
|
+
// gitIgnoredPaths fails open when git is unavailable; local-only-* is excluded by
|
|
56
|
+
// name as well so a non-git workspace cannot broadcast a private file to subagents.
|
|
57
|
+
if (ignored.has(relPaths[i]) || isLocalOnlyName(names[i])) continue;
|
|
58
|
+
const file = join(lockedDir, names[i]);
|
|
59
|
+
let content = '';
|
|
60
|
+
let size = 0;
|
|
61
|
+
try {
|
|
62
|
+
content = readFileSync(file, 'utf-8');
|
|
63
|
+
size = statSync(file).size;
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
entries.push({
|
|
68
|
+
name: basename(names[i], '.md'),
|
|
69
|
+
path: `${lockedRel}/${names[i]}`,
|
|
70
|
+
description: readDescription(file),
|
|
71
|
+
content,
|
|
72
|
+
size,
|
|
73
|
+
inline: size <= inlineMax,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (entries.length === 0) {
|
|
78
|
+
respond();
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const render = () => {
|
|
83
|
+
const inlined = entries.filter((e) => e.inline);
|
|
84
|
+
const pointers = entries.filter((e) => !e.inline);
|
|
85
|
+
const parts = [];
|
|
86
|
+
if (inlined.length > 0) {
|
|
87
|
+
parts.push('Canonical workspace context (team truths that apply to every task):');
|
|
88
|
+
for (const e of inlined) parts.push(`\n--- ${e.name} (${e.path}) ---\n${stripFrontmatter(e.content).trim()}`);
|
|
89
|
+
}
|
|
90
|
+
if (pointers.length > 0) {
|
|
91
|
+
parts.push(
|
|
92
|
+
'\nAlso canonical, not inlined here — read the file if your task touches it:',
|
|
93
|
+
...pointers.map((e) => `- ${e.name} — ${e.description} [${e.path}, ${e.size} bytes]`),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return parts.join('\n');
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Demote largest-first until the total fits. Short constraints survive; long reference
|
|
100
|
+
// material degrades to a pointer, which is what it should have been anyway.
|
|
101
|
+
let out = render();
|
|
102
|
+
while (
|
|
103
|
+
Buffer.byteLength(out) > totalMax &&
|
|
104
|
+
entries.some((e) => e.inline)
|
|
105
|
+
) {
|
|
106
|
+
const biggest = entries.filter((e) => e.inline).sort((a, b) => b.size - a.size)[0];
|
|
107
|
+
biggest.inline = false;
|
|
108
|
+
out = render();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
respond(out);
|