@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
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
// Source of truth: the filesystem. Hand edits are overwritten on regeneration.
|
|
10
10
|
// Gitignored files are excluded automatically. .indexignore adds prefix excludes.
|
|
11
11
|
//
|
|
12
|
-
// Canonical files honor
|
|
12
|
+
// Canonical files honor an opt-in byte budget. Each locked file declares
|
|
13
13
|
// `priority: critical | reference` in its frontmatter (default: critical).
|
|
14
14
|
// Section-level `<!-- canonical:trim --> ... <!-- canonical:end-trim -->` markers
|
|
15
|
-
// fence droppable spans inside reference files. When
|
|
16
|
-
//
|
|
17
|
-
// deterministic order. Critical files are never modified.
|
|
15
|
+
// fence droppable spans inside reference files. When a budget is set and the
|
|
16
|
+
// canonical body exceeds it, the builder trims reference files first, then
|
|
17
|
+
// stubs them, in that deterministic order. Critical files are never modified.
|
|
18
|
+
// `workspace.canonicalBudgetBytes` is off unless it holds a number: absent or
|
|
19
|
+
// null means every locked file ships in full and nothing is trimmed.
|
|
18
20
|
//
|
|
19
21
|
// Usage:
|
|
20
22
|
// node build-workspace-context.mjs --write [--root <workspace-root>]
|
|
@@ -25,10 +27,11 @@
|
|
|
25
27
|
// 0 — all artifacts current and canonical within budget
|
|
26
28
|
// 1 — at least one artifact missing or stale (regenerate via --write)
|
|
27
29
|
// 2 — artifacts current, but canonical body exceeds budget after trimming and stubbing
|
|
30
|
+
// (only reachable when a budget is set; off means nothing can exceed it)
|
|
28
31
|
// Stale wins over over-budget when both apply.
|
|
29
32
|
|
|
30
33
|
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
|
|
31
|
-
import { join, relative, sep } from 'node:path';
|
|
34
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
32
35
|
import { spawnSync } from 'node:child_process';
|
|
33
36
|
import { fileURLToPath } from 'node:url';
|
|
34
37
|
import { parseSessionContent } from '../lib/session-frontmatter.mjs';
|
|
@@ -129,6 +132,15 @@ function gitIgnoredPaths(workspaceRoot, paths) {
|
|
|
129
132
|
);
|
|
130
133
|
}
|
|
131
134
|
|
|
135
|
+
// The git filter fails open: gitIgnoredPaths returns an empty set when git is missing,
|
|
136
|
+
// the directory is not a repository, or the subprocess errors. Canonical content is
|
|
137
|
+
// committed and broadcast to every session and subagent, so that path must not depend on a
|
|
138
|
+
// subprocess succeeding. Exclude the local-only-* convention by name as well.
|
|
139
|
+
function isLocalOnlyName(filePath) {
|
|
140
|
+
const name = filePath.split(sep).pop() || '';
|
|
141
|
+
return name.startsWith('local-only-');
|
|
142
|
+
}
|
|
143
|
+
|
|
132
144
|
function stripFrontmatter(content) {
|
|
133
145
|
if (!content.startsWith('---\n')) return content;
|
|
134
146
|
const end = content.indexOf('\n---\n', 4);
|
|
@@ -159,7 +171,7 @@ function buildSharedIndex(workspaceRoot) {
|
|
|
159
171
|
const relToWC = relative(wcRoot, f).split(sep).join('/');
|
|
160
172
|
if (relToWC === INDEX_FILENAME || relToWC === CANONICAL_FILENAME) continue;
|
|
161
173
|
if (isIgnored(relToWC, ignorePrefixes)) continue;
|
|
162
|
-
if (gitIgnored.has(candidatePaths[i])) continue;
|
|
174
|
+
if (gitIgnored.has(candidatePaths[i]) || isLocalOnlyName(f)) continue;
|
|
163
175
|
const isLocked = relToWC.startsWith(`${SHARED_DIR}/${LOCKED_DIR}/`);
|
|
164
176
|
const { description } = describeAndPath(f, wcRoot);
|
|
165
177
|
entries.push({ rel: relToWC, isLocked, description });
|
|
@@ -171,11 +183,10 @@ function buildSharedIndex(workspaceRoot) {
|
|
|
171
183
|
return entries;
|
|
172
184
|
}
|
|
173
185
|
|
|
174
|
-
function renderSharedIndex(entries
|
|
186
|
+
function renderSharedIndex(entries) {
|
|
175
187
|
const lines = [
|
|
176
188
|
'---',
|
|
177
189
|
'type: index',
|
|
178
|
-
`generated: ${generatedAt}`,
|
|
179
190
|
'---',
|
|
180
191
|
'',
|
|
181
192
|
'# workspace-context — index',
|
|
@@ -293,16 +304,19 @@ export function extractCanonicalVariants({ name, rawContent }) {
|
|
|
293
304
|
}
|
|
294
305
|
|
|
295
306
|
/**
|
|
296
|
-
* Read `workspace.canonicalBudgetBytes` from `workspace.json`.
|
|
307
|
+
* Read `workspace.canonicalBudgetBytes` from `workspace.json`. The budget is
|
|
308
|
+
* opt-in — the whole always-loaded set is measured by `alwaysLoadedBudgetBytes`
|
|
309
|
+
* instead, so a canonical-only ceiling is something a workspace opts into.
|
|
297
310
|
* Returns:
|
|
298
|
-
* -
|
|
299
|
-
* - 0 when set to 0 or a negative number (treated as
|
|
300
|
-
* -
|
|
311
|
+
* - 0 when the field is absent or `null`, or workspace.json is missing (off).
|
|
312
|
+
* - 0 when set to 0 or a negative number (treated as off).
|
|
313
|
+
* - The integer value when set to a positive number.
|
|
314
|
+
* - DEFAULT_CANONICAL_BUDGET when set to a non-number (invalid, rejected as before).
|
|
301
315
|
* - DEFAULT_CANONICAL_BUDGET (with a stderr warning) when workspace.json fails to parse.
|
|
302
316
|
*/
|
|
303
317
|
export function readWorkspaceBudget(workspaceRoot) {
|
|
304
318
|
const path = join(workspaceRoot, 'workspace.json');
|
|
305
|
-
if (!existsSync(path)) return
|
|
319
|
+
if (!existsSync(path)) return 0;
|
|
306
320
|
let parsed;
|
|
307
321
|
try {
|
|
308
322
|
parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
@@ -311,9 +325,10 @@ export function readWorkspaceBudget(workspaceRoot) {
|
|
|
311
325
|
return DEFAULT_CANONICAL_BUDGET;
|
|
312
326
|
}
|
|
313
327
|
const ws = parsed && typeof parsed === 'object' ? parsed.workspace : null;
|
|
314
|
-
if (!ws || typeof ws !== 'object') return
|
|
315
|
-
if (!('canonicalBudgetBytes' in ws)) return
|
|
328
|
+
if (!ws || typeof ws !== 'object') return 0;
|
|
329
|
+
if (!('canonicalBudgetBytes' in ws)) return 0;
|
|
316
330
|
const v = ws.canonicalBudgetBytes;
|
|
331
|
+
if (v === null) return 0;
|
|
317
332
|
if (typeof v !== 'number' || !Number.isFinite(v)) return DEFAULT_CANONICAL_BUDGET;
|
|
318
333
|
if (v <= 0) return 0;
|
|
319
334
|
return Math.floor(v);
|
|
@@ -351,7 +366,8 @@ export function renderCanonicalBody(resolvedItems) {
|
|
|
351
366
|
* 4. Keep stage-3 resolution; status `over-budget`, overBy populated.
|
|
352
367
|
*
|
|
353
368
|
* Special cases:
|
|
354
|
-
* - budgetBytes <= 0
|
|
369
|
+
* - budgetBytes <= 0 (off — absent, null, 0, or negative): stage 1 always
|
|
370
|
+
* wins, selection.budgetBytes = null.
|
|
355
371
|
* - No reference items present and stage 1 fails: status `over-budget`,
|
|
356
372
|
* no transformation possible. Stderr warning is emitted.
|
|
357
373
|
*/
|
|
@@ -372,7 +388,7 @@ export function selectCanonicalContent(items, budgetBytes, opts) {
|
|
|
372
388
|
return { name: item.name, priority: item.priority, content: item.full };
|
|
373
389
|
});
|
|
374
390
|
|
|
375
|
-
//
|
|
391
|
+
// No-budget path (off because unset, or explicitly disabled).
|
|
376
392
|
if (!Number.isFinite(budgetBytes) || budgetBytes <= 0) {
|
|
377
393
|
const resolved = resolveAt(1);
|
|
378
394
|
return {
|
|
@@ -470,7 +486,16 @@ export function selectCanonicalContent(items, budgetBytes, opts) {
|
|
|
470
486
|
function buildCanonical(workspaceRoot) {
|
|
471
487
|
const lockedDir = join(workspaceRoot, WC_DIR, SHARED_DIR, LOCKED_DIR);
|
|
472
488
|
if (!existsSync(lockedDir)) return [];
|
|
473
|
-
const
|
|
489
|
+
const candidates = walkMarkdown(lockedDir).filter((f) => !f.endsWith('.keep')).sort();
|
|
490
|
+
// canonical.md is committed and loaded verbatim into every session prompt, so a
|
|
491
|
+
// gitignored file under shared/locked/ must never reach it. The index builder applies
|
|
492
|
+
// this same filter; without it here, a local-only-*.md dropped into this directory is
|
|
493
|
+
// published to the repo and broadcast to every session.
|
|
494
|
+
const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
|
|
495
|
+
const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
|
|
496
|
+
const files = candidates.filter(
|
|
497
|
+
(f, i) => !gitIgnored.has(candidatePaths[i]) && !isLocalOnlyName(f),
|
|
498
|
+
);
|
|
474
499
|
const items = [];
|
|
475
500
|
for (const f of files) {
|
|
476
501
|
const name = f.split(sep).pop().replace(/\.md$/, '');
|
|
@@ -501,9 +526,9 @@ function summarizeSelection(selection) {
|
|
|
501
526
|
return `over budget by ${selection.overBy} bytes`;
|
|
502
527
|
}
|
|
503
528
|
|
|
504
|
-
function renderCanonical(resolvedItems, selection
|
|
529
|
+
function renderCanonical(resolvedItems, selection) {
|
|
505
530
|
const showBudget = selection && selection.budgetBytes !== null && selection.budgetBytes !== undefined;
|
|
506
|
-
const fmLines = ['---', 'type: canonical'
|
|
531
|
+
const fmLines = ['---', 'type: canonical'];
|
|
507
532
|
if (showBudget) {
|
|
508
533
|
fmLines.push(`budget: ${selection.budgetBytes}`);
|
|
509
534
|
fmLines.push(`status: ${selection.status}`);
|
|
@@ -520,6 +545,8 @@ function renderCanonical(resolvedItems, selection, generatedAt) {
|
|
|
520
545
|
lines.push(
|
|
521
546
|
`> Budget: ${selection.budgetBytes} bytes (body); current: ${selection.currentBytes} bytes; status: ${selection.status} (${summarizeSelection(selection)}).`,
|
|
522
547
|
);
|
|
548
|
+
} else if (selection) {
|
|
549
|
+
lines.push(`> Budget: off; current: ${selection.currentBytes} bytes.`);
|
|
523
550
|
}
|
|
524
551
|
lines.push('');
|
|
525
552
|
|
|
@@ -556,11 +583,10 @@ function buildTeamMemberIndex(workspaceRoot, user) {
|
|
|
556
583
|
return entries;
|
|
557
584
|
}
|
|
558
585
|
|
|
559
|
-
function renderTeamMemberIndex(user, entries
|
|
586
|
+
function renderTeamMemberIndex(user, entries) {
|
|
560
587
|
const lines = [
|
|
561
588
|
'---',
|
|
562
589
|
'type: index',
|
|
563
|
-
`generated: ${generatedAt}`,
|
|
564
590
|
'---',
|
|
565
591
|
'',
|
|
566
592
|
`# ${user}'s context`,
|
|
@@ -590,6 +616,23 @@ function listTeamMembers(workspaceRoot) {
|
|
|
590
616
|
|
|
591
617
|
// ---------- orchestration ----------
|
|
592
618
|
|
|
619
|
+
// Artifacts no longer carry a `generated:` line — it changed on every build,
|
|
620
|
+
// nothing read it, and it made every long-lived branch conflict on files whose
|
|
621
|
+
// content was identical (gh:132). This filter stays so a checkout still holding
|
|
622
|
+
// a pre-fix artifact compares clean on body rather than reporting false staleness.
|
|
623
|
+
// An artifact git is configured to ignore is regenerated per machine, so its
|
|
624
|
+
// absence is expected rather than a failure. Falls back to treating the file
|
|
625
|
+
// as tracked when git is unavailable, which keeps the stricter behaviour.
|
|
626
|
+
function isUntrackedArtifact(root, absPath) {
|
|
627
|
+
const rel = relative(root, absPath).split(sep).join('/');
|
|
628
|
+
const r = spawnSync('git', ['-C', root, 'check-ignore', '-q', rel], { encoding: 'utf-8' });
|
|
629
|
+
return r.status === 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function workspaceRootOf(root) {
|
|
633
|
+
return resolve(root);
|
|
634
|
+
}
|
|
635
|
+
|
|
593
636
|
function fingerprint(content) {
|
|
594
637
|
return content
|
|
595
638
|
.split('\n')
|
|
@@ -597,7 +640,7 @@ function fingerprint(content) {
|
|
|
597
640
|
.join('\n');
|
|
598
641
|
}
|
|
599
642
|
|
|
600
|
-
function regenerateAll(workspaceRoot
|
|
643
|
+
function regenerateAll(workspaceRoot) {
|
|
601
644
|
const wcRoot = join(workspaceRoot, WC_DIR);
|
|
602
645
|
if (!existsSync(wcRoot)) return [];
|
|
603
646
|
|
|
@@ -606,7 +649,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
|
|
|
606
649
|
out.push({
|
|
607
650
|
path: join(wcRoot, INDEX_FILENAME),
|
|
608
651
|
label: 'index.md',
|
|
609
|
-
content: renderSharedIndex(sharedEntries
|
|
652
|
+
content: renderSharedIndex(sharedEntries) + '\n',
|
|
610
653
|
});
|
|
611
654
|
|
|
612
655
|
const canonicalItems = buildCanonical(workspaceRoot);
|
|
@@ -617,7 +660,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
|
|
|
617
660
|
out.push({
|
|
618
661
|
path: join(wcRoot, CANONICAL_FILENAME),
|
|
619
662
|
label: 'canonical.md',
|
|
620
|
-
content: renderCanonical(resolvedItems, selection
|
|
663
|
+
content: renderCanonical(resolvedItems, selection) + '\n',
|
|
621
664
|
selection,
|
|
622
665
|
});
|
|
623
666
|
|
|
@@ -626,7 +669,7 @@ function regenerateAll(workspaceRoot, generatedAt) {
|
|
|
626
669
|
out.push({
|
|
627
670
|
path: join(wcRoot, TEAM_MEMBER_DIR, user, INDEX_FILENAME),
|
|
628
671
|
label: `team-member/${user}/index.md`,
|
|
629
|
-
content: renderTeamMemberIndex(user, entries
|
|
672
|
+
content: renderTeamMemberIndex(user, entries),
|
|
630
673
|
});
|
|
631
674
|
}
|
|
632
675
|
|
|
@@ -640,7 +683,8 @@ function regenerateAll(workspaceRoot, generatedAt) {
|
|
|
640
683
|
* 0 — all artifacts current and canonical body is within budget.
|
|
641
684
|
* 1 — at least one artifact is missing or stale on disk. Run `--write`.
|
|
642
685
|
* 2 — artifacts are current but canonical body exceeds budget after
|
|
643
|
-
* trimming and stubbing eligible reference files
|
|
686
|
+
* trimming and stubbing eligible reference files (only reachable when
|
|
687
|
+
* a budget is set — off means nothing can exceed it). Triage via
|
|
644
688
|
* `/maintenance cleanup`. If both stale and over-budget, exit 1.
|
|
645
689
|
*
|
|
646
690
|
* `--write` always exits 0 on successful regeneration; over-budget is
|
|
@@ -648,14 +692,22 @@ function regenerateAll(workspaceRoot, generatedAt) {
|
|
|
648
692
|
*/
|
|
649
693
|
function main() {
|
|
650
694
|
const args = parseArgs(process.argv);
|
|
651
|
-
const
|
|
652
|
-
const artifacts = regenerateAll(args.root, generatedAt);
|
|
695
|
+
const artifacts = regenerateAll(args.root);
|
|
653
696
|
|
|
654
697
|
if (args.mode === 'check') {
|
|
655
698
|
const stale = [];
|
|
656
699
|
const missing = [];
|
|
700
|
+
const regenerable = [];
|
|
657
701
|
for (const a of artifacts) {
|
|
658
|
-
if (!existsSync(a.path)) {
|
|
702
|
+
if (!existsSync(a.path)) {
|
|
703
|
+
// Per-user indexes are gitignored (gh:132): absent is the normal state
|
|
704
|
+
// on a fresh checkout, not a staleness failure. Reporting them as
|
|
705
|
+
// missing would make --check, and therefore /maintenance and CI, fail
|
|
706
|
+
// on every clone. They are regenerated on demand instead.
|
|
707
|
+
if (isUntrackedArtifact(workspaceRootOf(args.root), a.path)) regenerable.push(a.label);
|
|
708
|
+
else missing.push(a.label);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
659
711
|
const onDisk = readFileSync(a.path, 'utf-8');
|
|
660
712
|
if (fingerprint(onDisk) !== fingerprint(a.content)) stale.push(a.label);
|
|
661
713
|
}
|
|
@@ -674,6 +726,7 @@ function main() {
|
|
|
674
726
|
if (missing.length === 0 && stale.length === 0) {
|
|
675
727
|
const overBudget = sel && sel.status === 'over-budget';
|
|
676
728
|
const payload = { status: 'current', missing: [], stale: [] };
|
|
729
|
+
if (regenerable.length) payload.regenerable = regenerable;
|
|
677
730
|
if (canonicalBlock) payload.canonical = canonicalBlock;
|
|
678
731
|
payload.artifacts = artifacts.length;
|
|
679
732
|
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
@@ -681,6 +734,7 @@ function main() {
|
|
|
681
734
|
}
|
|
682
735
|
|
|
683
736
|
const payload = { status: 'stale', missing, stale };
|
|
737
|
+
if (regenerable.length) payload.regenerable = regenerable;
|
|
684
738
|
if (canonicalBlock) payload.canonical = canonicalBlock;
|
|
685
739
|
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
686
740
|
process.exit(1);
|
|
@@ -709,4 +763,6 @@ export {
|
|
|
709
763
|
fingerprint,
|
|
710
764
|
readDescription,
|
|
711
765
|
stripFrontmatter,
|
|
766
|
+
gitIgnoredPaths,
|
|
767
|
+
isLocalOnlyName,
|
|
712
768
|
};
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The chat record: one file per long-lived chat, holding what that chat owns.
|
|
3
|
+
//
|
|
4
|
+
// The framework assumed one task spanning many chats and built a folder and a
|
|
5
|
+
// tracker file around it. Practice is one chat spanning many tasks (gh:132).
|
|
6
|
+
// The durable per-chat state is small — a declared scope, the concerns it
|
|
7
|
+
// subscribes to, and the tasks currently open under it — and it is
|
|
8
|
+
// machine-local, because chats are.
|
|
9
|
+
//
|
|
10
|
+
// Keyed on sessionId, filed under the chat name. Claude Code lets a session be
|
|
11
|
+
// renamed at any time, so a record keyed only by name is orphaned by a rename
|
|
12
|
+
// and a record keyed only by id is unreadable. The session registry carries
|
|
13
|
+
// both, so the record carries both: the filename stays legible, and
|
|
14
|
+
// reconcile() renames it when the chat's name changes.
|
|
15
|
+
//
|
|
16
|
+
// Lives in workspace-scratchpad/ because it is machine-local and regenerable —
|
|
17
|
+
// losing it costs one re-declaration of scope, not the work.
|
|
18
|
+
//
|
|
19
|
+
// Usage:
|
|
20
|
+
// node chat-record.mjs --root <dir> --list
|
|
21
|
+
// node chat-record.mjs --root <dir> --read <chat-name>
|
|
22
|
+
// node chat-record.mjs --root <dir> --reconcile --session-id <id> --name <n>
|
|
23
|
+
// node chat-record.mjs --root <dir> --whoami
|
|
24
|
+
// node chat-record.mjs --root <dir> --add-task --chat <n> --work-item <id> --branch <b> [--repo <r>]
|
|
25
|
+
// node chat-record.mjs --root <dir> --remove-task --chat <n> --work-item <id> [--repo <r>]
|
|
26
|
+
//
|
|
27
|
+
// --add-task / --remove-task report `action`: "added" | "updated" for
|
|
28
|
+
// --add-task, "removed" | "unchanged" for --remove-task. They also keep the
|
|
29
|
+
// older `updated` boolean / `removed` count fields — `updated` means "an
|
|
30
|
+
// existing entry was replaced", not "anything changed" — but new callers
|
|
31
|
+
// should read `action`. --whoami prints this chat's record name by matching
|
|
32
|
+
// $CLAUDE_CODE_SESSION_ID against the records' sessionId, nothing and exit
|
|
33
|
+
// 1 when there is no match: the `Chat record:` hook line can be missing
|
|
34
|
+
// after context compaction, and this is the recovery path.
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
readFileSync, writeFileSync, existsSync, mkdirSync,
|
|
38
|
+
readdirSync, renameSync, rmSync, realpathSync,
|
|
39
|
+
} from 'node:fs';
|
|
40
|
+
import { join, resolve } from 'node:path';
|
|
41
|
+
import { homedir } from 'node:os';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
|
|
44
|
+
function isMainModule(metaUrl) {
|
|
45
|
+
if (!process.argv[1]) return false;
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
48
|
+
} catch { return false; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CHATS_DIR = join('workspace-scratchpad', 'chats');
|
|
52
|
+
|
|
53
|
+
function chatsDir(root) {
|
|
54
|
+
return join(resolve(root), CHATS_DIR);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function recordPath(root, chatName) {
|
|
58
|
+
return join(chatsDir(root), `${chatName}.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The chat drawer: in-progress designs, braindumps and research that should not
|
|
62
|
+
// reach other chats until promoted. This is why local-only-* piles up at
|
|
63
|
+
// workspace roots today — capture skills invoked from the launcher have nowhere
|
|
64
|
+
// chat-scoped to write.
|
|
65
|
+
function drawerPath(root, chatName) {
|
|
66
|
+
return join(chatsDir(root), chatName);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function emptyRecord(chatName, sessionId) {
|
|
70
|
+
return {
|
|
71
|
+
chat: chatName,
|
|
72
|
+
sessionId,
|
|
73
|
+
scope: { epic: null, labels: [], paths: [] },
|
|
74
|
+
concerns: [],
|
|
75
|
+
tasks: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readRecord(root, chatName) {
|
|
80
|
+
const p = recordPath(root, chatName);
|
|
81
|
+
if (!existsSync(p)) return null;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
84
|
+
} catch {
|
|
85
|
+
// A corrupt record is machine-local and regenerable. Refusing to start a
|
|
86
|
+
// session over it would be worse than losing a scope declaration.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeRecord(root, record) {
|
|
92
|
+
if (!record || !record.chat) throw new Error('writeRecord: record.chat is required');
|
|
93
|
+
const dir = chatsDir(root);
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
writeFileSync(recordPath(root, record.chat), `${JSON.stringify(record, null, 2)}\n`);
|
|
96
|
+
return recordPath(root, record.chat);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function listRecords(root) {
|
|
100
|
+
const dir = chatsDir(root);
|
|
101
|
+
if (!existsSync(dir)) return [];
|
|
102
|
+
return readdirSync(dir)
|
|
103
|
+
.filter((n) => n.endsWith('.json'))
|
|
104
|
+
.map((n) => readRecord(root, n.slice(0, -5)))
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Bring the records on disk into line with the live session.
|
|
110
|
+
*
|
|
111
|
+
* - A record whose `sessionId` matches but whose filename does not is renamed:
|
|
112
|
+
* the chat was renamed, and the state follows it.
|
|
113
|
+
* - A record with no matching live session is stale. When `liveSessionIds` is
|
|
114
|
+
* supplied it is pruned; when it is not, pruning is skipped entirely rather
|
|
115
|
+
* than guessed at — deleting state on incomplete information is worse than
|
|
116
|
+
* leaving it.
|
|
117
|
+
*/
|
|
118
|
+
function reconcile(root, { sessionId, name, liveSessionIds = null } = {}) {
|
|
119
|
+
if (!sessionId || !name) throw new Error('reconcile: sessionId and name are required');
|
|
120
|
+
const result = { renamed: null, created: false, pruned: [] };
|
|
121
|
+
|
|
122
|
+
const existing = listRecords(root);
|
|
123
|
+
const mine = existing.find((r) => r.sessionId === sessionId);
|
|
124
|
+
|
|
125
|
+
if (mine && mine.chat !== name) {
|
|
126
|
+
const oldName = mine.chat;
|
|
127
|
+
const from = recordPath(root, oldName);
|
|
128
|
+
const to = recordPath(root, name);
|
|
129
|
+
|
|
130
|
+
mine.chat = name;
|
|
131
|
+
writeRecord(root, mine);
|
|
132
|
+
if (existsSync(from) && from !== to) rmSync(from);
|
|
133
|
+
|
|
134
|
+
// The drawer travels with the record. A rename that leaves captures behind
|
|
135
|
+
// under the old name strands them somewhere nothing looks.
|
|
136
|
+
const drawerFrom = drawerPath(root, oldName);
|
|
137
|
+
const drawerTo = drawerPath(root, name);
|
|
138
|
+
if (existsSync(drawerFrom) && !existsSync(drawerTo)) renameSync(drawerFrom, drawerTo);
|
|
139
|
+
|
|
140
|
+
result.renamed = { from: oldName, to: name };
|
|
141
|
+
} else if (!mine) {
|
|
142
|
+
writeRecord(root, emptyRecord(name, sessionId));
|
|
143
|
+
result.created = true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (Array.isArray(liveSessionIds)) {
|
|
147
|
+
for (const r of listRecords(root)) {
|
|
148
|
+
if (r.sessionId === sessionId) continue;
|
|
149
|
+
if (!liveSessionIds.includes(r.sessionId)) {
|
|
150
|
+
rmSync(recordPath(root, r.chat), { force: true });
|
|
151
|
+
result.pruned.push(r.chat);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
// Claude Code's session registry: one file per running process, carrying both
|
|
160
|
+
// the stable sessionId and the current, renameable name.
|
|
161
|
+
//
|
|
162
|
+
// Note what this is NOT: a list of chats that exist. It holds only processes
|
|
163
|
+
// running right now, so a chat the operator closed looks exactly like one that
|
|
164
|
+
// never existed. That is why reconcile() will not prune from it unless a caller
|
|
165
|
+
// explicitly says to.
|
|
166
|
+
function readSessionRegistry(homeDir = homedir()) {
|
|
167
|
+
const dir = join(homeDir, '.claude', 'sessions');
|
|
168
|
+
if (!existsSync(dir)) return [];
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const name of readdirSync(dir)) {
|
|
171
|
+
if (!name.endsWith('.json')) continue;
|
|
172
|
+
try {
|
|
173
|
+
const rec = JSON.parse(readFileSync(join(dir, name), 'utf-8'));
|
|
174
|
+
if (rec && rec.sessionId) out.push(rec);
|
|
175
|
+
} catch { /* a half-written registry file is not our problem to fix */ }
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Decide the name a chat's record should live under. The registry's name
|
|
182
|
+
* wins when it has one; otherwise an existing record for the same
|
|
183
|
+
* sessionId keeps its own name — a chat the registry has not named yet
|
|
184
|
+
* must not have its named record relabeled with the raw UUID. A genuinely
|
|
185
|
+
* new, unnamed chat falls back to the id so a record still exists.
|
|
186
|
+
*/
|
|
187
|
+
function resolveChatName(root, { sessionId, registryName = null } = {}) {
|
|
188
|
+
if (registryName) return registryName;
|
|
189
|
+
const existing = listRecords(root).find((r) => r.sessionId === sessionId);
|
|
190
|
+
return existing ? existing.chat : sessionId;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
// A task is a tracker issue plus a branch plus the repo it lands in. It is
|
|
195
|
+
// created on demand and disappears when it merges — nothing about it is
|
|
196
|
+
// durable except the issue and the commits, so the record holds only the
|
|
197
|
+
// pointer, never a copy of the issue.
|
|
198
|
+
//
|
|
199
|
+
// Identity is workItem + repo: the same issue can legitimately be open against
|
|
200
|
+
// two repos in a multi-repo task, and re-recording the same one must update
|
|
201
|
+
// rather than duplicate. /start-work is not always run exactly once.
|
|
202
|
+
function addTask(root, chatName, { workItem, branch, repo = null } = {}) {
|
|
203
|
+
if (!workItem) throw new Error('addTask: workItem is required');
|
|
204
|
+
if (!branch) throw new Error('addTask: branch is required');
|
|
205
|
+
const rec = readRecord(root, chatName);
|
|
206
|
+
if (!rec) throw new Error(`addTask: no chat record for "${chatName}"`);
|
|
207
|
+
const i = rec.tasks.findIndex((t) => t.workItem === workItem && (t.repo ?? null) === repo);
|
|
208
|
+
const task = { workItem, branch, repo };
|
|
209
|
+
if (i >= 0) rec.tasks[i] = task;
|
|
210
|
+
else rec.tasks.push(task);
|
|
211
|
+
writeRecord(root, rec);
|
|
212
|
+
return { record: rec, action: i >= 0 ? 'updated' : 'added', updated: i >= 0 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function removeTask(root, chatName, { workItem, repo = null } = {}) {
|
|
216
|
+
if (!workItem) throw new Error('removeTask: workItem is required');
|
|
217
|
+
const rec = readRecord(root, chatName);
|
|
218
|
+
if (!rec) throw new Error(`removeTask: no chat record for "${chatName}"`);
|
|
219
|
+
const before = rec.tasks.length;
|
|
220
|
+
rec.tasks = rec.tasks.filter((t) => !(t.workItem === workItem && (t.repo ?? null) === repo));
|
|
221
|
+
writeRecord(root, rec);
|
|
222
|
+
return { record: rec, action: before - rec.tasks.length > 0 ? 'removed' : 'unchanged', removed: before - rec.tasks.length };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The record name for the chat running now. The SessionStart hook injects
|
|
226
|
+
// a `Chat record:` line, but compaction can drop it; the sessionId the
|
|
227
|
+
// hook keyed on is stable, so matching it against the records recovers the
|
|
228
|
+
// name without guessing. Null when the env var is unset or nothing matches.
|
|
229
|
+
function whoami(root, { env = process.env } = {}) {
|
|
230
|
+
const sid = env.CLAUDE_CODE_SESSION_ID;
|
|
231
|
+
if (!sid) return null;
|
|
232
|
+
const mine = listRecords(root).find((r) => r.sessionId === sid);
|
|
233
|
+
return mine ? mine.chat : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Scope is what a chat declares it owns. The Aug 26 coordination burst had
|
|
237
|
+
// sessions declaring this by hand in chat messages; recording it makes it
|
|
238
|
+
// answerable without asking.
|
|
239
|
+
function setScope(root, chatName, { epic = null, labels = [], paths = [] } = {}) {
|
|
240
|
+
const rec = readRecord(root, chatName);
|
|
241
|
+
if (!rec) throw new Error(`setScope: no chat record for "${chatName}"`);
|
|
242
|
+
rec.scope = { epic, labels, paths };
|
|
243
|
+
writeRecord(root, rec);
|
|
244
|
+
return rec;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseArgs(argv) {
|
|
248
|
+
const args = { root: '.', mode: null, chat: null, sessionId: null, name: null, workItem: null, branch: null, repo: null };
|
|
249
|
+
const rest = argv.slice(2);
|
|
250
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
251
|
+
const a = rest[i];
|
|
252
|
+
if (a === '--root') { args.root = rest[++i]; continue; }
|
|
253
|
+
if (a === '--list') { args.mode = 'list'; continue; }
|
|
254
|
+
if (a === '--read') { args.mode = 'read'; args.chat = rest[++i]; continue; }
|
|
255
|
+
if (a === '--reconcile') { args.mode = 'reconcile'; continue; }
|
|
256
|
+
if (a === '--whoami') { args.mode = 'whoami'; continue; }
|
|
257
|
+
if (a === '--add-task') { args.mode = 'add-task'; continue; }
|
|
258
|
+
if (a === '--remove-task') { args.mode = 'remove-task'; continue; }
|
|
259
|
+
if (a === '--chat') { args.chat = rest[++i]; continue; }
|
|
260
|
+
if (a === '--work-item') { args.workItem = rest[++i]; continue; }
|
|
261
|
+
if (a === '--branch') { args.branch = rest[++i]; continue; }
|
|
262
|
+
if (a === '--repo') { args.repo = rest[++i]; continue; }
|
|
263
|
+
if (a === '--session-id') { args.sessionId = rest[++i]; continue; }
|
|
264
|
+
if (a === '--name') { args.name = rest[++i]; continue; }
|
|
265
|
+
throw new Error(`unknown argument: ${a}`);
|
|
266
|
+
}
|
|
267
|
+
if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile, --whoami is required');
|
|
268
|
+
if (args.mode === 'reconcile' && (!args.sessionId || !args.name)) {
|
|
269
|
+
throw new Error('--reconcile requires --session-id and --name');
|
|
270
|
+
}
|
|
271
|
+
if (args.mode === 'add-task' && (!args.chat || !args.workItem || !args.branch)) {
|
|
272
|
+
throw new Error('--add-task requires --chat, --work-item and --branch');
|
|
273
|
+
}
|
|
274
|
+
if (args.mode === 'remove-task' && (!args.chat || !args.workItem)) {
|
|
275
|
+
throw new Error('--remove-task requires --chat and --work-item');
|
|
276
|
+
}
|
|
277
|
+
return args;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function main() {
|
|
281
|
+
const args = parseArgs(process.argv);
|
|
282
|
+
if (args.mode === 'whoami') {
|
|
283
|
+
// A bare name, not JSON: the caller wants something to put on a command
|
|
284
|
+
// line. No match prints nothing and exits 1 — the caller decides what
|
|
285
|
+
// an unidentified chat means.
|
|
286
|
+
const name = whoami(args.root);
|
|
287
|
+
if (name === null) process.exit(1);
|
|
288
|
+
process.stdout.write(`${name}\n`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
let out;
|
|
292
|
+
if (args.mode === 'list') out = listRecords(args.root);
|
|
293
|
+
else if (args.mode === 'read') out = readRecord(args.root, args.chat);
|
|
294
|
+
else if (args.mode === 'add-task') {
|
|
295
|
+
out = addTask(args.root, args.chat, { workItem: args.workItem, branch: args.branch, repo: args.repo });
|
|
296
|
+
} else if (args.mode === 'remove-task') {
|
|
297
|
+
out = removeTask(args.root, args.chat, { workItem: args.workItem, repo: args.repo });
|
|
298
|
+
} else out = reconcile(args.root, { sessionId: args.sessionId, name: args.name });
|
|
299
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (isMainModule(import.meta.url)) {
|
|
303
|
+
try {
|
|
304
|
+
main();
|
|
305
|
+
} catch (err) {
|
|
306
|
+
process.stderr.write(`chat-record: ${err.message}\n`);
|
|
307
|
+
process.exit(2);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export {
|
|
312
|
+
recordPath, drawerPath, emptyRecord, readRecord, writeRecord,
|
|
313
|
+
listRecords, reconcile, parseArgs, readSessionRegistry, resolveChatName,
|
|
314
|
+
addTask, removeTask, setScope, whoami, CHATS_DIR,
|
|
315
|
+
};
|