@ulysses-ai/create-workspace 0.13.0-beta.1 → 0.14.0-beta.1

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 CHANGED
@@ -1,5 +1,8 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/ukt-solutions/create-ulysses-workspace/main/docs/assets/logo.png" alt="Ulysses Workspace" width="220">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ukt-solutions/create-ulysses-workspace/main/docs/assets/logo-dark.png">
4
+ <img src="https://raw.githubusercontent.com/ukt-solutions/create-ulysses-workspace/main/docs/assets/logo.png" alt="Ulysses Workspace" width="220">
5
+ </picture>
3
6
  </p>
4
7
 
5
8
  # @ulysses-ai/create-workspace
@@ -86,8 +89,8 @@ Four things, in the order you'll touch them:
86
89
  A scaffolded workspace with:
87
90
 
88
91
  - **14 skills** covering the workflow lifecycle, releases, handoffs, and maintenance
89
- - **6 active rules** + **8 optional `.skip` rules** for behaviors you can opt into
90
- - **8 hooks** for SessionStart, SubagentStart, PreCompact, WorktreeCreate, and the rest of the small set the conventions rely on
92
+ - **7 active rules** + **8 optional `.skip` rules** for behaviors you can opt into
93
+ - **9 hooks** for SessionStart, SubagentStart, PreCompact, WorktreeCreate, and the rest of the small set the conventions rely on
91
94
  - A **`shared-context/`** memory system with three visibility levels: locked (team truths), root (team-visible ephemerals), user-scoped (personal)
92
95
  - Conventions for **multi-repo work sessions** with isolated git worktrees, parallelizable from separate terminals
93
96
 
