wogiflow 2.32.0 → 2.34.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.
Files changed (39) hide show
  1. package/.claude/docs/claude-code-compatibility.md +51 -0
  2. package/.claude/docs/scheduled-mode.md +213 -0
  3. package/.claude/docs/skill-portability.md +190 -0
  4. package/.claude/rules/alternative-hook-args-exec-form.md +6 -0
  5. package/.claude/settings.json +2 -1
  6. package/.claude/skills/_template/skill.md +1 -0
  7. package/.claude/skills/conventional-commit/knowledge/examples.md +65 -0
  8. package/.claude/skills/conventional-commit/skill.md +76 -0
  9. package/bin/flow +16 -0
  10. package/lib/scheduled-mode.js +374 -0
  11. package/lib/skill-export-agentskills.js +211 -0
  12. package/lib/skill-export-claude-plugin.js +183 -0
  13. package/lib/skill-portability.js +342 -0
  14. package/lib/skill-registry.js +32 -2
  15. package/lib/workspace-channel-server.js +106 -3
  16. package/lib/workspace-channel-tracking.js +102 -1
  17. package/lib/workspace-dispatch-tracking.js +28 -0
  18. package/lib/workspace-messages.js +32 -4
  19. package/lib/workspace-subtask-state.js +215 -0
  20. package/lib/workspace.js +81 -0
  21. package/package.json +2 -2
  22. package/scripts/flow +25 -0
  23. package/scripts/flow-config-defaults.js +20 -0
  24. package/scripts/flow-constants.js +3 -1
  25. package/scripts/flow-schedule.js +486 -0
  26. package/scripts/flow-scheduled-runner.js +659 -0
  27. package/scripts/flow-skill-export.js +334 -0
  28. package/scripts/flow-standards-checker.js +37 -0
  29. package/scripts/hooks/adapters/claude-code.js +18 -3
  30. package/scripts/hooks/core/git-safety-gate.js +118 -27
  31. package/scripts/hooks/core/long-input-enforcement.js +139 -4
  32. package/scripts/hooks/core/overdue-dispatches.js +28 -6
  33. package/scripts/hooks/core/session-start-worker.js +52 -0
  34. package/scripts/hooks/core/stop-orchestrator.js +17 -2
  35. package/scripts/hooks/core/validation.js +8 -0
  36. package/scripts/hooks/core/worker-continuation-gate.js +326 -0
  37. package/scripts/hooks/core/workspace-stop-gates.js +21 -0
  38. package/scripts/hooks/core/workspace-stop-notify.js +174 -59
  39. package/scripts/hooks/entry/claude-code/post-tool-use.js +26 -0
@@ -22,6 +22,21 @@ const http = require('node:http');
22
22
  const readline = require('node:readline');
23
23
  const { safeJsonParseContent } = require('./utils');
24
24
 
25
+ // S5 (wf-ee87a24e): the version this long-lived server process loaded at boot.
26
+ // Compared against the on-disk package.json to detect a mid-session
27
+ // `npm i wogiflow@latest` that left this process running stale code.
28
+ const SERVER_VERSION = (() => {
29
+ try { return require('../package.json').version || null; } catch (_err) { return null; }
30
+ })();
31
+ function readDiskVersion() {
32
+ try {
33
+ const fs = require('node:fs');
34
+ const pkgPath = require('node:path').join(__dirname, '..', 'package.json');
35
+ const raw = fs.readFileSync(pkgPath, 'utf-8'); // fresh read, bypasses require cache
36
+ return JSON.parse(raw).version || null;
37
+ } catch (_err) { return null; }
38
+ }
39
+
25
40
  // ============================================================
26
41
  // Constants
27
42
  // ============================================================
@@ -129,8 +144,11 @@ When you receive a message:
129
144
  2. If it's a question or investigation request → do the work, then ALWAYS send results back
130
145
  3. If it's a status check → respond with your current task status
131
146
 
