@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.18.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 (45) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/template/.claude/hooks/_utils.mjs +1 -1
  4. package/template/.claude/hooks/repo-write-detection.mjs +161 -64
  5. package/template/.claude/hooks/session-start.mjs +35 -1
  6. package/template/.claude/hooks/subagent-start.mjs +89 -22
  7. package/template/.claude/lib/session-frontmatter.mjs +28 -0
  8. package/template/.claude/rules/coherent-revisions.md +1 -1
  9. package/template/.claude/rules/forge-operations.md +37 -93
  10. package/template/.claude/rules/git-conventions.md +16 -11
  11. package/template/.claude/rules/goal-driven-work.md +8 -416
  12. package/template/.claude/rules/honest-pushback.md +37 -37
  13. package/template/.claude/rules/memory-guidance.md +43 -90
  14. package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
  15. package/template/.claude/rules/work-item-tracking.md +30 -72
  16. package/template/.claude/rules/workspace-structure.md +36 -94
  17. package/template/.claude/scripts/build-workspace-context.mjs +61 -16
  18. package/template/.claude/scripts/chat-record.mjs +282 -0
  19. package/template/.claude/scripts/cleanup-work-session.mjs +257 -68
  20. package/template/.claude/scripts/context-footprint.mjs +282 -0
  21. package/template/.claude/scripts/forges/github.mjs +45 -0
  22. package/template/.claude/scripts/forges/gitlab.mjs +3 -2
  23. package/template/.claude/scripts/forges/interface.mjs +12 -0
  24. package/template/.claude/scripts/generate-claude-local.mjs +21 -2
  25. package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
  26. package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
  27. package/template/.claude/scripts/task-worktree.mjs +525 -0
  28. package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
  29. package/template/.claude/skills/braindump/SKILL.md +11 -4
  30. package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
  31. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  32. package/template/.claude/skills/complete-work/SKILL.md +229 -219
  33. package/template/.claude/skills/context-placement/SKILL.md +199 -0
  34. package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
  35. package/template/.claude/skills/handoff/SKILL.md +11 -4
  36. package/template/.claude/skills/maintenance/SKILL.md +7 -0
  37. package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
  38. package/template/.claude/skills/pause-work/SKILL.md +9 -1
  39. package/template/.claude/skills/release/SKILL.md +44 -108
  40. package/template/.claude/skills/start-work/SKILL.md +89 -7
  41. package/template/.claude/skills/workspace-init/SKILL.md +3 -1
  42. package/template/.claude/skills/workspace-update/SKILL.md +4 -0
  43. package/template/CLAUDE.md.tmpl +19 -2
  44. package/template/_gitignore +9 -0
  45. package/template/workspace.json.tmpl +3 -2