@@ -131,6 +134,15 @@ The eleven [chapters](https://github.com/ukt-solutions/create-ulysses-workspace/
131
134
 
132
135
  In active pre-1.0 development. Used as dogfood and validated against external workspaces. Conventions and CLI flags are stable; small refinements continue. v1.0 will mark a stability commitment.
133
136
 
137
+ ## Feedback
138
+
139
+ Beta testers, early adopters, and anyone curious — feedback of any shape is genuinely wanted.
140
+
141
+ - **Bugs, rough edges, "this didn't do what I expected"** → [open an issue](https://github.com/ukt-solutions/create-ulysses-workspace/issues/new)
142
+ - **Design questions, "why does it work this way?", ideas for new skills or rules** → [start a discussion](https://github.com/ukt-solutions/create-ulysses-workspace/discussions)
143
+
144
+ First-hand reports of the scaffold-and-go-for-it experience are the single most useful thing at this stage — especially anything that surprised you, tripped you up, or felt like friction the CLI could have caught.
145
+
134
146
  ## License
135
147
 
136
148
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.13.0-beta.1",
3
+ "version": "0.14.0-beta.1",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for bash-output-advisory.mjs pattern detection.
3
+ // Run: node .claude/hooks/_bash-output-advisory.test.mjs
4
+ import { detectNoisyPattern } from './bash-output-advisory.mjs';
5
+
6
+ let failed = 0;
7
+ let passed = 0;
8
+
9
+ function shouldWarn(command, label) {
10
+ const result = detectNoisyPattern(command);
11
+ if (result) {
12
+ passed++;
13
+ } else {
14
+ failed++;
15
+ console.error(` FAIL: ${label}\n command: ${command}\n expected an advisory, got null`);
16
+ }
17
+ }
18
+
19
+ function shouldNotWarn(command, label) {
20
+ const result = detectNoisyPattern(command);
21
+ if (!result) {
22
+ passed++;
23
+ } else {
24
+ failed++;
25
+ console.error(` FAIL: ${label}\n command: ${command}\n expected null, got: ${result}`);
26
+ }
27
+ }
28
+
29
+ // === Test runners ===
30
+ shouldWarn('npm test', 'bare npm test');
31
+ shouldWarn('npm run test', 'bare npm run test');
32
+ shouldWarn('yarn test', 'bare yarn test');
33
+ shouldWarn('pnpm test', 'bare pnpm test');
34
+ shouldWarn('bun test', 'bare bun test');
35
+ shouldWarn('cargo test', 'bare cargo test');
36
+ shouldWarn('npm test --coverage', 'npm test with non-scoping flag');
37
+
38
+ shouldNotWarn('npm test path/to/file.test.mjs', 'npm test with file path');
39
+ shouldNotWarn('npm test src/', 'npm test with directory');
40
+ shouldNotWarn('npm test -- --grep auth', 'npm test with -- args');
41
+ shouldNotWarn('npm test myFile.test.js', 'npm test with bare filename');
42
+ shouldNotWarn('npm test | grep FAIL', 'npm test piped to grep');
43
+ shouldNotWarn('npm test | head -50', 'npm test piped to head');
44
+ shouldNotWarn('cargo test auth_module', 'cargo test with module filter');
45
+
46
+ // === grep -r ===
47
+ shouldWarn('grep -r "pattern" .', 'bare grep -r');
48
+ shouldWarn('grep --recursive "pattern" src/', 'grep --recursive long form');
49
+ shouldWarn('grep -rn "TODO" .', 'grep -rn (recursive + line numbers)');
50
+
51
+ shouldNotWarn('grep -r --include="*.js" pattern .', 'grep -r with --include');
52
+ shouldNotWarn('grep -r pattern . --exclude="*.log"', 'grep -r with --exclude');
53
+ shouldNotWarn('grep "pattern" file.txt', 'non-recursive grep');
54
+
55
+ // === find on broad anchors ===
56
+ shouldWarn('find /', 'find on root');
57
+ shouldWarn('find ~', 'find on home tilde');
58
+ shouldWarn('find $HOME', 'find on $HOME');
59
+
60
+ shouldNotWarn('find / -name "*.log"', 'find / with -name');
61
+ shouldNotWarn('find ~ -path "*node_modules*" -prune', 'find ~ with -path');
62
+ shouldNotWarn('find . -type f', 'find on cwd');
63
+ shouldNotWarn('find ./src -name "*.ts"', 'find on relative subdir');
64
+
65
+ // === cat on log-shaped files ===
66
+ shouldWarn('cat server.log', 'cat .log file');
67
+ shouldWarn('cat events.jsonl', 'cat .jsonl file');
68
+ shouldWarn('cat trace.ndjson', 'cat .ndjson file');
69
+ shouldWarn('cat path/to/big.log', 'cat .log in subdir');
70
+
71
+ shouldNotWarn('cat package.json', 'cat package.json');
72
+ shouldNotWarn('cat README.md', 'cat README');
73
+ shouldNotWarn('cat src/index.ts', 'cat source file');
74
+ shouldNotWarn('cat server.log | tail -50', 'cat .log piped to tail');
75
+
76
+ // === redirected output (always allowed) ===
77
+ shouldNotWarn('npm test > /tmp/test-output.txt', 'npm test redirected to file');
78
+ shouldNotWarn('grep -r foo . > out.txt', 'grep -r redirected to file');
79
+ shouldNotWarn('find / 2>&1 | tee /tmp/find.txt', 'find piped to tee');
80
+
81
+ // === unrelated commands ===
82
+ shouldNotWarn('git status', 'git status');
83
+ shouldNotWarn('ls -la', 'ls');
84
+ shouldNotWarn('echo hello', 'echo');
85
+ shouldNotWarn('node --version', 'node version');
86
+
87
+ console.log(`Passed: ${passed}, Failed: ${failed}`);
88
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook — soft advisory for known-noisy bash commands.
3
+ //
4
+ // Does NOT modify the command. Emits a one-line additionalContext nudge so
5
+ // the model can choose to scope the command before executing. Patterns are
6
+ // deliberately narrow — false positives train the model to ignore the hook.
7
+ //
8
+ // Add patterns sparingly. The cost of a missed nudge is one bloated tool
9
+ // result; the cost of crying wolf is a permanently-ignored hook.
10
+ import { fileURLToPath } from 'url';
11
+ import { readStdin, respond } from './_utils.mjs';
12
+
13
+ // Only run the hook body when invoked directly (not when imported by tests).
14
+ const isEntry = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
15
+ if (isEntry) {
16
+ const input = await readStdin();
17
+ if (input.tool_name !== 'Bash') {
18
+ respond();
19
+ process.exit(0);
20
+ }
21
+
22
+ const command = (input.tool_input?.command || '').trim();
23
+ if (!command) {
24
+ respond();
25
+ process.exit(0);
26
+ }
27
+
28
+ const advisory = detectNoisyPattern(command);
29
+ if (advisory) {
30
+ respond(`Bash advisory: ${advisory} Consider scoping the command (path, filter, or pipe to head/tail/grep) before running.`);
31
+ } else {
32
+ respond();
33
+ }
34
+ }
35
+
36
+ export function detectNoisyPattern(command) {
37
+ // Already piped to a bounding command — caller knows what they're doing.
38
+ if (/\|\s*(head|tail|grep|rg|wc|less|more)\b/.test(command)) return null;
39
+ // Output already redirected to a file — not consuming context.
40
+ if (/(?:^|\s)(?:>|>>|\|\s*tee)\s+\S/.test(command)) return null;
41
+
42
+ // 1. Bare test runners with no scope.
43
+ // Matches `npm test`, `npm run test`, `yarn test`, `pnpm test`, `bun test`, `cargo test`
44
+ // followed by nothing, or only by recognized flags that don't constrain output.
45
+ const testRunner = /^(?:npm(?:\s+run)?|yarn|pnpm|bun)\s+test\b(.*)$/.exec(command)
46
+ || /^cargo\s+test\b(.*)$/.exec(command);
47
+ if (testRunner) {
48
+ const tail = testRunner[1];
49
+ // A scope is any non-flag positional arg (path, filename, or test-name filter).
50
+ // Flags that don't constrain output (--coverage, --watch, --verbose, --bail) don't count.
51
+ const hasScope = tail.split(/\s+/).some(arg => arg && !arg.startsWith('-'));
52
+ if (!hasScope) {
53
+ return 'Bare test-runner invocation will produce all-tests output.';
54
+ }
55
+ }
56
+
57
+ // 2. Recursive grep without an include filter.
58
+ // Note: ripgrep (`rg`) is recursive by default and respects .gitignore, so a
59
+ // bare `rg pattern .` is usually fine. We only flag classic `grep -r`.
60
+ if (/^grep\s+(?:-\w*r\w*|-r\b|--recursive\b)/.test(command)
61
+ && !/--include[=\s]|--exclude[=\s]/.test(command)) {
62
+ return 'Recursive grep without --include can return thousands of matches.';
63
+ }
64
+
65
+ // 3. find on a home/root anchor without a name/path constraint.
66
+ if (/^find\s+(?:\/|~|\$HOME|\$\{HOME\})(?:\s|$)/.test(command)
67
+ && !/-(?:name|iname|path|ipath|regex)\b/.test(command)) {
68
+ return 'find on a broad anchor without -name/-path enumerates the whole tree.';
69
+ }
70
+
71
+ // 4. cat on log-shaped files.
72
+ if (/^cat\s+\S*\.(?:log|jsonl|ndjson)\b/.test(command)) {
73
+ return 'Log-shaped files are often large.';
74
+ }
75
+
76
+ return null;
77
+ }
@@ -0,0 +1,69 @@
1
+ # Task List Mirroring
2
+
3
+ The Claude Code `TodoWrite` checklist is a live mirror of the workspace lifecycle. The durable backing store is a `## Tasks` section in `session.md`, round-tripped by `.claude/scripts/sync-tasks.mjs`. This rule defines the contract.
4
+
5
+ ## Why
6
+
7
+ `TodoWrite` is conversation-scoped — it disappears at the end of every chat. To make it useful for multi-chat work sessions, it needs a durable store that survives chat restarts, machine switches, and pause/resume cycles. `session.md` already lives on the session branch and travels with `git push`/`git pull`, so it's the natural home.
8
+
9
+ ## The `## Tasks` schema
10
+
11
+ Anchored after `## Pre-session context` (if present) and before `## Progress`:
12
+
13
+ ```
14
+ ## Tasks
15
+
16
+ > Linked: gh:42 — Auth timeout on mobile
17
+
18
+ - [x] Start work
19
+ - [x] Reproduce on iOS Safari
20
+ - [ ] Identify race condition in token refresh
21
+ - [ ] Complete work
22
+ ```
23
+
24
+ - The heading is exactly `## Tasks`.
25
+ - Optional first line is a blockquote `> Linked: {workItem-id} — {issue-title}`. Present iff `workItem:` is set in frontmatter.
26
+ - Each task is one checkbox line. Three statuses: `- [ ]` pending, `- [-]` in_progress, `- [x]` completed. No nesting.
27
+ - The bookends `Start work` and `Complete work` are always present, at positions 1 and N.
28
+
29
+ The `- [-]` marker is non-standard GFM — GitHub's web renderer shows it as literal text rather than a checkbox. That's acceptable because `session.md` lives on session branches and is mostly read in editors (where Obsidian, JetBrains, and similar renderers do treat `[-]` as in-progress). The tradeoff buys lossless `in_progress` round-trip across chats.
30
+
31
+ ## Helper invocations
32
+
33
+ ```bash
34
+ # Read: emits TodoWrite-shaped JSON.
35
+ node .claude/scripts/sync-tasks.mjs --read <session.md>
36
+
37
+ # Write: takes TodoWrite-shaped JSON on stdin, rewrites the section atomically.
38
+ node .claude/scripts/sync-tasks.mjs --write <session.md>
39
+ ```
40
+
41
+ The helper enforces bookends on write — Claude doesn't need to remember to include them, and any misplacement is silently corrected.
42
+
43
+ ## When to flush (write to disk)
44
+
45
+ **Lifecycle moments — always:**
46
+ - `/start-work` (blank): seed `## Tasks` with bookends + (if linked) tracker reference.
47
+ - `/start-work` (resume): read-only — populate `TodoWrite` from `## Tasks`.
48
+ - `/pause-work`: flush before the auto-commit.
49
+ - `/complete-work`: flush before release-note synthesis.
50
+ - `/handoff`, `/braindump`: include a snapshot of current `TodoWrite` state in the captured artifact.
51
+
52
+ **Mid-session — after meaningful change:**
53
+ - A new task was added.
54
+ - A task moved to `completed`.
55
+ - A task moved to `in_progress`.
56
+
57
+ Trivial edits (renaming, reordering) do **not** trigger a flush — they ride on the next lifecycle commit.
58
+
59
+ ## What NOT to do
60
+
61
+ - Do not edit the `## Tasks` section by hand or with `Edit` — always go through the helper. Manual edits will be overwritten and may miss the bookend invariant.
62
+ - Do not add nested checkboxes — `TodoWrite` is flat, and the round-trip ignores nesting.
63
+ - Do not omit the bookends — the helper auto-inserts them, but explicit is better than implicit.
64
+ - Do not flush every keystroke — that creates pointless file churn. Flush on meaningful change or lifecycle moment.
65
+ - Do not flush from inside a subagent — subagents have ephemeral context; only the main agent maintains the canonical `TodoWrite` state for the session.
66
+
67
+ ## Concurrency model
68
+
69
+ Multi-chat-on-same-session is rare but possible. Each chat maintains its own `TodoWrite`; flushes write the file and the last writer wins. This matches the existing `## Progress` model. If you notice a divergence, the disk version is authoritative — restart your chat to reseed.
@@ -1,6 +1,6 @@
1
1
  # Token Economics
2
2
 
3
- Activate this rule for token-aware behavior — cost-conscious model selection, context efficiency, and waste reduction.
3
+ Activate this rule for token-aware behavior — cost-conscious model selection, context efficiency, command discipline, and waste detection.
4
4
 
5
5
  ## Model Selection
6
6
 
@@ -18,14 +18,29 @@ Activate this rule for token-aware behavior — cost-conscious model selection,
18
18
  - If a tool result is large and mostly irrelevant, note what matters and move on
19
19
  - Prefer exact file paths over glob searches when you know where things are
20
20
 
21
- ## Waste Detection
21
+ ## Command Discipline
22
22
 
23
- - Flag when context is heavy with resolved discussions that could be compacted
24
- - Suggest /braindump to offload discussion into files, freeing context for work
25
- - Note when subagent context is bloated relative to the task size
26
- - If locked context exceeds the 10KB target, mention it
23
+ The single biggest source of session bloat is bash output you didn't bound. The model has to read every line that comes back, and there's no after-the-fact way to truncate it.
24
+
25
+ - Don't run unbounded commands. Pipe to `head`, `tail`, or `grep` first.
26
+ - For test runs, prefer scoped invocations: `npm test path/to/file.test.mjs` over bare `npm test`. If you only need failures, pipe through `grep -E "FAIL|✗|Error"`.
27
+ - For `find` and `grep -r` / `rg -r`, always supply a constraint (`-name`, `-path`, `--include`, `-g`). Bare `find ~/` against home is almost never what you want.
28
+ - For inspecting a large file, write to a tmp file and grep into it rather than `cat`ing the whole thing into context.
29
+ - For long-running processes (servers, watchers), use `run_in_background: true` and stream with the Monitor tool — don't let stdout pile up in a single tool result.
30
+ - Do not claim a task is done before running its tests. The cost of a forgotten test run is a follow-up cycle that bloats the session more than the test would have.
31
+
32
+ ## Ghost-Token Detection
33
+
34
+ "Ghost tokens" are context spend you can't see in any single tool result — structural bloat that accumulates across the session. Watch for:
35
+
36
+ - **Unused skills.** A loaded skill that you never invoke costs input tokens every turn. If a workspace ships skills you don't use in the current task, that's silent overhead.
37
+ - **Oversized locked context.** `shared-context/locked/` is injected into every session and every subagent. If it grows past 5% of the active model's context window, flag it (yellow); past 15%, treat as red. Absolute byte count is a weak proxy — duplicated coverage and stale references matter more.
38
+ - **Unused MCP servers.** Each MCP server's tool list is in your prompt whether you call it or not. If a server is registered but never used in this workspace's flow, surface it for cleanup.
39
+ - **Resolved discussions still in context.** If the conversation has worked through a long debate that's now settled, that text is still occupying the window. Suggest `/braindump` to offload it into a file.
40
+ - **Subagent context bloat.** A subagent prompt that ships more context than the task requires wastes tokens twice — once for the subagent, once for the dispatching model that wrote the prompt.
27
41
 
28
42
  ## Compaction Awareness
29
43
 
30
- - When approaching compaction threshold, prioritize capturing over continuing
31
- - After compaction, avoid re-reading files that were already discussed — check shared-context first
44
+ - When approaching the compaction threshold, prioritize capturing over continuing. A `/braindump` or `/handoff` *before* compaction preserves reasoning that gets summarized away.
45
+ - After compaction, avoid re-reading files that were already discussed — check `shared-context/` and the session tracker first.
46
+ - The `PreCompact` hook will prompt for capture; treat that prompt as a real checkpoint, not a dismissable nag.
@@ -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
+ }