@ulysses-ai/create-workspace 0.13.0-beta.2 → 0.14.0-beta.3
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 +15 -3
- package/package.json +1 -1
- package/template/.claude/hooks/_bash-output-advisory.test.mjs +88 -0
- package/template/.claude/hooks/bash-output-advisory.mjs +77 -0
- package/template/.claude/hooks/version-freshness-check.mjs +30 -0
- package/template/.claude/lib/freshness.mjs +75 -0
- package/template/.claude/lib/freshness.test.mjs +175 -0
- package/template/.claude/lib/registry-check.mjs +106 -0
- package/template/.claude/lib/registry-check.test.mjs +130 -0
- package/template/.claude/rules/memory-guidance.md +47 -0
- package/template/.claude/rules/task-list-mirroring.md +69 -0
- package/template/.claude/rules/token-economics.md.skip +23 -8
- package/template/.claude/rules/workspace-structure.md +2 -0
- package/template/.claude/scripts/build-shared-context-index.mjs +212 -0
- package/template/.claude/scripts/build-shared-context-index.test.mjs +318 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.mjs +30 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +54 -0
- package/template/.claude/scripts/sync-tasks.mjs +234 -0
- package/template/.claude/scripts/sync-tasks.test.mjs +350 -0
- package/template/.claude/settings.json +20 -9
- package/template/.claude/skills/braindump/SKILL.md +15 -0
- package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
- package/template/.claude/skills/build-docs-site/checklists/pitfalls.md +4 -4
- package/template/.claude/skills/complete-work/SKILL.md +47 -55
- package/template/.claude/skills/handoff/SKILL.md +15 -0
- package/template/.claude/skills/maintenance/SKILL.md +49 -7
- package/template/.claude/skills/pause-work/SKILL.md +25 -4
- package/template/.claude/skills/release/SKILL.md +59 -43
- package/template/.claude/skills/start-work/SKILL.md +34 -2
- package/template/.claude/skills/workspace-update/SKILL.md +16 -0
- package/template/CLAUDE.md.tmpl +1 -0
- package/template/_gitignore +2 -3
- package/template/workspace.json.tmpl +1 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for build-shared-context-index.mjs
|
|
3
|
+
// Run: node template/.claude/scripts/build-shared-context-index.test.mjs
|
|
4
|
+
|
|
5
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { buildEntries, renderIndex, fingerprint, readDescription } from './build-shared-context-index.mjs';
|
|
9
|
+
|
|
10
|
+
let failed = 0;
|
|
11
|
+
let passed = 0;
|
|
12
|
+
|
|
13
|
+
function assert(cond, msg) {
|
|
14
|
+
if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assertEq(actual, expected, msg) {
|
|
18
|
+
const a = JSON.stringify(actual);
|
|
19
|
+
const e = JSON.stringify(expected);
|
|
20
|
+
if (a === e) { passed++; } else {
|
|
21
|
+
failed++;
|
|
22
|
+
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function setupFixture() {
|
|
27
|
+
const root = mkdtempSync(join(tmpdir(), 'sc-index-test-'));
|
|
28
|
+
mkdirSync(join(root, 'shared-context'), { recursive: true });
|
|
29
|
+
mkdirSync(join(root, 'shared-context', 'locked'), { recursive: true });
|
|
30
|
+
mkdirSync(join(root, 'shared-context', 'alice'), { recursive: true });
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanup(root) {
|
|
35
|
+
rmSync(root, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log('# readDescription priority');
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
const root = setupFixture();
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(root, 'shared-context', 'with-fm.md'),
|
|
44
|
+
`---
|
|
45
|
+
state: locked
|
|
46
|
+
type: reference
|
|
47
|
+
description: Frontmatter description wins.
|
|
48
|
+
updated: 2026-04-25
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
# Title
|
|
52
|
+
|
|
53
|
+
This is the body.
|
|
54
|
+
`,
|
|
55
|
+
);
|
|
56
|
+
const desc = readDescription(join(root, 'shared-context', 'with-fm.md'));
|
|
57
|
+
assertEq(desc, 'Frontmatter description wins.', 'frontmatter description preferred');
|
|
58
|
+
cleanup(root);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
const root = setupFixture();
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(root, 'shared-context', 'no-fm-desc.md'),
|
|
65
|
+
`---
|
|
66
|
+
state: ephemeral
|
|
67
|
+
type: braindump
|
|
68
|
+
updated: 2026-04-25
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
# Some Title
|
|
72
|
+
|
|
73
|
+
This is the first paragraph that should be used. It has multiple sentences.
|
|
74
|
+
|
|
75
|
+
This is a second paragraph.
|
|
76
|
+
`,
|
|
77
|
+
);
|
|
78
|
+
const desc = readDescription(join(root, 'shared-context', 'no-fm-desc.md'));
|
|
79
|
+
assertEq(desc, 'This is the first paragraph that should be used.', 'first sentence fallback');
|
|
80
|
+
cleanup(root);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
const root = setupFixture();
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(root, 'shared-context', 'no-frontmatter.md'),
|
|
87
|
+
`# Bare File
|
|
88
|
+
|
|
89
|
+
Content without frontmatter at all.
|
|
90
|
+
`,
|
|
91
|
+
);
|
|
92
|
+
const desc = readDescription(join(root, 'shared-context', 'no-frontmatter.md'));
|
|
93
|
+
assertEq(desc, 'Content without frontmatter at all.', 'works without frontmatter');
|
|
94
|
+
cleanup(root);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
const root = setupFixture();
|
|
99
|
+
writeFileSync(join(root, 'shared-context', 'empty-body.md'), `---
|
|
100
|
+
type: index
|
|
101
|
+
---
|
|
102
|
+
`);
|
|
103
|
+
const desc = readDescription(join(root, 'shared-context', 'empty-body.md'));
|
|
104
|
+
assertEq(desc, 'empty body', 'filename slug fallback');
|
|
105
|
+
cleanup(root);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log('# buildEntries grouping & sort');
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
const root = setupFixture();
|
|
112
|
+
writeFileSync(
|
|
113
|
+
join(root, 'shared-context', 'locked', 'project-status.md'),
|
|
114
|
+
`---
|
|
115
|
+
description: Project status here.
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
body
|
|
119
|
+
`,
|
|
120
|
+
);
|
|
121
|
+
writeFileSync(
|
|
122
|
+
join(root, 'shared-context', 'locked', 'naming.md'),
|
|
123
|
+
`---
|
|
124
|
+
description: Naming convention.
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
body
|
|
128
|
+
`,
|
|
129
|
+
);
|
|
130
|
+
writeFileSync(
|
|
131
|
+
join(root, 'shared-context', 'inventory.md'),
|
|
132
|
+
`---
|
|
133
|
+
description: Top-level inventory.
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
body
|
|
137
|
+
`,
|
|
138
|
+
);
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(root, 'shared-context', 'alice', 'notes.md'),
|
|
141
|
+
`---
|
|
142
|
+
description: Alice notes.
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
body
|
|
146
|
+
`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const entries = buildEntries(root);
|
|
150
|
+
assertEq(entries.length, 4, '4 entries');
|
|
151
|
+
assertEq(entries[0].group, 'locked', 'locked first');
|
|
152
|
+
assertEq(entries[0].sortKey, 'naming.md', 'alphabetical within locked (naming before project)');
|
|
153
|
+
assertEq(entries[1].sortKey, 'project-status.md', 'second locked entry');
|
|
154
|
+
assertEq(entries[2].group, '__root__', 'root group second');
|
|
155
|
+
assertEq(entries[3].group, 'alice', 'user dir last');
|
|
156
|
+
|
|
157
|
+
cleanup(root);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log('# renderIndex output');
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
const entries = [
|
|
164
|
+
{ group: 'locked', sortKey: 'a.md', relativePath: 'locked/a.md', description: 'A.' },
|
|
165
|
+
{ group: '__root__', sortKey: 'b.md', relativePath: 'b.md', description: 'B.' },
|
|
166
|
+
{ group: 'alice', sortKey: 'c.md', relativePath: 'alice/c.md', description: 'C.' },
|
|
167
|
+
];
|
|
168
|
+
const output = renderIndex(entries, '2026-04-25T00:00:00Z');
|
|
169
|
+
assert(output.includes('## Locked (team truths, always loaded)'), 'has locked heading');
|
|
170
|
+
assert(output.includes('## Team-shared (root)'), 'has team-shared heading');
|
|
171
|
+
assert(output.includes('## alice'), 'has user dir heading');
|
|
172
|
+
assert(output.includes('- [locked/a.md](locked/a.md) — A.'), 'links use relative path');
|
|
173
|
+
assert(output.includes('generated: 2026-04-25T00:00:00Z'), 'frontmatter has generated timestamp');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
const output = renderIndex([], '2026-04-25T00:00:00Z');
|
|
178
|
+
assert(output.includes('_(no shared-context files yet)_'), 'empty state');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log('# fingerprint ignores generated line');
|
|
182
|
+
|
|
183
|
+
{
|
|
184
|
+
const a = `---
|
|
185
|
+
type: index
|
|
186
|
+
generated: 2026-04-25T00:00:00Z
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
body
|
|
190
|
+
`;
|
|
191
|
+
const b = `---
|
|
192
|
+
type: index
|
|
193
|
+
generated: 2026-04-26T00:00:00Z
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
body
|
|
197
|
+
`;
|
|
198
|
+
assertEq(fingerprint(a), fingerprint(b), 'different generated, same body → same fingerprint');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
const a = `---
|
|
203
|
+
generated: 2026-04-25T00:00:00Z
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
body one
|
|
207
|
+
`;
|
|
208
|
+
const b = `---
|
|
209
|
+
generated: 2026-04-25T00:00:00Z
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
body two
|
|
213
|
+
`;
|
|
214
|
+
assert(fingerprint(a) !== fingerprint(b), 'different body → different fingerprint');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.log('# missing shared-context dir');
|
|
218
|
+
|
|
219
|
+
{
|
|
220
|
+
const root = mkdtempSync(join(tmpdir(), 'sc-index-empty-'));
|
|
221
|
+
const entries = buildEntries(root);
|
|
222
|
+
assertEq(entries, [], 'no shared-context dir yields empty entries');
|
|
223
|
+
cleanup(root);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log('# .indexignore prefix excludes');
|
|
227
|
+
|
|
228
|
+
{
|
|
229
|
+
const root = setupFixture();
|
|
230
|
+
mkdirSync(join(root, 'shared-context', 'archive'), { recursive: true });
|
|
231
|
+
writeFileSync(
|
|
232
|
+
join(root, 'shared-context', 'archive', 'old1.md'),
|
|
233
|
+
`---
|
|
234
|
+
description: Archived 1.
|
|
235
|
+
---
|
|
236
|
+
body
|
|
237
|
+
`,
|
|
238
|
+
);
|
|
239
|
+
writeFileSync(
|
|
240
|
+
join(root, 'shared-context', 'archive', 'old2.md'),
|
|
241
|
+
`---
|
|
242
|
+
description: Archived 2.
|
|
243
|
+
---
|
|
244
|
+
body
|
|
245
|
+
`,
|
|
246
|
+
);
|
|
247
|
+
writeFileSync(
|
|
248
|
+
join(root, 'shared-context', 'live.md'),
|
|
249
|
+
`---
|
|
250
|
+
description: Live file.
|
|
251
|
+
---
|
|
252
|
+
body
|
|
253
|
+
`,
|
|
254
|
+
);
|
|
255
|
+
writeFileSync(join(root, 'shared-context', '.indexignore'), 'archive/\n# comment\n\n');
|
|
256
|
+
|
|
257
|
+
const entries = buildEntries(root);
|
|
258
|
+
assertEq(entries.length, 1, 'archive/ excluded by .indexignore');
|
|
259
|
+
assertEq(entries[0].relativePath, 'live.md', 'only live.md survives');
|
|
260
|
+
cleanup(root);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
{
|
|
264
|
+
const root = setupFixture();
|
|
265
|
+
writeFileSync(
|
|
266
|
+
join(root, 'shared-context', 'a.md'),
|
|
267
|
+
`---
|
|
268
|
+
description: A.
|
|
269
|
+
---
|
|
270
|
+
body
|
|
271
|
+
`,
|
|
272
|
+
);
|
|
273
|
+
writeFileSync(
|
|
274
|
+
join(root, 'shared-context', 'b.md'),
|
|
275
|
+
`---
|
|
276
|
+
description: B.
|
|
277
|
+
---
|
|
278
|
+
body
|
|
279
|
+
`,
|
|
280
|
+
);
|
|
281
|
+
writeFileSync(join(root, 'shared-context', '.indexignore'), 'a.md\n');
|
|
282
|
+
const entries = buildEntries(root);
|
|
283
|
+
assertEq(entries.length, 1, 'specific file excluded by .indexignore');
|
|
284
|
+
assertEq(entries[0].relativePath, 'b.md', 'only b.md survives');
|
|
285
|
+
cleanup(root);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
console.log('# index.md is excluded from its own entries');
|
|
289
|
+
|
|
290
|
+
{
|
|
291
|
+
const root = setupFixture();
|
|
292
|
+
writeFileSync(
|
|
293
|
+
join(root, 'shared-context', 'index.md'),
|
|
294
|
+
`---
|
|
295
|
+
type: index
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
# index
|
|
299
|
+
`,
|
|
300
|
+
);
|
|
301
|
+
writeFileSync(
|
|
302
|
+
join(root, 'shared-context', 'real.md'),
|
|
303
|
+
`---
|
|
304
|
+
description: Real file.
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
body
|
|
308
|
+
`,
|
|
309
|
+
);
|
|
310
|
+
const entries = buildEntries(root);
|
|
311
|
+
assertEq(entries.length, 1, 'only the non-index file');
|
|
312
|
+
assertEq(entries[0].relativePath, 'real.md', 'index.md excluded');
|
|
313
|
+
cleanup(root);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
console.log('');
|
|
317
|
+
console.log(`${passed} passed, ${failed} failed`);
|
|
318
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Idempotent migrator: ensures CLAUDE.md includes @local-only-template-freshness.md.
|
|
3
|
+
// Appends one line at end if missing. Preserves the rest of the file byte-for-byte.
|
|
4
|
+
//
|
|
5
|
+
// Run standalone: node .claude/scripts/migrate-claude-md-freshness-include.mjs
|
|
6
|
+
// Or import { runMigration } and call programmatically.
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
8
|
+
import { join, dirname, resolve } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const INCLUDE_LINE = '@local-only-template-freshness.md';
|
|
12
|
+
|
|
13
|
+
export function runMigration({ workspaceRoot }) {
|
|
14
|
+
const path = join(workspaceRoot, 'CLAUDE.md');
|
|
15
|
+
if (!existsSync(path)) return { action: 'skipped', reason: 'no-claude-md' };
|
|
16
|
+
const before = readFileSync(path, 'utf-8');
|
|
17
|
+
if (before.includes(INCLUDE_LINE)) return { action: 'unchanged' };
|
|
18
|
+
const after = before.endsWith('\n') ? before + INCLUDE_LINE + '\n' : before + '\n' + INCLUDE_LINE + '\n';
|
|
19
|
+
writeFileSync(path, after);
|
|
20
|
+
return { action: 'appended' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// CLI entry point — workspace root is two levels up from this file
|
|
24
|
+
// (.claude/scripts/migrate-... → workspace root).
|
|
25
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const root = resolve(here, '..', '..');
|
|
28
|
+
const result = runMigration({ workspaceRoot: root });
|
|
29
|
+
console.log(JSON.stringify(result));
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for migrate-claude-md-freshness-include.mjs
|
|
3
|
+
// Run: node template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs
|
|
4
|
+
import { runMigration } from './migrate-claude-md-freshness-include.mjs';
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
|
|
9
|
+
let failed = 0;
|
|
10
|
+
let passed = 0;
|
|
11
|
+
function assertEq(actual, expected, msg) {
|
|
12
|
+
const a = JSON.stringify(actual);
|
|
13
|
+
const e = JSON.stringify(expected);
|
|
14
|
+
if (a === e) { passed++; } else {
|
|
15
|
+
failed++;
|
|
16
|
+
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function withTemp(fn) {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), 'claude-md-mig-'));
|
|
22
|
+
try { fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log('# migrate-claude-md-freshness-include');
|
|
26
|
+
|
|
27
|
+
// CLAUDE.md missing → no-op
|
|
28
|
+
withTemp((dir) => {
|
|
29
|
+
const result = runMigration({ workspaceRoot: dir });
|
|
30
|
+
assertEq(result.action, 'skipped', 'no CLAUDE.md is skipped');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// CLAUDE.md missing the include → appended
|
|
34
|
+
withTemp((dir) => {
|
|
35
|
+
const path = join(dir, 'CLAUDE.md');
|
|
36
|
+
writeFileSync(path, '# Workspace\n@workspace.json\n');
|
|
37
|
+
const result = runMigration({ workspaceRoot: dir });
|
|
38
|
+
assertEq(result.action, 'appended', 'missing line is appended');
|
|
39
|
+
const after = readFileSync(path, 'utf-8');
|
|
40
|
+
assertEq(after.includes('@local-only-template-freshness.md'), true, 'line present after migration');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// CLAUDE.md already has the include → no-op
|
|
44
|
+
withTemp((dir) => {
|
|
45
|
+
const path = join(dir, 'CLAUDE.md');
|
|
46
|
+
const original = '# Workspace\n@workspace.json\n@local-only-template-freshness.md\n';
|
|
47
|
+
writeFileSync(path, original);
|
|
48
|
+
const result = runMigration({ workspaceRoot: dir });
|
|
49
|
+
assertEq(result.action, 'unchanged', 'already-present line is unchanged');
|
|
50
|
+
assertEq(readFileSync(path, 'utf-8'), original, 'file content unchanged');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
54
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Mirror TodoWrite ↔ session.md ## Tasks section.
|
|
2
|
+
// Round-trips a flat task list across chats by persisting it on the session branch.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, renameSync } from 'fs';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { readSessionFile } from '../lib/session-frontmatter.mjs';
|
|
7
|
+
import { createTracker } from './trackers/interface.mjs';
|
|
8
|
+
|
|
9
|
+
const IRREGULARS = {
|
|
10
|
+
// Pre-built map for verbs whose gerund isn't a clean suffix transform.
|
|
11
|
+
// Add entries as needed; the rule of thumb is "if the test catches it, fix here".
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function toActiveForm(content) {
|
|
15
|
+
const trimmed = content.trim();
|
|
16
|
+
const firstSpace = trimmed.indexOf(' ');
|
|
17
|
+
const verb = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
|
|
18
|
+
const rest = firstSpace === -1 ? '' : trimmed.slice(firstSpace);
|
|
19
|
+
|
|
20
|
+
const lower = verb.toLowerCase();
|
|
21
|
+
if (IRREGULARS[lower]) return IRREGULARS[lower] + rest;
|
|
22
|
+
|
|
23
|
+
let gerund;
|
|
24
|
+
if (verb.endsWith('e') && !verb.endsWith('ee')) {
|
|
25
|
+
gerund = verb.slice(0, -1) + 'ing';
|
|
26
|
+
} else if (
|
|
27
|
+
verb.length >= 3 &&
|
|
28
|
+
/[aeiou]/.test(verb[verb.length - 2]) &&
|
|
29
|
+
!/[aeiouwxy]/.test(verb[verb.length - 1])
|
|
30
|
+
) {
|
|
31
|
+
// CVC pattern → double the final consonant (Run → Running)
|
|
32
|
+
// Skip when ending in w/x/y (Show → Showing, Fix → Fixing).
|
|
33
|
+
gerund = verb + verb[verb.length - 1] + 'ing';
|
|
34
|
+
} else {
|
|
35
|
+
gerund = verb + 'ing';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return gerund + rest;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TASKS_HEADING = '## Tasks';
|
|
42
|
+
const LINK_PREFIX = '> Linked:';
|
|
43
|
+
|
|
44
|
+
export function parseTasksSection(sessionMdContent) {
|
|
45
|
+
const lines = sessionMdContent.split('\n');
|
|
46
|
+
const startIdx = lines.findIndex(l => l.trim() === TASKS_HEADING);
|
|
47
|
+
if (startIdx === -1) return { linked: null, todos: [] };
|
|
48
|
+
|
|
49
|
+
// Section runs until the next "## " heading or EOF.
|
|
50
|
+
let endIdx = lines.length;
|
|
51
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
52
|
+
if (lines[i].startsWith('## ')) { endIdx = i; break; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const sectionLines = lines.slice(startIdx + 1, endIdx);
|
|
56
|
+
let linked = null;
|
|
57
|
+
const todos = [];
|
|
58
|
+
|
|
59
|
+
for (const line of sectionLines) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (!trimmed) continue;
|
|
62
|
+
|
|
63
|
+
if (trimmed.startsWith(LINK_PREFIX)) {
|
|
64
|
+
const rest = trimmed.slice(LINK_PREFIX.length).trim();
|
|
65
|
+
const dashIdx = rest.indexOf(' — ');
|
|
66
|
+
if (dashIdx === -1) {
|
|
67
|
+
linked = { id: rest, title: null };
|
|
68
|
+
} else {
|
|
69
|
+
linked = { id: rest.slice(0, dashIdx).trim(), title: rest.slice(dashIdx + 3).trim() };
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const checkboxMatch = trimmed.match(/^- \[([ x\-])\] (.+)$/);
|
|
75
|
+
if (checkboxMatch) {
|
|
76
|
+
const marker = checkboxMatch[1];
|
|
77
|
+
const status = marker === 'x' ? 'completed' : marker === '-' ? 'in_progress' : 'pending';
|
|
78
|
+
const content = checkboxMatch[2].trim();
|
|
79
|
+
todos.push({ content, activeForm: toActiveForm(content), status });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { linked, todos };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const START_BOOKEND = { content: 'Start work', activeForm: 'Starting work', status: 'completed' };
|
|
87
|
+
const END_BOOKEND = { content: 'Complete work', activeForm: 'Completing work', status: 'pending' };
|
|
88
|
+
|
|
89
|
+
export function enforceBookends(todos) {
|
|
90
|
+
const middle = [];
|
|
91
|
+
let foundStart = null;
|
|
92
|
+
let foundEnd = null;
|
|
93
|
+
for (const t of todos) {
|
|
94
|
+
if (t.content === 'Start work') foundStart = t;
|
|
95
|
+
else if (t.content === 'Complete work') foundEnd = t;
|
|
96
|
+
else middle.push(t);
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
foundStart || { ...START_BOOKEND },
|
|
100
|
+
...middle,
|
|
101
|
+
foundEnd || { ...END_BOOKEND },
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderTasksSection({ linked, todos }) {
|
|
106
|
+
const safe = enforceBookends(todos);
|
|
107
|
+
const lines = ['## Tasks', ''];
|
|
108
|
+
if (linked) {
|
|
109
|
+
if (linked.title) {
|
|
110
|
+
lines.push(`> Linked: ${linked.id} — ${linked.title}`);
|
|
111
|
+
} else {
|
|
112
|
+
lines.push(`> Linked: ${linked.id}`);
|
|
113
|
+
}
|
|
114
|
+
lines.push('');
|
|
115
|
+
}
|
|
116
|
+
for (const t of safe) {
|
|
117
|
+
const box = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[-]' : '[ ]';
|
|
118
|
+
lines.push(`- ${box} ${t.content}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push('');
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function writeTasksToSession(filePath, taskState) {
|
|
125
|
+
const original = readFileSync(filePath, 'utf-8');
|
|
126
|
+
const newSection = renderTasksSection(taskState);
|
|
127
|
+
const updated = spliceTasksSection(original, newSection);
|
|
128
|
+
|
|
129
|
+
// Atomic write: temp file in same dir + rename.
|
|
130
|
+
const tmp = filePath + '.tmp-sync-tasks';
|
|
131
|
+
writeFileSync(tmp, updated);
|
|
132
|
+
renameSync(tmp, filePath);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function spliceTasksSection(content, newSection) {
|
|
136
|
+
const lines = content.split('\n');
|
|
137
|
+
const startIdx = lines.findIndex(l => l.trim() === '## Tasks');
|
|
138
|
+
|
|
139
|
+
if (startIdx === -1) {
|
|
140
|
+
// Insert before ## Progress if present, else append before EOF.
|
|
141
|
+
const progressIdx = lines.findIndex(l => l.trim() === '## Progress');
|
|
142
|
+
if (progressIdx !== -1) {
|
|
143
|
+
const before = lines.slice(0, progressIdx).join('\n').replace(/\n+$/, '\n');
|
|
144
|
+
const after = lines.slice(progressIdx).join('\n');
|
|
145
|
+
return before + '\n' + newSection + '\n' + after;
|
|
146
|
+
}
|
|
147
|
+
// No Progress section — append at end.
|
|
148
|
+
return content.replace(/\n*$/, '\n\n') + newSection;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Find end of existing ## Tasks section (next ## heading or EOF).
|
|
152
|
+
let endIdx = lines.length;
|
|
153
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
154
|
+
if (lines[i].startsWith('## ')) { endIdx = i; break; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const before = lines.slice(0, startIdx).join('\n');
|
|
158
|
+
const after = endIdx < lines.length ? lines.slice(endIdx).join('\n') : '';
|
|
159
|
+
const beforeTrimmed = before.replace(/\n+$/, '\n');
|
|
160
|
+
return beforeTrimmed + '\n' + newSection + (after ? '\n' + after : '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function resolveLinked(filePath, { tracker } = {}) {
|
|
164
|
+
const { fields } = readSessionFile(filePath);
|
|
165
|
+
if (!fields.workItem) return null;
|
|
166
|
+
const id = fields.workItem;
|
|
167
|
+
if (!tracker) return { id, title: null };
|
|
168
|
+
try {
|
|
169
|
+
const issue = await tracker.getIssue(id);
|
|
170
|
+
return { id, title: issue?.title || null };
|
|
171
|
+
} catch {
|
|
172
|
+
return { id, title: null };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function main() {
|
|
177
|
+
const args = process.argv.slice(2);
|
|
178
|
+
const mode = args[0];
|
|
179
|
+
const filePath = args[1];
|
|
180
|
+
|
|
181
|
+
if (!mode || !filePath || (mode !== '--read' && mode !== '--write')) {
|
|
182
|
+
console.error('Usage: sync-tasks.mjs --read|--write <session.md>');
|
|
183
|
+
process.exit(2);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let fields;
|
|
187
|
+
try {
|
|
188
|
+
fields = readSessionFile(filePath).fields;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
console.error(`Not a session file: ${e.message}`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
if (fields.type !== 'session-tracker') {
|
|
194
|
+
console.error(`Not a session-tracker file (type=${fields.type})`);
|
|
195
|
+
process.exit(2);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let tracker = null;
|
|
199
|
+
try {
|
|
200
|
+
const ws = JSON.parse(readFileSync('workspace.json', 'utf-8'));
|
|
201
|
+
if (ws.workspace?.tracker) tracker = createTracker(ws.workspace.tracker);
|
|
202
|
+
} catch {
|
|
203
|
+
// No workspace.json or no tracker configured — skip.
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (mode === '--read') {
|
|
207
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
208
|
+
const parsed = parseTasksSection(content);
|
|
209
|
+
if (!parsed.linked) {
|
|
210
|
+
parsed.linked = await resolveLinked(filePath, { tracker });
|
|
211
|
+
}
|
|
212
|
+
process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const stdin = await readStdin();
|
|
217
|
+
const input = JSON.parse(stdin);
|
|
218
|
+
const linked = input.linked ?? await resolveLinked(filePath, { tracker });
|
|
219
|
+
writeTasksToSession(filePath, { linked, todos: input.todos || [] });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readStdin() {
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
let data = '';
|
|
225
|
+
process.stdin.setEncoding('utf-8');
|
|
226
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
227
|
+
process.stdin.on('end', () => resolve(data));
|
|
228
|
+
process.stdin.on('error', reject);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
233
|
+
main().catch(e => { console.error(e); process.exit(1); });
|
|
234
|
+
}
|