132
- CRITICALALWAYS REPLY TO THE MANAGER:
133
- After completing ANY work triggered by a channel message, you MUST send results back using the workspace_send_message tool with to: "manager". The user only sees the manager terminal if you don't reply, they never see your results.
147
+ SUSTAINED EXECUTION a task dispatch runs to COMPLETION across turns:
148
+ A "/wogi-" dispatch (especially one you decompose into sub-tasks) is NOT a one-turn request. Work through ALL sub-tasks in the same session; the Stop hook's continuation gate will keep you going while the task is in-progress with work remaining. Do NOT stop to "report progress" mid-task only reply when the task is COMPLETE or you are ESCALATING a blocker.
149
+
150
+ CRITICAL — REPLY TO THE MANAGER WHEN THE TASK IS DONE OR BLOCKED:
151
+ When the dispatched task is complete (or you must escalate), you MUST send results back using the workspace_send_message tool with to: "manager". The user only sees the manager terminal — if you don't reply, they never see your results.
134
152
 
135
153
  Example: workspace_send_message(to: "manager", message: "## Investigation Results\\n\\n1. Found the bug in X\\n2. Root cause: Y\\n3. Fix: Z")
136
154
 
@@ -466,18 +484,60 @@ function broadcastSSE(event) {
466
484
 
467
485
  const channelTracking = require('./workspace-channel-tracking');
468
486
 
487
+ // S4 (wf-87611c5e): the channel server is the only process that sees every
488
+ // inbound dispatch, so it owns the "ack-received" timestamp used by GET /status.
489
+ let _lastInboundAt = 0;
490
+ const STATUS_STALENESS_MS = (() => {
491
+ const raw = parseInt(process.env.WOGI_STATUS_STALENESS_MS || '', 10);
492
+ return Number.isFinite(raw) && raw > 0 ? raw : 300000;
493
+ })();
494
+
469
495
  // ============================================================
470
496
  // HTTP Server
471
497
  // ============================================================
472
498
 
473
499
  const server = http.createServer(async (req, res) => {
474
- // Health check — minimal info, no topology exposure
500
+ // Health check — minimal info, no topology exposure. PURE liveness: "the
501
+ // server process is up." Says nothing about whether the agent is working —
502
+ // use /status for that (S4).
475
503
  if (req.method === 'GET' && req.url === '/health') {
476
504
  res.writeHead(200, { 'Content-Type': 'application/json' });
477
505
  res.end(JSON.stringify({ status: 'ok', repo: REPO_NAME, port: PORT }));
478
506
  return;
479
507
  }
480
508
 
509
+ // Activity status (S4 / wf-87611c5e) — the real execution state, so a manager
510
+ // can never mistake a channel POST `ok` for "work happening". Derived from the
511
+ // worker's own state files + the last inbound dispatch this server saw.
512
+ if (req.method === 'GET' && req.url === '/status') {
513
+ let body;
514
+ try {
515
+ const path = require('node:path');
516
+ const stateDir = path.join(process.cwd(), '.workflow', 'state');
517
+ body = channelTracking.computeWorkerStatus({
518
+ stateDir,
519
+ repoName: REPO_NAME,
520
+ lastInboundAt: _lastInboundAt || undefined,
521
+ stalenessMs: STATUS_STALENESS_MS
522
+ });
523
+ body.port = PORT;
524
+ // S5: version-drift signal — if the on-disk wogiflow differs from what this
525
+ // long-lived server loaded, a `flow workspace restart` is required to load it.
526
+ const diskVersion = readDiskVersion();
527
+ body.serverVersion = SERVER_VERSION;
528
+ body.diskVersion = diskVersion;
529
+ body.versionDrift = Boolean(SERVER_VERSION && diskVersion && SERVER_VERSION !== diskVersion);
530
+ if (body.versionDrift) {
531
+ body.restartRequired = `Server is running ${SERVER_VERSION} but ${diskVersion} is on disk — run 'flow workspace restart ${REPO_NAME}'`;
532
+ }
533
+ } catch (_err) {
534
+ body = { repo: REPO_NAME, port: PORT, state: 'unknown' };
535
+ }
536
+ res.writeHead(200, { 'Content-Type': 'application/json' });
537
+ res.end(JSON.stringify(body));
538
+ return;
539
+ }
540
+
481
541
  // SSE endpoint for event subscriptions
482
542
  if (req.method === 'GET' && req.url?.startsWith('/events')) {
483
543
  const lastEventId = req.headers['last-event-id'] || '';
@@ -485,6 +545,44 @@ const server = http.createServer(async (req, res) => {
485
545
  return;
486
546
  }
487
547
 
548
+ // Manager-triggered restart (S5 / wf-ee87a24e). Writes the wogi-claude
549
+ // wrapper's restart flag and SIGTERMs this server's parent (the claude
550
+ // process). The wrapper relaunches claude with a FRESH require cache —
551
+ // reloading any upgraded wogiflow code, and claude respawns this MCP server.
552
+ // No PID tracking needed; reuses the proven task-boundary restart loop.
553
+ if (req.method === 'POST' && (req.url === '/restart' || req.url === '/control/restart')) {
554
+ const rawFrom = req.headers['x-wogi-from'] || '';
555
+ // localhost-bound already; additionally require the manager as sender.
556
+ if (rawFrom && rawFrom !== 'manager' && rawFrom !== 'workspace-manager') {
557
+ res.writeHead(403, { 'Content-Type': 'application/json' });
558
+ res.end(JSON.stringify({ ok: false, error: 'restart may only be triggered by the manager' }));
559
+ return;
560
+ }
561
+ let scheduled = false;
562
+ try {
563
+ const fs = require('node:fs');
564
+ const nodePath = require('node:path');
565
+ const flagPath = process.env.WOGI_RESTART_FLAG ||
566
+ nodePath.join(process.cwd(), '.workflow', 'state', 'restart-requested');
567
+ fs.mkdirSync(nodePath.dirname(flagPath), { recursive: true });
568
+ fs.writeFileSync(flagPath, JSON.stringify({
569
+ version: 1, reason: 'manager-restart', repo: REPO_NAME,
570
+ triggeredAt: new Date().toISOString()
571
+ }, null, 2));
572
+ // Defer the SIGTERM briefly so the HTTP response flushes first.
573
+ const ppid = process.ppid;
574
+ setTimeout(() => { try { process.kill(ppid, 'SIGTERM'); } catch (_err) { /* parent gone */ } }, 150);
575
+ scheduled = true;
576
+ } catch (err) {
577
+ res.writeHead(500, { 'Content-Type': 'application/json' });
578
+ res.end(JSON.stringify({ ok: false, error: err.message }));
579
+ return;
580
+ }
581
+ res.writeHead(202, { 'Content-Type': 'application/json' });
582
+ res.end(JSON.stringify({ ok: true, scheduled, repo: REPO_NAME, note: 'worker restarting; channel server will respawn with fresh code' }));
583
+ return;
584
+ }
585
+
488
586
  // Receive webhook (POST)
489
587
  if (req.method === 'POST') {
490
588
  const { body, truncated } = await collectBody(req, MAX_BODY_BYTES);
@@ -508,6 +606,11 @@ const server = http.createServer(async (req, res) => {
508
606
  cleanBody = body.substring(effortMatch[0].length);
509
607
  }
510
608
 
609
+ // S4: record when a dispatch arrived (ack-received signal for /status).
610
+ if (channelTracking.DISPATCH_BODY_PATTERN.test(cleanBody)) {
611
+ _lastInboundAt = Date.now();
612
+ }
613
+
511
614
  // Forward as channel notification to Claude Code
512
615
  const meta = {
513
616
  from,
@@ -115,11 +115,112 @@ function tryReconcileInboundCompletion(ctx, tracking) {
115
115
  }
116
116
  }
117
117
 
118
+ // ============================================================
119
+ // Worker activity status (epic-workspace-sustained-exec / S4, wf-87611c5e)
120
+ // ============================================================
121
+
122
+ const fsNode = require('node:fs');
123
+ const pathNode = require('node:path');
124
+
125
+ const ACTIVE_PHASES = new Set(['coding', 'validating']);
126
+ const DEFAULT_STALENESS_MS = 300000; // 5 min
127
+
128
+ function _safeRead(p) {
129
+ try { return JSON.parse(fsNode.readFileSync(p, 'utf-8')); } catch (_err) { return null; }
130
+ }
131
+ function _mtimeMs(p) {
132
+ try { return fsNode.statSync(p).mtimeMs; } catch (_err) { return 0; }
133
+ }
134
+
135
+ /**
136
+ * Derive the worker's real execution state for GET /status. Distinguishes
137
+ * ack-received / work-started / in-progress / complete / blocked / idle so the
138
+ * manager can never mistake a channel POST `ok` (or `/health` ok) for progress.
139
+ *
140
+ * Pure-ish (reads files from stateDir); injectable for tests.
141
+ *
142
+ * @param {Object} opts
143
+ * @param {string} opts.stateDir worker .workflow/state dir
144
+ * @param {string} [opts.repoName]
145
+ * @param {number} [opts.lastInboundAt] ms epoch of the last dispatch POST the server saw
146
+ * @param {number} [opts.stalenessMs] heartbeat freshness window
147
+ * @param {number} [opts.now]
148
+ * @returns {{repo, state, taskId, subtasks:{total,remaining}, lastHeartbeatAt, lastSha, phase}}
149
+ */
150
+ function computeWorkerStatus(opts = {}) {
151
+ const stateDir = opts.stateDir;
152
+ const now = opts.now || Date.now();
153
+ const stalenessMs = Number.isFinite(opts.stalenessMs) ? opts.stalenessMs : DEFAULT_STALENESS_MS;
154
+ const out = {
155
+ repo: opts.repoName || null,
156
+ state: 'idle',
157
+ taskId: null,
158
+ subtasks: { total: 0, remaining: 0 },
159
+ lastHeartbeatAt: null,
160
+ lastSha: null,
161
+ phase: null
162
+ };
163
+ try {
164
+ if (!stateDir) return out;
165
+ const ready = _safeRead(pathNode.join(stateDir, 'ready.json')) || {};
166
+ const phaseData = _safeRead(pathNode.join(stateDir, 'workflow-phase.json')) || {};
167
+ const ledger = _safeRead(pathNode.join(stateDir, 'subtask-state.json'));
168
+ const counter = _safeRead(pathNode.join(stateDir, 'worker-continuation.json'));
169
+ const phase = typeof phaseData.phase === 'string' ? phaseData.phase : null;
170
+ out.phase = phase;
171
+
172
+ const inProgress = (ready.inProgress || [])[0] || null;
173
+
174
+ // Activity freshness: newest mtime of the files a working worker touches.
175
+ const lastActivityMs = Math.max(
176
+ _mtimeMs(pathNode.join(stateDir, 'workflow-phase.json')),
177
+ _mtimeMs(pathNode.join(stateDir, 'subtask-state.json')),
178
+ _mtimeMs(pathNode.join(stateDir, 'worker-continuation.json'))
179
+ );
180
+ if (lastActivityMs > 0) out.lastHeartbeatAt = new Date(lastActivityMs).toISOString();
181
+ const activityFresh = lastActivityMs > 0 && (now - lastActivityMs) < stalenessMs;
182
+
183
+ if (!inProgress) {
184
+ const recent = (ready.recentlyCompleted || [])[0] || null;
185
+ const completedTs = recent && recent.completedAt ? Date.parse(recent.completedAt) : NaN;
186
+ if (Number.isFinite(completedTs) && (now - completedTs) < stalenessMs) {
187
+ out.state = 'complete';
188
+ out.taskId = recent.id || null;
189
+ } else {
190
+ out.state = 'idle';
191
+ }
192
+ return out;
193
+ }
194
+
195
+ out.taskId = inProgress.id || null;
196
+ if (ledger && (!ledger.taskId || ledger.taskId === out.taskId) && Array.isArray(ledger.subtasks)) {
197
+ const open = ledger.subtasks.filter(s => s && (s.status === 'pending' || s.status === 'in_progress')).length;
198
+ out.subtasks = { total: ledger.subtasks.length, remaining: open };
199
+ }
200
+
201
+ const escalated = counter && counter.taskId === out.taskId && counter.escalated === true;
202
+ if (escalated) {
203
+ out.state = 'blocked';
204
+ } else if (ACTIVE_PHASES.has(phase)) {
205
+ out.state = activityFresh ? 'in-progress' : 'work-started';
206
+ } else {
207
+ // Picked up (in inProgress) but not yet in active-work phase.
208
+ out.state = 'ack-received';
209
+ }
210
+ return out;
211
+ } catch (_err) {
212
+ return out; // fail-open: never 500
213
+ }
214
+ }
215
+
118
216
  module.exports = {
119
217
  TASK_ID_PATTERN,
120
218
  DISPATCH_BODY_PATTERN,
121
219
  QUESTION_BODY_PATTERN,
122
220
  COMPLETION_BODY_PATTERN,
123
221
  tryRecordInboundDispatch,
124
- tryReconcileInboundCompletion
222
+ tryReconcileInboundCompletion,
223
+ computeWorkerStatus,
224
+ ACTIVE_PHASES,
225
+ DEFAULT_STALENESS_MS
125
226
  };
@@ -136,6 +136,33 @@ function reconcileDispatch(workspaceRoot, taskId, status, reason) {
136
136
  return null;
137
137
  }
138
138
 
139
+ /**
140
+ * Refresh a pending dispatch's deadline on a worker-progress heartbeat
141
+ * (epic-workspace-sustained-exec / S3). A worker grinding through a decomposed
142
+ * task across many turns would otherwise blow past expectedDeadline and be
143
+ * misclassified as a silent-halt. Each heartbeat pushes the deadline out and
144
+ * records lastHeartbeatAt. Keeps status 'pending'. Returns the record or null.
145
+ *
146
+ * @param {string} workspaceRoot
147
+ * @param {string} taskId
148
+ * @param {number} [extendMs=DEFAULT_DURATION_MS]
149
+ */
150
+ function refreshDispatchDeadline(workspaceRoot, taskId, extendMs) {
151
+ const state = loadState(workspaceRoot);
152
+ const ms = Number.isFinite(extendMs) && extendMs > 0 ? extendMs : DEFAULT_DURATION_MS;
153
+ for (let i = state.dispatches.length - 1; i >= 0; i--) {
154
+ const r = state.dispatches[i];
155
+ if (r && r.taskId === taskId && r.status === 'pending') {
156
+ r.lastHeartbeatAt = new Date().toISOString();
157
+ r.expectedDeadline = new Date(Date.now() + ms).toISOString();
158
+ r.heartbeatCount = (r.heartbeatCount || 0) + 1;
159
+ saveState(workspaceRoot, state);
160
+ return r;
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+
139
166
  /**
140
167
  * Read all currently-active dispatch records (not archived).
141
168
  *
@@ -306,6 +333,7 @@ module.exports = {
306
333
  MAX_ACTIVE,
307
334
  recordDispatch,
308
335
  reconcileDispatch,
336
+ refreshDispatchDeadline,
309
337
  readDispatches,
310
338
  getOverdueDispatches,
311
339
  attachCompletionSummary,
@@ -25,6 +25,10 @@ const MESSAGE_TYPES = [
25
25
  'task-complete', // "I finished my side of feature Z"
26
26
  'worker-stopped', // Graceful Stop hook — worker session ending, not necessarily at task completion
27
27
  'worker-ready', // Fresh worker session with empty queue — "got anything for me?" (wf-restart-handoff)
28
+ 'worker-progress', // Heartbeat on a forced continuation — work ongoing, NOT a stop (epic-workspace-sustained-exec S3)
29
+ 'worker-blocked', // Escalation: gate hit a cap / no-progress / validation failure — needs manager (S2/S3)
30
+ 'worker-idle', // Real terminal stop: nothing in progress and nothing queued (S3)
31
+ 'worker-awaiting-approval', // Spec written, in spec_review — waiting on manager GO, NOT done (S3)
28
32
  'needs-help', // "I'm stuck, can you check X on your side?"
29
33
  'heads-up', // "I'm about to change Y, just FYI"
30
34
  'impact-query', // Pre-dev: "I'm about to change X, will this break you?"
@@ -96,6 +100,32 @@ function createMessage({ from, to, type, subject, body, priority, diff, suggeste
96
100
  // Message Persistence (Criterion 2 — lifecycle)
97
101
  // ============================================================
98
102
 
103
+ /**
104
+ * Atomically write a JSON file: tmp + fsync(file) + rename (+ best-effort dir
105
+ * fsync). Guarantees a concurrent reader sees old-or-new, never torn JSON, and
106
+ * survives the SIGTERM/relaunch boundary. (epic-workspace-sustained-exec / S3 —
107
+ * the manager was reading partial worker→manager messages off the bus.)
108
+ * @param {string} filePath
109
+ * @param {string} data
110
+ */
111
+ function atomicWriteFile(filePath, data) {
112
+ const dir = path.dirname(filePath);
113
+ fs.mkdirSync(dir, { recursive: true });
114
+ const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
115
+ const fd = fs.openSync(tmp, 'w');
116
+ try {
117
+ fs.writeSync(fd, data);
118
+ fs.fsyncSync(fd);
119
+ } finally {
120
+ fs.closeSync(fd);
121
+ }
122
+ fs.renameSync(tmp, filePath);
123
+ try {
124
+ const dfd = fs.openSync(dir, 'r');
125
+ try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
126
+ } catch (_err) { /* directory fsync best-effort (not supported on all FS) */ }
127
+ }
128
+
99
129
  /**
100
130
  * Save a message to the workspace message bus
101
131
  * @param {string} workspaceRoot
@@ -104,10 +134,8 @@ function createMessage({ from, to, type, subject, body, priority, diff, suggeste
104
134
  */
105
135
  function saveMessage(workspaceRoot, message) {
106
136
  const messagesDir = path.join(workspaceRoot, '.workspace', 'messages');
107
- fs.mkdirSync(messagesDir, { recursive: true });
108
-
109
137
  const filePath = path.join(messagesDir, `${message.id}.json`);
110
- fs.writeFileSync(filePath, JSON.stringify(message, null, 2));
138
+ atomicWriteFile(filePath, JSON.stringify(message, null, 2));
111
139
  return filePath;
112
140
  }
113
141
 
@@ -178,7 +206,7 @@ function updateMessageStatus(workspaceRoot, messageId, newStatus, extra = {}) {
178
206
  }
179
207
  }
180
208
  }
181
- fs.writeFileSync(filePath, JSON.stringify(message, null, 2));
209
+ atomicWriteFile(filePath, JSON.stringify(message, null, 2));
182
210
  return message;
183
211
  } catch (_err) {
184
212
  return null;
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Wogi Flow — Durable Sub-task State (epic-workspace-sustained-exec / S1, wf-e72350bf)
5
+ *
6
+ * Sub-tasks of a decomposed task are normally tracked ONLY in Claude Code's
7
+ * in-context TodoWrite list, which is ephemeral: a Stop hook running in a fresh
8
+ * `node` process can't read it, and a session restart loses it entirely. That's
9
+ * the root cause behind workers re-executing completed sub-tasks after a restart
10
+ * and behind the continuation gate (S2) having no reliable "work remaining"
11
+ * signal.
12
+ *
13
+ * This module mirrors the decomposition to a durable, atomically-written ledger
14
+ * at `.workflow/state/subtask-state.json` so that:
15
+ * - a fresh-process Stop hook can read "N of M sub-tasks remain" (S2),
16
+ * - a restarted session can resume without redoing completed sub-tasks (S5).
17
+ *
18
+ * The ledger holds the CURRENT in-progress task's decomposition. There is one
19
+ * active task per worker at a time, so a single-task shape is sufficient and
20
+ * avoids stale cross-task contamination — read* helpers take an optional taskId
21
+ * and return empty when it doesn't match the ledger's task.
22
+ *
23
+ * Atomic write: tmp + fsync(file) + rename + fsync(dir), mirroring
24
+ * task-boundary-reset.js:writeCleanCompletionMarker so the ledger survives the
25
+ * SIGTERM/relaunch boundary and concurrent readers never see torn JSON.
26
+ *
27
+ * Fail-open throughout: any error degrades to "no durable state" (callers fall
28
+ * back to their prior behavior). Never throws.
29
+ */
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const { PATHS } = require('../scripts/flow-utils');
35
+ const { safeJsonParse } = require('../scripts/flow-io');
36
+
37
+ const LEDGER_FILE = 'subtask-state.json';
38
+ const LEDGER_VERSION = 1;
39
+ // Statuses that count as "still needs work" for remaining().
40
+ const OPEN_STATUSES = new Set(['pending', 'in_progress']);
41
+ const TERMINAL_STATUSES = new Set(['completed', 'blocked']);
42
+
43
+ function getLedgerPath() {
44
+ return path.join(PATHS.state, LEDGER_FILE);
45
+ }
46
+
47
+ /**
48
+ * Normalize a free-form sub-task entry (TodoWrite item or plain object/string)
49
+ * into the ledger shape { id, title, status }.
50
+ */
51
+ function normalizeSubtask(entry, index) {
52
+ if (entry == null) return null;
53
+ if (typeof entry === 'string') {
54
+ return { id: String(index + 1).padStart(2, '0'), title: entry.slice(0, 500), status: 'pending' };
55
+ }
56
+ if (typeof entry !== 'object') return null;
57
+ const rawStatus = typeof entry.status === 'string' ? entry.status.toLowerCase() : 'pending';
58
+ const status = OPEN_STATUSES.has(rawStatus) || TERMINAL_STATUSES.has(rawStatus) ? rawStatus : 'pending';
59
+ const title = entry.title || entry.content || entry.text || entry.description || entry.activeForm || `Sub-task ${index + 1}`;
60
+ const id = entry.id != null ? String(entry.id) : String(index + 1).padStart(2, '0');
61
+ return { id, title: String(title).slice(0, 500), status };
62
+ }
63
+
64
+ /**
65
+ * Map a Claude Code TodoWrite toolInput ({ todos: [{ content, status }] })
66
+ * into normalized sub-tasks. Returns [] for anything unparseable.
67
+ */
68
+ function subtasksFromTodos(toolInput) {
69
+ try {
70
+ const todos = toolInput && Array.isArray(toolInput.todos) ? toolInput.todos : null;
71
+ if (!todos) return [];
72
+ return todos.map(normalizeSubtask).filter(Boolean);
73
+ } catch (_err) {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Read the full ledger object, or null if absent/unreadable.
80
+ * @returns {{version:number, taskId:string, updatedAt:string, subtasks:Array}|null}
81
+ */
82
+ function readLedger() {
83
+ try {
84
+ const data = safeJsonParse(getLedgerPath(), null);
85
+ if (!data || typeof data !== 'object' || !Array.isArray(data.subtasks)) return null;
86
+ return data;
87
+ } catch (_err) {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Read the sub-tasks for a given task. When taskId is provided and doesn't match
94
+ * the ledger's task, returns [] (the ledger belongs to a different task).
95
+ * @param {string} [taskId]
96
+ * @returns {Array<{id:string,title:string,status:string}>}
97
+ */
98
+ function read(taskId) {
99
+ const led = readLedger();
100
+ if (!led) return [];
101
+ if (taskId && led.taskId && led.taskId !== taskId) return [];
102
+ return led.subtasks;
103
+ }
104
+
105
+ /**
106
+ * Write/replace the ledger for taskId atomically.
107
+ * @param {string} taskId
108
+ * @param {Array} subtasks raw or normalized entries
109
+ * @returns {{written:boolean, reason?:string, path?:string}}
110
+ */
111
+ function write(taskId, subtasks) {
112
+ if (!taskId) return { written: false, reason: 'no-task-id' };
113
+ try {
114
+ const normalized = (Array.isArray(subtasks) ? subtasks : [])
115
+ .map(normalizeSubtask)
116
+ .filter(Boolean);
117
+ const payload = {
118
+ version: LEDGER_VERSION,
119
+ taskId,
120
+ updatedAt: new Date().toISOString(),
121
+ subtasks: normalized
122
+ };
123
+ const p = getLedgerPath();
124
+ const dir = path.dirname(p);
125
+ fs.mkdirSync(dir, { recursive: true });
126
+ const tmp = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
127
+ const fd = fs.openSync(tmp, 'w');
128
+ try {
129
+ fs.writeSync(fd, JSON.stringify(payload, null, 2));
130
+ fs.fsyncSync(fd);
131
+ } finally {
132
+ fs.closeSync(fd);
133
+ }
134
+ fs.renameSync(tmp, p);
135
+ try {
136
+ const dfd = fs.openSync(dir, 'r');
137
+ try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
138
+ } catch (_err) { /* directory fsync best-effort (not supported on all FS) */ }
139
+ return { written: true, path: p };
140
+ } catch (err) {
141
+ return { written: false, reason: `write-failed: ${err.message}` };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Count sub-tasks still needing work (pending + in_progress) for taskId.
147
+ * blocked and completed do NOT count. Returns 0 when no matching ledger exists
148
+ * (no durable state ⇒ "nothing known to remain"; callers decide what that means).
149
+ * @param {string} [taskId]
150
+ * @returns {number}
151
+ */
152
+ function remaining(taskId) {
153
+ const subs = read(taskId);
154
+ return subs.filter(s => OPEN_STATUSES.has(s.status)).length;
155
+ }
156
+
157
+ /**
158
+ * Summary counts for heartbeats / status (S3, S4).
159
+ * @param {string} [taskId]
160
+ * @returns {{total:number, remaining:number, completed:number, blocked:number}}
161
+ */
162
+ function summary(taskId) {
163
+ const subs = read(taskId);
164
+ return {
165
+ total: subs.length,
166
+ remaining: subs.filter(s => OPEN_STATUSES.has(s.status)).length,
167
+ completed: subs.filter(s => s.status === 'completed').length,
168
+ blocked: subs.filter(s => s.status === 'blocked').length
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Mark one sub-task's status, preserving the rest. No-op if the ledger task
174
+ * doesn't match. Returns the updated count.
175
+ * @param {string} taskId
176
+ * @param {string} subId
177
+ * @param {string} [status='completed']
178
+ */
179
+ function markStatus(taskId, subId, status = 'completed') {
180
+ const led = readLedger();
181
+ if (!led || (taskId && led.taskId !== taskId)) return { written: false, reason: 'no-matching-ledger' };
182
+ const next = led.subtasks.map(s => (s.id === String(subId) ? { ...s, status } : s));
183
+ return write(led.taskId, next);
184
+ }
185
+
186
+ /**
187
+ * Clear the ledger (e.g. on task completion). Best-effort.
188
+ */
189
+ function clear() {
190
+ try {
191
+ fs.unlinkSync(getLedgerPath());
192
+ return { cleared: true };
193
+ } catch (err) {
194
+ if (err && err.code !== 'ENOENT' && process.env.DEBUG) {
195
+ console.error(`[workspace-subtask-state] clear: ${err.code} ${err.message}`);
196
+ }
197
+ return { cleared: false };
198
+ }
199
+ }
200
+
201
+ module.exports = {
202
+ getLedgerPath,
203
+ normalizeSubtask,
204
+ subtasksFromTodos,
205
+ readLedger,
206
+ read,
207
+ write,
208
+ remaining,
209
+ summary,
210
+ markStatus,
211
+ clear,
212
+ LEDGER_FILE,
213
+ OPEN_STATUSES,
214
+ TERMINAL_STATUSES
215
+ };