@ulysses-ai/create-workspace 0.13.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/bin/create.mjs +79 -0
  4. package/lib/git.mjs +26 -0
  5. package/lib/init.mjs +129 -0
  6. package/lib/payload.mjs +44 -0
  7. package/lib/prompts.mjs +113 -0
  8. package/lib/scaffold.mjs +84 -0
  9. package/lib/upgrade.mjs +42 -0
  10. package/package.json +43 -0
  11. package/template/.claude/agents/aside-researcher.md +48 -0
  12. package/template/.claude/agents/implementer.md +39 -0
  13. package/template/.claude/agents/researcher.md +40 -0
  14. package/template/.claude/agents/reviewer.md +47 -0
  15. package/template/.claude/hooks/_utils.mjs +196 -0
  16. package/template/.claude/hooks/_utils.test.mjs +99 -0
  17. package/template/.claude/hooks/post-compact.mjs +7 -0
  18. package/template/.claude/hooks/pre-compact.mjs +34 -0
  19. package/template/.claude/hooks/repo-write-detection.mjs +107 -0
  20. package/template/.claude/hooks/session-end.mjs +91 -0
  21. package/template/.claude/hooks/session-start.mjs +150 -0
  22. package/template/.claude/hooks/subagent-start.mjs +44 -0
  23. package/template/.claude/hooks/workspace-update-check.mjs +42 -0
  24. package/template/.claude/hooks/worktree-create.mjs +53 -0
  25. package/template/.claude/lib/session-frontmatter.mjs +265 -0
  26. package/template/.claude/lib/session-frontmatter.test.mjs +242 -0
  27. package/template/.claude/recipes/migrate-from-notion.md +120 -0
  28. package/template/.claude/rules/agent-rules.md.skip +32 -0
  29. package/template/.claude/rules/cloud-infrastructure.md.skip +15 -0
  30. package/template/.claude/rules/coherent-revisions.md +24 -0
  31. package/template/.claude/rules/documentation.md.skip +13 -0
  32. package/template/.claude/rules/git-conventions.md +34 -0
  33. package/template/.claude/rules/honest-pushback.md +56 -0
  34. package/template/.claude/rules/local-dev-environment.md.skip +60 -0
  35. package/template/.claude/rules/memory-guidance.md +26 -0
  36. package/template/.claude/rules/product-integrity.md.skip +24 -0
  37. package/template/.claude/rules/scope-guard.md.skip +22 -0
  38. package/template/.claude/rules/superpowers-workflow.md.skip +22 -0
  39. package/template/.claude/rules/token-economics.md.skip +31 -0
  40. package/template/.claude/rules/work-item-tracking.md +90 -0
  41. package/template/.claude/rules/workspace-structure.md +69 -0
  42. package/template/.claude/scripts/add-repo-to-session.mjs +78 -0
  43. package/template/.claude/scripts/cleanup-work-session.mjs +108 -0
  44. package/template/.claude/scripts/create-work-session.mjs +124 -0
  45. package/template/.claude/scripts/migrate-open-work.mjs +91 -0
  46. package/template/.claude/scripts/migrate-session-layout.mjs +236 -0
  47. package/template/.claude/scripts/migrate-session-layout.test.mjs +144 -0
  48. package/template/.claude/scripts/trackers/github-issues.mjs +170 -0
  49. package/template/.claude/scripts/trackers/github-issues.test.mjs +190 -0
  50. package/template/.claude/scripts/trackers/interface.mjs +25 -0
  51. package/template/.claude/scripts/trackers/interface.test.mjs +40 -0
  52. package/template/.claude/settings.json +107 -0
  53. package/template/.claude/skills/aside/SKILL.md +125 -0
  54. package/template/.claude/skills/braindump/SKILL.md +96 -0
  55. package/template/.claude/skills/build-docs-site/SKILL.md +323 -0
  56. package/template/.claude/skills/build-docs-site/checklists/framing.md +221 -0
  57. package/template/.claude/skills/build-docs-site/checklists/pitfalls.md +228 -0
  58. package/template/.claude/skills/build-docs-site/checklists/review.md +130 -0
  59. package/template/.claude/skills/build-docs-site/scripts/bulk-fill-migration.py +393 -0
  60. package/template/.claude/skills/build-docs-site/scripts/forbidden-word-grep.mjs +159 -0
  61. package/template/.claude/skills/build-docs-site/scripts/leak-grep.mjs +328 -0
  62. package/template/.claude/skills/build-docs-site/templates/custom.css.tmpl +212 -0
  63. package/template/.claude/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +95 -0
  64. package/template/.claude/skills/build-docs-site/templates/primitives/Arrow.tsx +87 -0
  65. package/template/.claude/skills/build-docs-site/templates/primitives/Box.tsx +90 -0
  66. package/template/.claude/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +46 -0
  67. package/template/.claude/skills/build-docs-site/templates/primitives/Region.tsx +68 -0
  68. package/template/.claude/skills/build-docs-site/templates/primitives/SectionTitle.tsx +42 -0
  69. package/template/.claude/skills/build-docs-site/templates/primitives/tokens.ts +67 -0
  70. package/template/.claude/skills/build-docs-site/templates/sidebars.ts.tmpl +89 -0
  71. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +119 -0
  72. package/template/.claude/skills/complete-work/SKILL.md +369 -0
  73. package/template/.claude/skills/handoff/SKILL.md +98 -0
  74. package/template/.claude/skills/maintenance/SKILL.md +116 -0
  75. package/template/.claude/skills/pause-work/SKILL.md +98 -0
  76. package/template/.claude/skills/promote/SKILL.md +77 -0
  77. package/template/.claude/skills/release/SKILL.md +126 -0
  78. package/template/.claude/skills/setup-tracker/SKILL.md +117 -0
  79. package/template/.claude/skills/start-work/SKILL.md +234 -0
  80. package/template/.claude/skills/sync-work/SKILL.md +73 -0
  81. package/template/.claude/skills/workspace-init/SKILL.md +420 -0
  82. package/template/.claude/skills/workspace-update/SKILL.md +108 -0
  83. package/template/.mcp.json +12 -0
  84. package/template/CLAUDE.md.tmpl +32 -0
  85. package/template/_gitignore +28 -0
  86. package/template/workspace.json.tmpl +15 -0
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
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';
5
+ import { join } from 'path';
6
+ import { execSync } from 'child_process';
7
+ import {
8
+ getWorkspaceRoot,
9
+ readStdin,
10
+ readJSON,
11
+ respond,
12
+ getActiveSessionPointer,
13
+ getMainRoot,
14
+ readSessionTracker,
15
+ updateSessionTracker,
16
+ sessionFilePath,
17
+ sessionWorktreePath,
18
+ getWorkspacePaths,
19
+ } from './_utils.mjs';
20
+
21
+ const root = getWorkspaceRoot(import.meta.url);
22
+ const mainRoot = getMainRoot(root);
23
+ const { scratchpadDir } = getWorkspacePaths(mainRoot);
24
+ const logFile = join(scratchpadDir, 'session-log.jsonl');
25
+ const settings = readJSON(join(root, '.claude', 'settings.local.json'));
26
+
27
+ const input = await readStdin();
28
+ const reason = input.reason || 'unknown';
29
+ const sessionId = input.session_id || null;
30
+ const user = settings?.workspace?.user || 'unknown';
31
+
32
+ let branch = 'unknown';
33
+ try {
34
+ branch = execSync('git branch --show-current', { cwd: root, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
35
+ } catch {}
36
+
37
+ // Update session tracker: mark this chat as ended, append safety note
38
+ const pointer = getActiveSessionPointer(root);
39
+ if (pointer && sessionId) {
40
+ const tracker = readSessionTracker(mainRoot, pointer.name);
41
+ if (tracker) {
42
+ const chats = tracker.chatSessions || [];
43
+ const chat = chats.find(c => c.id === sessionId && c.ended === null);
44
+ if (chat) {
45
+ chat.ended = new Date().toISOString();
46
+ updateSessionTracker(mainRoot, pointer.name, {
47
+ chatSessions: chats,
48
+ updated: new Date().toISOString().slice(0, 10),
49
+ });
50
+ }
51
+
52
+ // Append a safety-net note to the session.md body so the next chat
53
+ // can see that a previous chat ended without capturing explicitly.
54
+ // The tracker lives inside the session worktree on the session branch,
55
+ // so the auto-commit must run from inside the worktree.
56
+ const trackerPath = sessionFilePath(mainRoot, pointer.name);
57
+ if (existsSync(trackerPath)) {
58
+ const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
59
+ const safetyEntry = `\n### Session ended (${timestamp})\n\nReason: ${reason}. Chat session ${sessionId || 'unknown'}.\n`;
60
+ appendFileSync(trackerPath, safetyEntry);
61
+
62
+ // Auto-commit from inside the worktree. Best-effort — non-fatal if
63
+ // the commit fails (hook blocked, nothing to commit, etc.).
64
+ try {
65
+ const worktreeCwd = sessionWorktreePath(mainRoot, pointer.name);
66
+ execSync('git add session.md', { cwd: worktreeCwd, stdio: 'pipe' });
67
+ execSync(
68
+ `git commit -m "chore: session-end safety capture for ${pointer.name}"`,
69
+ { cwd: worktreeCwd, stdio: 'pipe' }
70
+ );
71
+ } catch {
72
+ // Non-fatal
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // Append to the workspace session log (disposable)
79
+ if (!existsSync(scratchpadDir)) mkdirSync(scratchpadDir, { recursive: true });
80
+ const entry = JSON.stringify({
81
+ event: 'session_end',
82
+ date: new Date().toISOString(),
83
+ user,
84
+ reason,
85
+ session_id: sessionId,
86
+ workspace_branch: branch,
87
+ work_session: pointer?.name || null,
88
+ });
89
+ appendFileSync(logFile, entry + '\n');
90
+
91
+ respond();
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook — register this chat in the active session tracker
3
+ // and surface open sessions + shared context.
4
+ import { readdirSync, statSync, readFileSync, existsSync } from 'fs';
5
+ import { join, basename, relative } from 'path';
6
+ import { execSync } from 'child_process';
7
+ import {
8
+ getWorkspaceRoot,
9
+ readJSON,
10
+ readStdin,
11
+ respond,
12
+ getSessionTrackers,
13
+ getActiveSessionPointer,
14
+ readSessionTracker,
15
+ updateSessionTracker,
16
+ sessionFolderPath,
17
+ timeAgo,
18
+ } from './_utils.mjs';
19
+
20
+ const root = getWorkspaceRoot(import.meta.url);
21
+ const input = await readStdin();
22
+ const config = readJSON(join(root, 'workspace.json'));
23
+
24
+ const chatId = input.session_id || null;
25
+ const contextDir = join(root, 'shared-context');
26
+ const reposDir = join(root, 'repos');
27
+ const lines = [];
28
+
29
+ if (!config) {
30
+ respond('No workspace.json found. Run /workspace-init to initialize this workspace.');
31
+ process.exit(0);
32
+ }
33
+
34
+ lines.push(`Workspace: ${config.workspace?.name || 'unnamed'}`);
35
+
36
+ // If we're inside a workspace worktree, its .claude/.active-session.json
37
+ // tells us which session this is.
38
+ const pointer = getActiveSessionPointer(root);
39
+ if (pointer) {
40
+ if (chatId) {
41
+ const mainRoot = pointer.rootPath || root;
42
+ const tracker = readSessionTracker(mainRoot, pointer.name);
43
+ if (tracker) {
44
+ const chats = tracker.chatSessions || [];
45
+ if (!chats.some(c => c.id === chatId)) {
46
+ chats.push({
47
+ id: chatId,
48
+ names: [],
49
+ started: new Date().toISOString(),
50
+ ended: null,
51
+ });
52
+ updateSessionTracker(mainRoot, pointer.name, {
53
+ chatSessions: chats,
54
+ updated: new Date().toISOString().slice(0, 10),
55
+ });
56
+ }
57
+ }
58
+ }
59
+ lines.push(`Active work session: ${pointer.name}`);
60
+ lines.push(`Working in workspace worktree. Main root: ${pointer.rootPath}`);
61
+ respond(lines.join('\n'));
62
+ process.exit(0);
63
+ }
64
+
65
+ // We're at the main workspace root — surface open sessions and context.
66
+
67
+ // Fetch project repos in the background (best effort)
68
+ const repoNames = Object.keys(config.repos || {});
69
+ const missing = [];
70
+ const existing = [];
71
+ for (const name of repoNames) {
72
+ const repoPath = join(reposDir, name);
73
+ if (existsSync(repoPath)) {
74
+ existing.push(name);
75
+ try {
76
+ execSync('git fetch --quiet', { cwd: repoPath, stdio: 'pipe', timeout: 10000 });
77
+ } catch {}
78
+ } else {
79
+ missing.push(name);
80
+ }
81
+ }
82
+ if (missing.length > 0) lines.push(`Missing repos: ${missing.join(', ')}. Run /workspace-init to clone them.`);
83
+ if (existing.length > 0) lines.push(`Repos synced: ${existing.join(', ')}`);
84
+
85
+ // Surface active work sessions
86
+ const trackers = getSessionTrackers(root);
87
+ if (trackers.length > 0) {
88
+ lines.push('');
89
+ lines.push('Active work sessions:');
90
+ for (let i = 0; i < trackers.length; i++) {
91
+ const t = trackers[i];
92
+ const wsWorktree = join(sessionFolderPath(root, t.name), 'workspace');
93
+ const worktreeExists = existsSync(wsWorktree);
94
+ if (!worktreeExists) {
95
+ lines.push(` ${i + 1}. ${t.name} (orphaned — worktree missing)`);
96
+ continue;
97
+ }
98
+ const chats = t.chatSessions || [];
99
+ const lastChat = chats[chats.length - 1];
100
+ const lastEnded = lastChat?.ended;
101
+ const statusDetail = t.status === 'paused'
102
+ ? `paused ${timeAgo(lastEnded)}`
103
+ : lastEnded
104
+ ? `active, last chat ended ${timeAgo(lastEnded)}`
105
+ : 'active';
106
+ const repoList = Array.isArray(t.repos) ? t.repos.join(', ') : (t.repos || '—');
107
+ lines.push(` ${i + 1}. ${t.name} (${statusDetail})`);
108
+ lines.push(` "${t.description || ''}"`);
109
+ lines.push(` Branch: ${t.branch} | Repos: ${repoList}`);
110
+ lines.push(` Worktree: work-sessions/${t.name}/workspace/`);
111
+ lines.push('');
112
+ }
113
+ lines.push('Use /start-work to resume a session or start new work.');
114
+ }
115
+
116
+ // Surface shared context (secondary)
117
+ if (existsSync(contextDir)) {
118
+ const entries = [];
119
+
120
+ function scanDir(dir, depth = 0) {
121
+ if (depth > 3) return;
122
+ for (const entry of readdirSync(dir)) {
123
+ const fullPath = join(dir, entry);
124
+ const stat = statSync(fullPath);
125
+ if (stat.isDirectory()) {
126
+ if (entry === 'locked' || entry === '.keep') continue;
127
+ scanDir(fullPath, depth + 1);
128
+ } else if (entry.endsWith('.md') && entry !== '.keep' && !entry.startsWith('local-only-')) {
129
+ const relPath = relative(contextDir, fullPath);
130
+ if (relPath.startsWith('locked/')) continue;
131
+ const content = readFileSync(fullPath, 'utf-8');
132
+ const topicMatch = content.match(/^topic:\s*(.+)$/m);
133
+ const lifecycleMatch = content.match(/^lifecycle:\s*(.+)$/m);
134
+ const topic = topicMatch ? topicMatch[1].trim() : basename(entry, '.md');
135
+ const lifecycle = lifecycleMatch ? lifecycleMatch[1].trim() : 'active';
136
+ const mtime = stat.mtime.toISOString().slice(0, 16).replace('T', ' ');
137
+ entries.push(`- ${topic} (${lifecycle}, updated ${mtime}) — ${relPath}`);
138
+ }
139
+ }
140
+ }
141
+
142
+ scanDir(contextDir);
143
+ if (entries.length > 0) {
144
+ lines.push('');
145
+ lines.push('Shared context:');
146
+ lines.push(...entries);
147
+ }
148
+ }
149
+
150
+ respond(lines.join('\n'));
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // SubagentStart hook — inject shared-context/locked/ into subagent context
3
+ import { readdirSync, readFileSync, existsSync } from 'fs';
4
+ import { join, basename } from 'path';
5
+ import { getWorkspaceRoot, readJSON, respond } from './_utils.mjs';
6
+
7
+ const root = getWorkspaceRoot(import.meta.url);
8
+ const config = readJSON(join(root, 'workspace.json'));
9
+ const lockedDir = join(root, 'shared-context', 'locked');
10
+
11
+ const maxBytes = config?.workspace?.subagentContextMaxBytes || 10240;
12
+
13
+ if (!existsSync(lockedDir)) {
14
+ respond();
15
+ process.exit(0);
16
+ }
17
+
18
+ const files = readdirSync(lockedDir)
19
+ .filter(f => f.endsWith('.md') && f !== '.keep')
20
+ .sort();
21
+
22
+ if (files.length === 0) {
23
+ respond();
24
+ process.exit(0);
25
+ }
26
+
27
+ let context = '';
28
+ for (const file of files) {
29
+ const name = basename(file, '.md');
30
+ const content = readFileSync(join(lockedDir, file), 'utf-8');
31
+ context += `\n--- ${name} ---\n${content}\n`;
32
+ }
33
+
34
+ if (Buffer.byteLength(context) > maxBytes) {
35
+ const summary = files.map(f => {
36
+ const content = readFileSync(join(lockedDir, f), 'utf-8');
37
+ const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('---'))?.replace(/^#*\s*/, '') || '';
38
+ return `- ${basename(f, '.md')}: ${firstLine}`;
39
+ }).join('\n');
40
+
41
+ context = `[Locked shared context exceeds ${maxBytes} byte limit (${Buffer.byteLength(context)} bytes). Summary of ${files.length} files:]\n${summary}\n[Read individual files from shared-context/locked/ if you need full content.]`;
42
+ }
43
+
44
+ respond(context);
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart + PreToolUse hook — detect pending workspace update payload
3
+ import { existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { getWorkspaceRoot, readJSON, respond } from './_utils.mjs';
6
+
7
+ const root = getWorkspaceRoot(import.meta.url);
8
+ const payloadDir = join(root, '.workspace-update');
9
+
10
+ // Fast bail if no payload exists
11
+ if (!existsSync(payloadDir)) {
12
+ respond();
13
+ process.exit(0);
14
+ }
15
+
16
+ const manifest = readJSON(join(payloadDir, '.manifest.json'));
17
+ const config = readJSON(join(root, 'workspace.json'));
18
+ const initialized = config?.workspace?.initialized === true || !!config?.workspace?.templateVersion;
19
+ const action = manifest?.action || (initialized ? 'upgrade' : 'init');
20
+ const version = manifest?.templateVersion || 'unknown';
21
+
22
+ // Check if payload is stale (older than 5 minutes = likely survived a previous session)
23
+ const stale = manifest?.timestamp
24
+ ? (Date.now() - new Date(manifest.timestamp).getTime()) > 5 * 60 * 1000
25
+ : false;
26
+ const urgency = stale
27
+ ? `URGENT: This update payload has been pending since ${manifest.timestamp}. It was not completed in a previous session. `
28
+ : '';
29
+ const skipAudit = stale
30
+ ? 'Skip the pre-update audit and proceed directly to comparing and applying changes. '
31
+ : '';
32
+
33
+ if (action === 'init' || !initialized) {
34
+ respond(`MANDATORY: ${urgency}A workspace init payload (template v${version}) is pending at .workspace-update/.
35
+ Read .workspace-update/.claude/skills/workspace-init/SKILL.md and follow it before doing anything else.
36
+ ${skipAudit}Do not proceed with the user's request until initialization is complete.`);
37
+ } else {
38
+ const from = manifest?.fromVersion || 'unknown';
39
+ respond(`MANDATORY: ${urgency}A workspace upgrade payload (v${from} → v${version}) is pending at .workspace-update/.
40
+ Read .workspace-update/.claude/skills/workspace-update/SKILL.md and follow it before doing anything else.
41
+ ${skipAudit}Do not proceed with the user's request until the update is complete.`);
42
+ }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // WorktreeCreate hook — scan for stale worktrees across project repos and
3
+ // flag them. Project worktrees live inside work-sessions/{name}/workspace/repos/
4
+ // in the new layout. The scan walks each project repo's worktree admin list
5
+ // rather than a filesystem pattern, so it keeps working regardless of where
6
+ // worktrees are physically located.
7
+ import { readdirSync, existsSync } from 'fs';
8
+ import { join, basename } from 'path';
9
+ import { execSync } from 'child_process';
10
+ import { getWorkspaceRoot, respond } from './_utils.mjs';
11
+
12
+ const root = getWorkspaceRoot(import.meta.url);
13
+ const reposDir = join(root, 'repos');
14
+ const stale = [];
15
+
16
+ if (!existsSync(reposDir)) {
17
+ respond();
18
+ process.exit(0);
19
+ }
20
+
21
+ for (const entry of readdirSync(reposDir)) {
22
+ const repoPath = join(reposDir, entry);
23
+ if (!existsSync(join(repoPath, '.git'))) continue;
24
+
25
+ let worktreeOutput;
26
+ try {
27
+ worktreeOutput = execSync('git worktree list', { cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
28
+ } catch { continue; }
29
+
30
+ for (const line of worktreeOutput.trim().split('\n')) {
31
+ const parts = line.trim().split(/\s+/);
32
+ const wtPath = parts[0];
33
+ if (!wtPath || wtPath === repoPath) continue;
34
+ if (!existsSync(wtPath)) continue;
35
+
36
+ const branchMatch = line.match(/\[(.+?)\]/);
37
+ const branch = branchMatch ? branchMatch[1] : 'unknown';
38
+
39
+ try {
40
+ const lastCommit = execSync('git log -1 --format=%ci', { cwd: wtPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
41
+ const daysAgo = Math.floor((Date.now() - new Date(lastCommit).getTime()) / 86400000);
42
+ if (daysAgo > 3) {
43
+ stale.push(`- ${basename(wtPath)} (${branch}): no commits in ${daysAgo} days`);
44
+ }
45
+ } catch {}
46
+ }
47
+ }
48
+
49
+ if (stale.length > 0) {
50
+ respond(`Stale worktrees found:\n${stale.join('\n')}\n\nConsider cleaning up with: git worktree remove {path}`);
51
+ } else {
52
+ respond();
53
+ }
@@ -0,0 +1,265 @@
1
+ // Session frontmatter parser for work-sessions/{name}/session.md trackers.
2
+ //
3
+ // Reads and writes a small, opinionated subset of YAML scoped to the
4
+ // fields the workspace actually uses. Lossless rewrites: when a field is
5
+ // updated, only that field's lines are touched — every other byte of the
6
+ // frontmatter and body is preserved.
7
+ //
8
+ // Supported value shapes:
9
+ // - flat scalars (string, number, null, boolean, ISO timestamps, UUIDs)
10
+ // - flat lists ("repos:\n - one\n - two")
11
+ // - lists of mappings ("chatSessions:\n - id: x\n names: [a, b]\n ...")
12
+ // - inline lists ("names: [a, b]")
13
+ //
14
+ // Anything outside this subset throws. The parser is intentionally narrow.
15
+
16
+ import { readFileSync, writeFileSync } from 'fs';
17
+
18
+ const FM_DELIM = '---';
19
+
20
+ export function readSessionFile(filePath) {
21
+ return parseSessionContent(readFileSync(filePath, 'utf-8'));
22
+ }
23
+
24
+ export function parseSessionContent(content) {
25
+ const lines = content.split('\n');
26
+ if (lines[0] !== FM_DELIM) {
27
+ throw new Error('No frontmatter found (file does not start with ---)');
28
+ }
29
+ let endIdx = -1;
30
+ for (let i = 1; i < lines.length; i++) {
31
+ if (lines[i] === FM_DELIM) { endIdx = i; break; }
32
+ }
33
+ if (endIdx === -1) {
34
+ throw new Error('No closing --- for frontmatter');
35
+ }
36
+ const fmLines = lines.slice(1, endIdx);
37
+ const bodyLines = lines.slice(endIdx + 1);
38
+ const body = bodyLines.join('\n');
39
+ const { fields, fieldRanges } = parseFmLines(fmLines);
40
+ return { fields, body, raw: { fmLines, fieldRanges } };
41
+ }
42
+
43
+ function parseFmLines(fmLines) {
44
+ const fields = {};
45
+ const fieldRanges = {};
46
+ let i = 0;
47
+ while (i < fmLines.length) {
48
+ const line = fmLines[i];
49
+ if (!line.trim() || line.startsWith('#')) { i++; continue; }
50
+ const m = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*(.*)$/);
51
+ if (!m) { i++; continue; }
52
+ const key = m[1];
53
+ const valuePart = m[2];
54
+ const start = i;
55
+
56
+ if (valuePart === '') {
57
+ // Block: either flat list, list of mappings, or empty scalar
58
+ i++;
59
+ const items = [];
60
+ while (i < fmLines.length) {
61
+ const next = fmLines[i];
62
+ if (next.startsWith(' - ')) {
63
+ const itemFirstLine = next.slice(4);
64
+ const mapMatch = itemFirstLine.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*(.*)$/);
65
+ if (mapMatch) {
66
+ const item = {};
67
+ item[mapMatch[1]] = parseScalar(mapMatch[2]);
68
+ i++;
69
+ while (i < fmLines.length && fmLines[i].startsWith(' ')) {
70
+ const subLine = fmLines[i].slice(4);
71
+ const subMatch = subLine.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*(.*)$/);
72
+ if (subMatch) item[subMatch[1]] = parseScalar(subMatch[2]);
73
+ i++;
74
+ }
75
+ items.push(item);
76
+ } else {
77
+ items.push(parseScalar(itemFirstLine));
78
+ i++;
79
+ }
80
+ } else {
81
+ break;
82
+ }
83
+ }
84
+ if (items.length === 0) {
85
+ fields[key] = null;
86
+ fieldRanges[key] = { start, end: start };
87
+ } else {
88
+ fields[key] = items;
89
+ fieldRanges[key] = { start, end: i - 1 };
90
+ }
91
+ } else {
92
+ fields[key] = parseScalar(valuePart);
93
+ fieldRanges[key] = { start, end: start };
94
+ i++;
95
+ }
96
+ }
97
+ return { fields, fieldRanges };
98
+ }
99
+
100
+ function parseScalar(s) {
101
+ s = s.trim();
102
+ if (s === '' || s === '~' || /^null$/i.test(s)) return null;
103
+ if (/^true$/i.test(s)) return true;
104
+ if (/^false$/i.test(s)) return false;
105
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
106
+ if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
107
+ if (s.startsWith('[') && s.endsWith(']')) {
108
+ const inner = s.slice(1, -1).trim();
109
+ if (inner === '') return [];
110
+ return inner.split(',').map(p => parseScalar(p.trim()));
111
+ }
112
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
113
+ return s.slice(1, -1);
114
+ }
115
+ return s;
116
+ }
117
+
118
+ function serializeFieldLines(key, value) {
119
+ if (value === null || value === undefined) return [`${key}: null`];
120
+ if (typeof value === 'boolean' || typeof value === 'number') return [`${key}: ${value}`];
121
+ if (typeof value === 'string') {
122
+ if (needsQuoting(value)) return [`${key}: "${escapeQuoted(value)}"`];
123
+ return [`${key}: ${value}`];
124
+ }
125
+ if (Array.isArray(value)) {
126
+ if (value.length === 0) return [`${key}: []`];
127
+ if (value.every(v => typeof v !== 'object' || v === null)) {
128
+ return [`${key}:`, ...value.map(v => ` - ${serializeInlineScalar(v)}`)];
129
+ }
130
+ const lines = [`${key}:`];
131
+ for (const item of value) {
132
+ let firstKey = true;
133
+ for (const [k, v] of Object.entries(item)) {
134
+ if (firstKey) {
135
+ lines.push(` - ${k}: ${serializeMappingValue(v)}`);
136
+ firstKey = false;
137
+ } else {
138
+ lines.push(` ${k}: ${serializeMappingValue(v)}`);
139
+ }
140
+ }
141
+ }
142
+ return lines;
143
+ }
144
+ throw new Error(`Cannot serialize ${typeof value} as field ${key}`);
145
+ }
146
+
147
+ function serializeMappingValue(v) {
148
+ if (v === null || v === undefined) return 'null';
149
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
150
+ if (Array.isArray(v)) {
151
+ return '[' + v.map(serializeInlineScalar).join(', ') + ']';
152
+ }
153
+ if (typeof v === 'string') {
154
+ if (needsQuoting(v)) return `"${escapeQuoted(v)}"`;
155
+ return v;
156
+ }
157
+ throw new Error(`Cannot serialize mapping value of type ${typeof v}`);
158
+ }
159
+
160
+ function serializeInlineScalar(v) {
161
+ if (v === null || v === undefined) return 'null';
162
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
163
+ if (typeof v === 'string') {
164
+ if (needsInlineQuoting(v)) return `"${escapeQuoted(v)}"`;
165
+ return v;
166
+ }
167
+ throw new Error(`Cannot inline-serialize ${typeof v}`);
168
+ }
169
+
170
+ function needsQuoting(s) {
171
+ if (s === '') return true;
172
+ if (/^(true|false|null|yes|no|on|off|~)$/i.test(s)) return true;
173
+ if (/^-?\d+(\.\d+)?$/.test(s)) return true;
174
+ if (/^[\[\]{}!&*>|'"`@%?,]/.test(s)) return true;
175
+ if (s === '-' || s.startsWith('- ')) return true;
176
+ if (s.includes(': ')) return true;
177
+ if (s.endsWith(':')) return true;
178
+ if (s.includes(' #')) return true;
179
+ if (s !== s.trim()) return true;
180
+ return false;
181
+ }
182
+
183
+ function needsInlineQuoting(s) {
184
+ if (needsQuoting(s)) return true;
185
+ // Inside [a, b], commas would split the value
186
+ if (s.includes(',')) return true;
187
+ if (s.includes(']')) return true;
188
+ if (s.includes('[')) return true;
189
+ return false;
190
+ }
191
+
192
+ function escapeQuoted(s) {
193
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
194
+ }
195
+
196
+ // === Public write API ===
197
+
198
+ /**
199
+ * Update specific fields in a session file's frontmatter.
200
+ * Lossless for unchanged fields and the body. Fields set to `undefined`
201
+ * are removed. Fields not present in the file are appended.
202
+ */
203
+ export function updateSessionFile(filePath, updates) {
204
+ const content = readFileSync(filePath, 'utf-8');
205
+ const newContent = updateSessionContent(content, updates);
206
+ if (newContent !== content) writeFileSync(filePath, newContent);
207
+ }
208
+
209
+ export function updateSessionContent(content, updates) {
210
+ const parsed = parseSessionContent(content);
211
+ const { fmLines, fieldRanges } = parsed.raw;
212
+ let newFmLines = [...fmLines];
213
+
214
+ // Process replacements/removals from bottom to top so line indices stay valid
215
+ const replacements = [];
216
+ const appends = [];
217
+ for (const [key, value] of Object.entries(updates)) {
218
+ if (fieldRanges[key]) {
219
+ replacements.push({ range: fieldRanges[key], key, value });
220
+ } else if (value !== undefined) {
221
+ appends.push({ key, value });
222
+ }
223
+ }
224
+ replacements.sort((a, b) => b.range.start - a.range.start);
225
+ for (const op of replacements) {
226
+ const removeCount = op.range.end - op.range.start + 1;
227
+ if (op.value === undefined) {
228
+ newFmLines.splice(op.range.start, removeCount);
229
+ } else {
230
+ const newLines = serializeFieldLines(op.key, op.value);
231
+ newFmLines.splice(op.range.start, removeCount, ...newLines);
232
+ }
233
+ }
234
+ for (const op of appends) {
235
+ newFmLines.push(...serializeFieldLines(op.key, op.value));
236
+ }
237
+
238
+ const reconstructed = ['---', ...newFmLines, '---'];
239
+ if (parsed.body === '') {
240
+ reconstructed.push('');
241
+ } else {
242
+ reconstructed.push(parsed.body);
243
+ }
244
+ return reconstructed.join('\n');
245
+ }
246
+
247
+ /**
248
+ * Create a session file from scratch with the given fields and body.
249
+ * Field order follows the order of keys in the fields object.
250
+ */
251
+ export function writeSessionFile(filePath, fields, body = '') {
252
+ const fmLines = [];
253
+ for (const [key, value] of Object.entries(fields)) {
254
+ fmLines.push(...serializeFieldLines(key, value));
255
+ }
256
+ const lines = ['---', ...fmLines, '---', '', body];
257
+ writeFileSync(filePath, lines.join('\n'));
258
+ }
259
+
260
+ /**
261
+ * Convenience: read just the parsed fields object.
262
+ */
263
+ export function readSessionFields(filePath) {
264
+ return readSessionFile(filePath).fields;
265
+ }