@@ -0,0 +1,654 @@
1
+ #!/usr/bin/env node
2
+ // Opt-in workspace diagnostics report: how a workspace is actually used, in
3
+ // aggregate, so an operator can decide whether to share it (gh:139).
4
+ //
5
+ // Going to v1.0 means shipping to workspaces nobody can inspect directly.
6
+ // This script reads local Claude Code transcripts, the session log, and
7
+ // workspace config, and produces a report of usage patterns — skill
8
+ // invocations, tool mix, session shape, always-loaded context footprint —
9
+ // entirely in aggregate.
10
+ //
11
+ // THE HARD CONSTRAINT: transcripts are private conversations. This script
12
+ // emits counts, not content. It never emits message text, thinking blocks,
13
+ // tool inputs or outputs (the one exception is `input.skill`, a bare skill
14
+ // name, from Skill tool_use blocks), branch names, session/work-session
15
+ // names or slugs, ticket titles, file paths, absolute paths, email
16
+ // addresses, or usernames. Where an identifier's cardinality is the
17
+ // interesting part, only its count is emitted — identifiers are never
18
+ // hashed, since a shared salt makes hashes re-identifiable.
19
+ //
20
+ // Before writing or printing anything, the generated report text is run
21
+ // through scanForLeaks() and refused (exit 2) if it matches an absolute
22
+ // path, an email address, a git branch-shaped token, a bare UUID, or the
23
+ // current OS username. That scan is the load-bearing safety feature here —
24
+ // everything else is best-effort aggregation.
25
+ //
26
+ // No network calls of any kind. This script only reads local files and
27
+ // writes one local report file (or prints to stdout).
28
+ //
29
+ // Usage:
30
+ // node workspace-diagnostics.mjs --root . --out workspace-scratchpad/diagnostics-report.md
31
+ // node workspace-diagnostics.mjs --root . --json
32
+ //
33
+ // --root <path> workspace root to diagnose (default: .)
34
+ // --out <path> write markdown report to this path; without it, print
35
+ // the markdown to stdout
36
+ // --json emit the raw aggregate object instead of markdown
37
+ // --since <ISO> optional lower bound on records considered
38
+ //
39
+ // Data sources (see gh:139 for the full source list):
40
+ // - ~/.claude/projects/<slug>/*.jsonl — session transcripts
41
+ // - ~/.claude/projects/<slug>/<id>/subagents/agent-*.jsonl — subagent dispatch (counted, never opened)
42
+ // - <root>/workspace-scratchpad/session-log.jsonl — lifecycle event log
43
+ // - <root>/workspace.json — templateVersion
44
+ // - ./context-footprint.mjs (measure()) — always-loaded context footprint
45
+ //
46
+ // Exit codes:
47
+ // 0 — report generated (written or printed)
48
+ // 1 — argument or filesystem error
49
+ // 2 — leak scan refused to emit the report (see stderr for pattern + line)
50
+
51
+ import {
52
+ readFileSync,
53
+ writeFileSync,
54
+ readdirSync,
55
+ statSync,
56
+ existsSync,
57
+ mkdirSync,
58
+ realpathSync,
59
+ } from 'node:fs';
60
+ import { join, resolve, dirname, basename } from 'node:path';
61
+ import { homedir, userInfo } from 'node:os';
62
+ import { fileURLToPath } from 'node:url';
63
+
64
+ function isMainModule(metaUrl) {
65
+ if (!process.argv[1]) return false;
66
+ try {
67
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
68
+ } catch { return false; }
69
+ }
70
+
71
+ function parseArgs(argv) {
72
+ const args = { root: process.cwd(), out: null, json: false, since: null };
73
+ for (let i = 2; i < argv.length; i++) {
74
+ const a = argv[i];
75
+ if (a === '--root') args.root = argv[++i];
76
+ else if (a === '--out') args.out = argv[++i];
77
+ else if (a === '--json') args.json = true;
78
+ else if (a === '--since') args.since = argv[++i];
79
+ }
80
+ return args;
81
+ }
82
+
83
+ // ---------- generic helpers ----------
84
+
85
+ function readJsonlRecords(filePath) {
86
+ let content;
87
+ try {
88
+ content = readFileSync(filePath, 'utf-8');
89
+ } catch {
90
+ return [];
91
+ }
92
+ const records = [];
93
+ for (const line of content.split('\n')) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed) continue;
96
+ try {
97
+ records.push(JSON.parse(trimmed));
98
+ } catch {
99
+ // Malformed line — skip rather than throw. Transcripts are written
100
+ // incrementally and a partial final line is expected, not an error.
101
+ }
102
+ }
103
+ return records;
104
+ }
105
+
106
+ /**
107
+ * True when `isoValue` is missing, unparsable, or on/after `sinceDate`.
108
+ * Fails open (keeps the record) rather than dropping data it can't judge —
109
+ * a record with no timestamp is more likely a structural event than one
110
+ * that should be silently excluded by a date filter.
111
+ */
112
+ function isAfterOrEqual(isoValue, sinceDate) {
113
+ if (!sinceDate) return true;
114
+ if (typeof isoValue !== 'string') return true;
115
+ const t = Date.parse(isoValue);
116
+ if (Number.isNaN(t)) return true;
117
+ return t >= sinceDate.getTime();
118
+ }
119
+
120
+ function mapToSortedArray(map) {
121
+ return [...map.entries()]
122
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
123
+ .map(([name, count]) => ({ name, count }));
124
+ }
125
+
126
+ function computeMinMedianMax(nums) {
127
+ if (nums.length === 0) return { min: 0, median: 0, max: 0 };
128
+ const sorted = [...nums].sort((a, b) => a - b);
129
+ const mid = Math.floor(sorted.length / 2);
130
+ const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
131
+ return { min: sorted[0], median, max: sorted[sorted.length - 1] };
132
+ }
133
+
134
+ // ---------- transcripts ----------
135
+
136
+ /**
137
+ * Derive the ~/.claude/projects/<slug> directory name for a workspace root:
138
+ * the absolute root path with `/` and `.` replaced by `-`.
139
+ */
140
+ export function deriveProjectsSlug(absRootPath) {
141
+ return absRootPath.replace(/[/.]/g, '-');
142
+ }
143
+
144
+ /**
145
+ * Resolve the transcripts directory for a workspace root. `claudeHome`
146
+ * defaults to `~/.claude` but is an explicit parameter so tests can point
147
+ * at a fixture instead of the real home directory.
148
+ */
149
+ export function resolveProjectsDir(root, claudeHome) {
150
+ const home = claudeHome || join(homedir(), '.claude');
151
+ const slug = deriveProjectsSlug(resolve(root));
152
+ return join(home, 'projects', slug);
153
+ }
154
+
155
+ function listTranscriptFiles(projectsDir) {
156
+ if (!existsSync(projectsDir)) return [];
157
+ let entries;
158
+ try {
159
+ entries = readdirSync(projectsDir);
160
+ } catch {
161
+ return [];
162
+ }
163
+ return entries
164
+ .filter((f) => f.endsWith('.jsonl'))
165
+ .map((f) => join(projectsDir, f));
166
+ }
167
+
168
+ /**
169
+ * Count subagent transcript files for one session, without opening them —
170
+ * dispatch count is the metric, not their content.
171
+ */
172
+ function countSubagentFiles(projectsDir, sessionId) {
173
+ const dir = join(projectsDir, sessionId, 'subagents');
174
+ if (!existsSync(dir)) return 0;
175
+ let entries;
176
+ try {
177
+ entries = readdirSync(dir);
178
+ } catch {
179
+ return 0;
180
+ }
181
+ return entries.filter((f) => /^agent-.*\.jsonl$/.test(f)).length;
182
+ }
183
+
184
+ /**
185
+ * Walk one transcript file's records (already since-filtered) and extract
186
+ * every aggregate this report needs in a single pass: skill invocations
187
+ * (from Skill tool_use blocks' bare `input.skill` name — the one permitted
188
+ * exception to "never read tool input"), tool-name counts, Claude Code
189
+ * version counts, the set of distinct git branches seen, the count of
190
+ * cwd-to-cwd transitions between consecutive records, and the span of
191
+ * timestamps observed. Never reads message text or thinking blocks.
192
+ */
193
+ function analyzeTranscriptFile(filePath, sinceDate) {
194
+ const records = readJsonlRecords(filePath).filter((r) => isAfterOrEqual(r?.timestamp, sinceDate));
195
+
196
+ const skillCounts = new Map();
197
+ const toolCounts = new Map();
198
+ const versionCounts = new Map();
199
+ const branches = new Set();
200
+ const timestamps = [];
201
+ let cwdTransitions = 0;
202
+ let hasCwd = false;
203
+ let lastCwd;
204
+
205
+ for (const r of records) {
206
+ if (!r || typeof r !== 'object') continue;
207
+
208
+ if (typeof r.version === 'string' && r.version) {
209
+ versionCounts.set(r.version, (versionCounts.get(r.version) || 0) + 1);
210
+ }
211
+ if (typeof r.gitBranch === 'string' && r.gitBranch) {
212
+ branches.add(r.gitBranch);
213
+ }
214
+ if (typeof r.timestamp === 'string') {
215
+ const t = Date.parse(r.timestamp);
216
+ if (!Number.isNaN(t)) timestamps.push(t);
217
+ }
218
+ if (typeof r.cwd === 'string') {
219
+ if (hasCwd && r.cwd !== lastCwd) cwdTransitions++;
220
+ lastCwd = r.cwd;
221
+ hasCwd = true;
222
+ }
223
+
224
+ if (r.type === 'assistant' && r.message && Array.isArray(r.message.content)) {
225
+ for (const block of r.message.content) {
226
+ if (!block || typeof block !== 'object' || block.type !== 'tool_use') continue;
227
+ if (typeof block.name !== 'string') continue;
228
+ toolCounts.set(block.name, (toolCounts.get(block.name) || 0) + 1);
229
+ if (block.name === 'Skill' && block.input && typeof block.input.skill === 'string') {
230
+ const skillName = block.input.skill;
231
+ skillCounts.set(skillName, (skillCounts.get(skillName) || 0) + 1);
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ return {
238
+ recordCount: records.length,
239
+ skillCounts,
240
+ toolCounts,
241
+ versionCounts,
242
+ branches,
243
+ cwdTransitions,
244
+ timestamps,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Aggregate across every transcript file in `projectsDir`. Returns
250
+ * `{ available: false }` when the directory doesn't exist (rather than
251
+ * throwing) so the report can render an "unavailable" section instead of
252
+ * crashing.
253
+ */
254
+ export function aggregateTranscripts({ projectsDir, sinceDate = null }) {
255
+ if (!existsSync(projectsDir)) return { available: false };
256
+
257
+ const files = listTranscriptFiles(projectsDir);
258
+ const sessionIds = files.map((f) => basename(f, '.jsonl'));
259
+
260
+ const skillCounts = new Map();
261
+ const toolCounts = new Map();
262
+ const versionCounts = new Map();
263
+ const recordCounts = [];
264
+ const perSessionDistinctBranches = [];
265
+ const overallBranches = new Set();
266
+ let cwdTransitionsTotal = 0;
267
+ let subagentDispatches = 0;
268
+ let minTs = null;
269
+ let maxTs = null;
270
+
271
+ for (let i = 0; i < files.length; i++) {
272
+ const result = analyzeTranscriptFile(files[i], sinceDate);
273
+ recordCounts.push(result.recordCount);
274
+ cwdTransitionsTotal += result.cwdTransitions;
275
+ perSessionDistinctBranches.push(result.branches.size);
276
+ for (const b of result.branches) overallBranches.add(b);
277
+ for (const [k, v] of result.skillCounts) skillCounts.set(k, (skillCounts.get(k) || 0) + v);
278
+ for (const [k, v] of result.toolCounts) toolCounts.set(k, (toolCounts.get(k) || 0) + v);
279
+ for (const [k, v] of result.versionCounts) versionCounts.set(k, (versionCounts.get(k) || 0) + v);
280
+ for (const t of result.timestamps) {
281
+ if (minTs === null || t < minTs) minTs = t;
282
+ if (maxTs === null || t > maxTs) maxTs = t;
283
+ }
284
+ subagentDispatches += countSubagentFiles(projectsDir, sessionIds[i]);
285
+ }
286
+
287
+ const daysSpanned = minTs !== null ? Math.floor((maxTs - minTs) / 86400000) + 1 : 0;
288
+
289
+ return {
290
+ available: true,
291
+ transcriptCount: files.length,
292
+ recordCountStats: computeMinMedianMax(recordCounts),
293
+ distinctBranchCount: {
294
+ overall: overallBranches.size,
295
+ perSession: perSessionDistinctBranches,
296
+ },
297
+ cwdTransitions: cwdTransitionsTotal,
298
+ subagentDispatches,
299
+ skillCounts: mapToSortedArray(skillCounts),
300
+ toolCounts: mapToSortedArray(toolCounts),
301
+ versionCounts: mapToSortedArray(versionCounts),
302
+ dateRange: {
303
+ minIso: minTs !== null ? new Date(minTs).toISOString() : null,
304
+ maxIso: maxTs !== null ? new Date(maxTs).toISOString() : null,
305
+ daysSpanned,
306
+ },
307
+ };
308
+ }
309
+
310
+ // ---------- available skills (for zero-invocation call-out) ----------
311
+
312
+ /**
313
+ * List skill directory names under <root>/.claude/skills. Each subdirectory
314
+ * is one skill (holding a SKILL.md). Reading this directory is safe to
315
+ * report on — it's the framework's own skill catalog, not user content.
316
+ */
317
+ export function listAvailableSkills(root) {
318
+ const dir = join(root, '.claude', 'skills');
319
+ if (!existsSync(dir)) return [];
320
+ let entries;
321
+ try {
322
+ entries = readdirSync(dir);
323
+ } catch {
324
+ return [];
325
+ }
326
+ return entries
327
+ .filter((name) => {
328
+ try {
329
+ return statSync(join(dir, name)).isDirectory();
330
+ } catch {
331
+ return false;
332
+ }
333
+ })
334
+ .sort();
335
+ }
336
+
337
+ // ---------- session log ----------
338
+
339
+ /**
340
+ * Aggregate <root>/workspace-scratchpad/session-log.jsonl by `event` and
341
+ * `reason`. Never reads or emits `user`, `session_id`, `workspace_branch`,
342
+ * or `work_session` from those records.
343
+ */
344
+ export function aggregateSessionLog(root, sinceDate = null) {
345
+ const path = join(root, 'workspace-scratchpad', 'session-log.jsonl');
346
+ if (!existsSync(path)) return { available: false };
347
+
348
+ const records = readJsonlRecords(path).filter((r) => isAfterOrEqual(r?.date, sinceDate));
349
+ const eventCounts = new Map();
350
+ const reasonCounts = new Map();
351
+ for (const r of records) {
352
+ if (!r || typeof r !== 'object') continue;
353
+ if (typeof r.event === 'string' && r.event) {
354
+ eventCounts.set(r.event, (eventCounts.get(r.event) || 0) + 1);
355
+ }
356
+ if (typeof r.reason === 'string' && r.reason) {
357
+ reasonCounts.set(r.reason, (reasonCounts.get(r.reason) || 0) + 1);
358
+ }
359
+ }
360
+ return {
361
+ available: true,
362
+ totalRecords: records.length,
363
+ eventCounts: mapToSortedArray(eventCounts),
364
+ reasonCounts: mapToSortedArray(reasonCounts),
365
+ };
366
+ }
367
+
368
+ // ---------- workspace config ----------
369
+
370
+ export function readTemplateVersion(root) {
371
+ const path = join(root, 'workspace.json');
372
+ if (!existsSync(path)) return null;
373
+ try {
374
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
375
+ return parsed?.workspace?.templateVersion ?? null;
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ // ---------- always-loaded footprint ----------
382
+
383
+ /**
384
+ * Load and run context-footprint.mjs's measure({ root }). That module is
385
+ * being written in parallel and may not exist yet, may not export
386
+ * `measure`, or may throw — all three are reported as "unavailable" with a
387
+ * reason rather than crashing this script.
388
+ */
389
+ export async function loadFootprint(root) {
390
+ let mod;
391
+ try {
392
+ mod = await import(new URL('./context-footprint.mjs', import.meta.url));
393
+ } catch {
394
+ return { available: false, reason: 'context-footprint.mjs not found (being developed in parallel)' };
395
+ }
396
+ if (typeof mod.measure !== 'function') {
397
+ return { available: false, reason: 'context-footprint.mjs does not export measure()' };
398
+ }
399
+ try {
400
+ const data = mod.measure({ root });
401
+ return { available: true, data };
402
+ } catch (err) {
403
+ return { available: false, reason: `measure() threw: ${err.message}` };
404
+ }
405
+ }
406
+
407
+ // ---------- report assembly ----------
408
+
409
+ /**
410
+ * Build the full diagnostics aggregate for `root`. Pure aside from the
411
+ * dynamic footprint import: reads files at explicit, injectable paths and
412
+ * never falls back to process.cwd(), so it behaves identically regardless
413
+ * of the caller's current directory.
414
+ */
415
+ export async function buildReport({ root, since = null, claudeHome } = {}) {
416
+ const absRoot = resolve(root);
417
+ const sinceDate = since ? new Date(since) : null;
418
+
419
+ const templateVersion = readTemplateVersion(absRoot);
420
+ const projectsDir = resolveProjectsDir(absRoot, claudeHome);
421
+ const transcripts = aggregateTranscripts({ projectsDir, sinceDate });
422
+ const zeroInvocationSkills = transcripts.available
423
+ ? listAvailableSkills(absRoot).filter(
424
+ (name) => !transcripts.skillCounts.some((s) => s.name === name),
425
+ )
426
+ : [];
427
+ const sessionLog = aggregateSessionLog(absRoot, sinceDate);
428
+ const footprint = await loadFootprint(absRoot);
429
+
430
+ return {
431
+ generatedAt: new Date().toISOString(),
432
+ since: since || null,
433
+ templateVersion,
434
+ transcripts,
435
+ zeroInvocationSkills,
436
+ sessionLog,
437
+ footprint,
438
+ };
439
+ }
440
+
441
+ export function renderMarkdown(agg) {
442
+ const lines = [];
443
+ lines.push('# Workspace Diagnostics Report', '');
444
+ lines.push(`Generated: ${agg.generatedAt}`);
445
+ if (agg.since) lines.push(`Since: ${agg.since}`);
446
+ lines.push('');
447
+
448
+ lines.push('## Environment', '');
449
+ lines.push(`- Template version: ${agg.templateVersion ?? '_unknown_'}`);
450
+ if (agg.transcripts.available) {
451
+ if (agg.transcripts.versionCounts.length > 0) {
452
+ lines.push('- Claude Code versions seen (record count):');
453
+ for (const v of agg.transcripts.versionCounts) lines.push(` - ${v.name}: ${v.count}`);
454
+ } else {
455
+ lines.push('- Claude Code versions seen: _none recorded_');
456
+ }
457
+ if (agg.transcripts.dateRange.minIso) {
458
+ lines.push(
459
+ `- Date range covered: ${agg.transcripts.dateRange.minIso} to ${agg.transcripts.dateRange.maxIso} (${agg.transcripts.dateRange.daysSpanned} days spanned)`,
460
+ );
461
+ } else {
462
+ lines.push('- Date range covered: _no timestamped records_');
463
+ }
464
+ } else {
465
+ lines.push('- Transcript data: _unavailable — no projects directory found for this workspace_');
466
+ }
467
+ lines.push('');
468
+
469
+ lines.push('## Skill Usage', '');
470
+ if (agg.transcripts.available) {
471
+ if (agg.transcripts.skillCounts.length === 0) {
472
+ lines.push('_No skill invocations recorded._');
473
+ } else {
474
+ lines.push('| Skill | Invocations |', '|---|---|');
475
+ for (const s of agg.transcripts.skillCounts) lines.push(`| ${s.name} | ${s.count} |`);
476
+ }
477
+ if (agg.zeroInvocationSkills.length > 0) {
478
+ lines.push('', `**Never invoked (the finding):** ${agg.zeroInvocationSkills.join(', ')}`);
479
+ }
480
+ } else {
481
+ lines.push('_Unavailable — transcript data not found._');
482
+ }
483
+ lines.push('');
484
+
485
+ lines.push('## Session Shape', '');
486
+ if (agg.transcripts.available) {
487
+ const stats = agg.transcripts.recordCountStats;
488
+ const branchInfo = agg.transcripts.distinctBranchCount;
489
+ const perSessionMin = branchInfo.perSession.length > 0 ? Math.min(...branchInfo.perSession) : 0;
490
+ const perSessionMax = branchInfo.perSession.length > 0 ? Math.max(...branchInfo.perSession) : 0;
491
+ lines.push(`- Transcript (session) count: ${agg.transcripts.transcriptCount}`);
492
+ lines.push(`- Records per session — min: ${stats.min}, median: ${stats.median}, max: ${stats.max}`);
493
+ lines.push(
494
+ `- Distinct branches — overall: ${branchInfo.overall} (per-session range: ${perSessionMin}–${perSessionMax})`,
495
+ );
496
+ lines.push(`- cwd transitions (total across sessions): ${agg.transcripts.cwdTransitions}`);
497
+ lines.push(`- Subagent dispatches (total): ${agg.transcripts.subagentDispatches}`);
498
+ } else {
499
+ lines.push('_Unavailable — transcript data not found._');
500
+ }
501
+ lines.push('');
502
+
503
+ lines.push('## Tool Mix', '');
504
+ if (agg.transcripts.available) {
505
+ if (agg.transcripts.toolCounts.length === 0) {
506
+ lines.push('_No tool invocations recorded._');
507
+ } else {
508
+ lines.push('| Tool | Invocations |', '|---|---|');
509
+ for (const t of agg.transcripts.toolCounts.slice(0, 15)) lines.push(`| ${t.name} | ${t.count} |`);
510
+ }
511
+ } else {
512
+ lines.push('_Unavailable — transcript data not found._');
513
+ }
514
+ lines.push('');
515
+
516
+ lines.push('## Always-Loaded Footprint', '');
517
+ if (agg.footprint.available) {
518
+ const f = agg.footprint.data || {};
519
+ lines.push(`- Total bytes: ${f.totalBytes}`);
520
+ lines.push(`- Total tokens: ${f.totalTokens}`);
521
+ lines.push(`- Percent of context window: ${f.percentOfWindow}`);
522
+ if (Array.isArray(f.missingImports) && f.missingImports.length > 0) {
523
+ lines.push(`- Missing imports: ${f.missingImports.length}`);
524
+ }
525
+ if (typeof f.local === 'boolean') {
526
+ lines.push(`- Local: ${f.local}`);
527
+ }
528
+ if (Array.isArray(f.files) && f.files.length > 0) {
529
+ lines.push('', '| File | Bytes | Kind |', '|---|---|---|');
530
+ for (const file of f.files) lines.push(`| ${file.path} | ${file.bytes} | ${file.kind} |`);
531
+ }
532
+ } else {
533
+ lines.push(`_Unavailable — ${agg.footprint.reason}._`);
534
+ }
535
+ lines.push('');
536
+
537
+ lines.push('## Session Log', '');
538
+ if (agg.sessionLog.available) {
539
+ lines.push('Events:');
540
+ if (agg.sessionLog.eventCounts.length === 0) {
541
+ lines.push('_none recorded_');
542
+ } else {
543
+ for (const e of agg.sessionLog.eventCounts) lines.push(`- ${e.name}: ${e.count}`);
544
+ }
545
+ lines.push('', 'Reasons:');
546
+ if (agg.sessionLog.reasonCounts.length === 0) {
547
+ lines.push('_none recorded_');
548
+ } else {
549
+ for (const r of agg.sessionLog.reasonCounts) lines.push(`- ${r.name}: ${r.count}`);
550
+ }
551
+ } else {
552
+ lines.push('_Unavailable — no session-log.jsonl found._');
553
+ }
554
+ lines.push('');
555
+
556
+ return lines.join('\n');
557
+ }
558
+
559
+ // ---------- leak scan (the load-bearing safety feature) ----------
560
+ //
561
+ // Deliberate tension, spelled out: the "Always-Loaded Footprint" table
562
+ // emits `files[].path` values, which are framework-relative paths like
563
+ // `.claude/rules/git-conventions.md` — safe, since they describe the
564
+ // template's own file layout, not user content. What must never appear is
565
+ // an ABSOLUTE path, which would anchor that same file to one operator's
566
+ // home directory. The regex below only matches the absolute forms.
567
+
568
+ const ABS_PATH_RE = /(\/Users\/[^\s`)]+|\/home\/[^\s`)]+|[A-Za-z]:\\[^\s`)]+)/;
569
+ const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
570
+ const BRANCH_RE = /(feature|bugfix|chore|release|hotfix)\/[A-Za-z0-9][A-Za-z0-9._-]*/;
571
+ const UUID_RE = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/;
572
+
573
+ /**
574
+ * Scan `text` for patterns that must never appear in a diagnostics report.
575
+ * `username` is injectable (defaults to the real OS username) so tests can
576
+ * exercise the username check deterministically. Returns an array of
577
+ * `{ pattern, line, excerpt }` — empty when clean.
578
+ */
579
+ export function scanForLeaks(text, { username } = {}) {
580
+ const effectiveUsername = username !== undefined ? username : userInfo().username;
581
+ const patterns = [
582
+ { pattern: 'absolute-path', re: ABS_PATH_RE },
583
+ { pattern: 'email-address', re: EMAIL_RE },
584
+ { pattern: 'git-branch-token', re: BRANCH_RE },
585
+ { pattern: 'uuid', re: UUID_RE },
586
+ ];
587
+ if (typeof effectiveUsername === 'string' && effectiveUsername.trim().length > 0) {
588
+ const escaped = effectiveUsername.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
589
+ patterns.push({ pattern: 'os-username', re: new RegExp(`\\b${escaped}\\b`) });
590
+ }
591
+
592
+ const hits = [];
593
+ const lines = text.split('\n');
594
+ for (let i = 0; i < lines.length; i++) {
595
+ const line = lines[i];
596
+ for (const { pattern, re } of patterns) {
597
+ const match = line.match(re);
598
+ if (match) {
599
+ hits.push({ pattern, line: i + 1, excerpt: line.trim().slice(0, 200) });
600
+ }
601
+ }
602
+ }
603
+ return hits;
604
+ }
605
+
606
+ // ---------- CLI entry point ----------
607
+
608
+ async function main() {
609
+ const args = parseArgs(process.argv);
610
+
611
+ if (args.since) {
612
+ const parsed = new Date(args.since);
613
+ if (Number.isNaN(parsed.getTime())) {
614
+ process.stderr.write(`error: --since value is not a valid date: ${args.since}\n`);
615
+ process.exit(1);
616
+ }
617
+ }
618
+
619
+ const aggregate = await buildReport({ root: args.root, since: args.since });
620
+ const outputText = args.json ? JSON.stringify(aggregate, null, 2) : renderMarkdown(aggregate);
621
+
622
+ const leaks = scanForLeaks(outputText);
623
+ if (leaks.length > 0) {
624
+ process.stderr.write('workspace-diagnostics: refusing to emit — potential privacy leak detected:\n');
625
+ for (const hit of leaks) {
626
+ process.stderr.write(` [${hit.pattern}] line ${hit.line}: ${hit.excerpt}\n`);
627
+ }
628
+ process.exit(2);
629
+ }
630
+
631
+ const finalText = outputText.endsWith('\n') ? outputText : `${outputText}\n`;
632
+ if (args.out) {
633
+ const outPath = resolve(args.out);
634
+ mkdirSync(dirname(outPath), { recursive: true });
635
+ writeFileSync(outPath, finalText);
636
+ process.stdout.write(`${outPath}\n`);
637
+ } else {
638
+ process.stdout.write(finalText);
639
+ }
640
+ }
641
+
642
+ if (isMainModule(import.meta.url)) {
643
+ main().catch((err) => {
644
+ process.stderr.write(`error: ${err.stack || err.message}\n`);
645
+ process.exit(1);
646
+ });
647
+ }
648
+
649
+ export {
650
+ parseArgs,
651
+ isAfterOrEqual,
652
+ mapToSortedArray,
653
+ computeMinMedianMax,
654
+ };
@@ -13,7 +13,7 @@ Capture discussion reasoning, exploration results, and design rationale into wor
13
13
 
14
14
  > **Note:** `/braindump side` has moved to `/aside`. If the user invokes `/braindump side`, redirect them: "The side braindump is now `/aside`. Running it for you." Then invoke the `/aside` skill with their text.
15
15
 
16
- ## Session-Aware Behavior
16
+ ## Lifecycle-Aware Behavior
17
17
 
18
18
  When called within an active work session (the active-session pointer at `.claude/.active-session.json` exists inside the current worktree):
19
19
 
@@ -26,9 +26,16 @@ When called within an active work session (the active-session pointer at `.claud
26
26
  git commit -m "braindump: update {session-name} tracker"
27
27
  ```
28
28
 
29
- When called from the workspace root (no active session):
30
- - Use `--local-only` so the captured file is gitignored (the root only allows local-only writes)
31
- - Suggest starting a work session if the braindump is about actionable work
29
+ Under the task model — `workspace.sessionModel` is `"task"` in `workspace.json` AND the SessionStart hook injected a `Chat record:` line (`{chat}` is its name):
30
+
31
+ - Default behavior: write `braindump_{topic}.md` directly into that chat's drawer at `workspace-scratchpad/chats/{chat}/` — the drawer sits outside `workspace-context/`, so `capture-context.mjs` is not involved
32
+ - No commit for drawer writes: the drawer is gitignored and machine-local; `/complete-work` lists it and asks what to promote into `workspace-context/`
33
+
34
+ When called from the workspace root with no active session — every other case, including a `sessionModel: "session"` workspace (the `Chat record:` line is injected in every chat, so it alone does not select the drawer):
35
+
36
+ - Use `--local-only` so the captured file is gitignored (the root only allows local-only writes), landing in `team-member/{user}/`
37
+ - If the task model applies but the `Chat record:` line is absent, say the drawer destination is unavailable for that reason
38
+ - Suggest starting work (`/start-work`) if the braindump is about actionable work
32
39
 
33
40
  The flows below apply when NOT in an active work session, or when the user explicitly asks for a standalone braindump file.
34
41