@logicseek/hello-cc 0.1.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +26 -4
  3. package/README.zh-CN.md +22 -3
  4. package/bin/hcc.mjs +3 -5622
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +16 -5
  10. package/docs/commands.zh-CN.md +16 -5
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +53 -5
  14. package/docs/guide.zh-CN.md +46 -3
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +15 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +139 -15
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +61 -8
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +232 -29
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +143 -7
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5646 -262
@@ -0,0 +1,405 @@
1
+ // Task CLI commands extracted from bin/hcc.mjs.
2
+
3
+ const TASK_STATUS_SHORTCUTS = new Set(['running', 'review', 'blocked', 'abandoned']);
4
+
5
+ export function createTaskCommands(deps) {
6
+ const {
7
+ connect, now, tx, addEvent, auditPayload, touchCurrentPeer, resolveCurrentPeer,
8
+ parseOpts, intOpt, required, positiveIntOpt, parseTaskIds, wantsHelp, helpTask,
9
+ printResult, table, CliError, ACTIVE_PEER_TTL,
10
+ sendMessage, readRuntime, runtimeRequest, injectPeer,
11
+ queryOpenTasks, claimNextTasksForPeer, claimTaskRowsForPeer,
12
+ takeOverTaskForPeer, assertTaskOwnerForMutation,
13
+ annotateTasksWithLiveness, taskOwnerStateText, taskRowsText,
14
+ observePeerEvidence, clockGraceSuppressed, readClockGraceUntil
15
+ } = deps;
16
+
17
+ async function cmdTask(ctx, args) {
18
+ const sub = args[0];
19
+ if (!sub || wantsHelp(args)) return helpTask();
20
+ if (sub === 'create') return taskCreate(ctx, args.slice(1));
21
+ if (sub === 'dispatch') return taskDispatch(ctx, args.slice(1));
22
+ if (sub === 'list') return taskList(ctx, args.slice(1));
23
+ if (sub === 'claim') return taskClaim(ctx, args.slice(1));
24
+ if (sub === 'takeover') return taskTakeover(ctx, args.slice(1));
25
+ if (sub === 'next') return taskNext(ctx, args.slice(1));
26
+ if (sub === 'update') return taskUpdate(ctx, args.slice(1));
27
+ if (sub === 'done') return taskDone(ctx, args.slice(1));
28
+ if (TASK_STATUS_SHORTCUTS.has(sub)) return taskStatusShortcut(ctx, sub, args.slice(1));
29
+ throw new CliError('BAD_ARGS', `Unknown task command: ${sub}`);
30
+ }
31
+
32
+ async function taskCreate(ctx, args) {
33
+ const opts = parseOpts(args);
34
+ const title = required(opts, 'title');
35
+ const body = opts.body || '';
36
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
37
+ const createdBy = identity.id;
38
+ const assignee = opts.to || opts.assignee || null;
39
+ const priority = intOpt(opts, 'priority', 100);
40
+ const parentId = intOpt(opts, 'parent', null);
41
+ const teamRole = opts.role || opts['team-role'] || null;
42
+ const db = connect(ctx);
43
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
44
+ const t = now();
45
+ const id = tx(db, () => {
46
+ if (parentId && !db.prepare('SELECT id FROM tasks WHERE id = ?').get(parentId)) {
47
+ throw new CliError('NOT_FOUND', `Parent task #${parentId} does not exist`);
48
+ }
49
+ const info = db.prepare(`
50
+ INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
51
+ VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?, ?, ?)
52
+ `).run(title, body, assignee, parentId, teamRole, priority, createdBy, t, t);
53
+ const taskId = Number(info.lastInsertRowid);
54
+ addEvent(db, 'task.created', createdBy, taskId, { title, assignee, priority, parent_id: parentId, team_role: teamRole });
55
+ if (assignee) {
56
+ sendMessage(db, createdBy, assignee, taskId, 'task', `Task #${taskId} assigned: ${title}`);
57
+ }
58
+ return taskId;
59
+ });
60
+ printResult(ctx, { id, title, assignee, priority, parent_id: parentId, team_role: teamRole },
61
+ (data) => `created task #${data.id}: ${data.title}${data.assignee ? ` -> ${data.assignee}` : ''}${data.parent_id ? ` (child of #${data.parent_id})` : ''}`);
62
+ }
63
+
64
+ function dispatchPromptText(task, customMessage = null) {
65
+ if (customMessage) return customMessage;
66
+ return [
67
+ `Please pick up hello-cc task #${task.id}: ${task.title}.`,
68
+ `Run hcc task claim --id ${task.id}, then follow project coordination rules, create a handoff, and mark the task done when finished.`
69
+ ].join(' ');
70
+ }
71
+
72
+ function currentOwnedTaskForPeer(db, peer) {
73
+ return db.prepare(`
74
+ SELECT *
75
+ FROM tasks
76
+ WHERE owner = ?
77
+ AND status IN ('claimed', 'running', 'review', 'blocked')
78
+ ORDER BY
79
+ CASE status
80
+ WHEN 'running' THEN 0
81
+ WHEN 'claimed' THEN 1
82
+ WHEN 'review' THEN 2
83
+ WHEN 'blocked' THEN 3
84
+ ELSE 4
85
+ END,
86
+ priority ASC,
87
+ id ASC
88
+ LIMIT 1
89
+ `).get(peer);
90
+ }
91
+
92
+ function findRuntimeSessionForPeer(runtimeData, peer) {
93
+ return (runtimeData?.sessions || []).find((session) => {
94
+ const sessionPeer = session.peer_id || session.id;
95
+ return session.status === 'running' && (session.id === peer || sessionPeer === peer);
96
+ }) || null;
97
+ }
98
+
99
+ function sessionLooksProviderInteractive(session) {
100
+ return Boolean(session) && ['claude', 'codex'].includes(session.kind);
101
+ }
102
+
103
+ async function taskDispatch(ctx, args) {
104
+ const opts = parseOpts(args, { booleans: ['force', 'no-inject'] });
105
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
106
+ const actor = identity.id;
107
+ const target = required(opts, 'to');
108
+ const requestedTaskId = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
109
+ const title = requestedTaskId ? (opts.title || null) : required(opts, 'title');
110
+ const body = opts.body || '';
111
+ const priority = intOpt(opts, 'priority', 100);
112
+ const customMessage = opts.message ? String(opts.message) : null;
113
+ const injectAllowed = !Boolean(opts['no-inject']);
114
+
115
+ let task = null;
116
+ let messageId = null;
117
+ let currentTask = null;
118
+ let previousAssignee = null;
119
+ const db = connect(ctx);
120
+ try {
121
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
122
+ const t = now();
123
+ task = tx(db, () => {
124
+ if (requestedTaskId) {
125
+ const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
126
+ if (!existing) throw new CliError('NOT_FOUND', `Task #${requestedTaskId} does not exist`);
127
+ if (['done', 'abandoned'].includes(existing.status)) {
128
+ throw new CliError('BAD_STATE', `Task #${requestedTaskId} is ${existing.status}`);
129
+ }
130
+ if (existing.owner && existing.owner !== target) {
131
+ throw new CliError('TASK_OWNED', `Task #${requestedTaskId} is owned by ${existing.owner}`, {
132
+ owner: existing.owner,
133
+ task_id: requestedTaskId,
134
+ attempted_by: actor,
135
+ target
136
+ });
137
+ }
138
+ previousAssignee = existing.assignee || null;
139
+ db.prepare('UPDATE tasks SET assignee = ?, updated_at = ? WHERE id = ?').run(target, t, requestedTaskId);
140
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(requestedTaskId);
141
+ }
142
+ const info = db.prepare(`
143
+ INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
144
+ VALUES (?, ?, 'pending', ?, NULL, NULL, NULL, ?, ?, ?, ?)
145
+ `).run(title, body, target, priority, actor, t, t);
146
+ const taskId = Number(info.lastInsertRowid);
147
+ addEvent(db, 'task.created', actor, taskId, { title, assignee: target, priority, parent_id: null, team_role: null });
148
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
149
+ });
150
+ const durableMessage = dispatchPromptText(task, customMessage);
151
+ messageId = sendMessage(db, actor, target, task.id, 'task', durableMessage);
152
+ currentTask = currentOwnedTaskForPeer(db, target);
153
+ } finally {
154
+ db.close();
155
+ }
156
+
157
+ const durableMessage2 = dispatchPromptText(task, customMessage);
158
+ let session = null;
159
+ let injected = false;
160
+ let injectionReason = injectAllowed ? 'runtime_unavailable' : 'no_inject';
161
+ const busyTask = currentTask && Number(currentTask.id) !== Number(task.id) ? currentTask : null;
162
+ if (injectAllowed) {
163
+ let runtime = null;
164
+ let runtimeData = null;
165
+ try {
166
+ runtime = readRuntime(ctx);
167
+ runtimeData = await runtimeRequest(ctx, 'GET', '/api/sessions', null, runtime);
168
+ } catch (err) {
169
+ if (!(err instanceof CliError) || !['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) throw err;
170
+ }
171
+ session = runtimeData ? findRuntimeSessionForPeer(runtimeData, target) : null;
172
+ if (!session) {
173
+ injectionReason = runtimeData ? 'session_not_running' : 'runtime_unavailable';
174
+ } else if (!customMessage && !sessionLooksProviderInteractive(session)) {
175
+ injectionReason = 'unsupported_session_kind';
176
+ } else if (busyTask && !Boolean(opts.force)) {
177
+ injectionReason = 'target_busy';
178
+ } else {
179
+ try {
180
+ await injectPeer(ctx, target, durableMessage2, true, runtime, actor);
181
+ injected = true;
182
+ injectionReason = 'injected';
183
+ } catch (err) {
184
+ if (!(err instanceof CliError)) throw err;
185
+ if (['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) {
186
+ injectionReason = 'runtime_unavailable';
187
+ } else if (['NOT_FOUND', 'SESSION_NOT_RUNNING'].includes(err.code)) {
188
+ injectionReason = 'session_not_running';
189
+ } else {
190
+ throw err;
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ const eventDb = connect(ctx);
197
+ try {
198
+ addEvent(eventDb, 'task.dispatched', actor, task.id, auditPayload({
199
+ actor, target, source: 'cli', admin: actor !== target,
200
+ task_id: task.id, title: task.title, message_id: messageId,
201
+ injected, injection_reason: injectionReason, busy_task: busyTask?.id || null,
202
+ previous_assignee: previousAssignee
203
+ }));
204
+ } finally {
205
+ eventDb.close();
206
+ }
207
+ const result = {
208
+ task,
209
+ target,
210
+ message_id: messageId,
211
+ message: durableMessage2,
212
+ injected,
213
+ delivery: injected ? 'message+inject' : 'message-only',
214
+ injection_reason: injectionReason,
215
+ session: session ? {
216
+ id: session.id,
217
+ peer_id: session.peer_id || session.id,
218
+ kind: session.kind,
219
+ status: session.status
220
+ } : null,
221
+ previous_assignee: previousAssignee,
222
+ blocked_by_task: busyTask ? {
223
+ id: busyTask.id,
224
+ status: busyTask.status,
225
+ title: busyTask.title
226
+ } : null
227
+ };
228
+ printResult(ctx, result, (data) => {
229
+ const base = `dispatched task #${data.task.id} to ${data.target} with message #${data.message_id}`;
230
+ if (data.injected) return `${base} and injected live input`;
231
+ if (data.injection_reason === 'target_busy' && data.blocked_by_task) {
232
+ return `${base} (not injected: ${data.target} already owns task #${data.blocked_by_task.id})`;
233
+ }
234
+ if (data.injection_reason === 'unsupported_session_kind' && data.session) {
235
+ return `${base} (not injected: managed ${data.session.kind} session needs an explicit shell-safe message)`;
236
+ }
237
+ if (data.injection_reason === 'session_not_running') return `${base} (not injected: target is not a running managed session)`;
238
+ if (data.injection_reason === 'runtime_unavailable') return `${base} (not injected: web runtime is unavailable)`;
239
+ if (data.injection_reason === 'no_inject') return `${base} (message only)`;
240
+ return `${base} (not injected: ${data.injection_reason})`;
241
+ });
242
+ }
243
+
244
+ async function taskList(ctx, args) {
245
+ const opts = parseOpts(args, { booleans: ['all'] });
246
+ const status = opts.status || null;
247
+ const peer = opts.peer || null;
248
+ const limit = intOpt(opts, 'limit', 50);
249
+ const db = connect(ctx);
250
+ let rows;
251
+ if (status && peer) {
252
+ rows = db.prepare(`SELECT * FROM tasks WHERE status = ? AND (owner = ? OR assignee = ?) ORDER BY priority ASC, id ASC LIMIT ?`).all(status, peer, peer, limit);
253
+ } else if (status) {
254
+ rows = db.prepare('SELECT * FROM tasks WHERE status = ? ORDER BY priority ASC, id ASC LIMIT ?').all(status, limit);
255
+ } else if (opts.all && peer) {
256
+ rows = db.prepare(`SELECT * FROM tasks WHERE owner = ? OR assignee = ? ORDER BY status ASC, priority ASC, id ASC LIMIT ?`).all(peer, peer, limit);
257
+ } else if (opts.all) {
258
+ rows = db.prepare('SELECT * FROM tasks ORDER BY status ASC, priority ASC, id ASC LIMIT ?').all(limit);
259
+ } else if (peer) {
260
+ rows = queryOpenTasks(db, limit, peer);
261
+ } else {
262
+ rows = queryOpenTasks(db, limit);
263
+ }
264
+ const t = now();
265
+ const peers = db.prepare(`SELECT id, status, pid, pid_start_token, pid_command_hash, last_seen_at, (? - last_seen_at) AS age_sec FROM peers`).all(t);
266
+ for (const peerRow of peers) {
267
+ const binding = db.prepare(`SELECT transport, runtime_target FROM peer_bindings WHERE peer = ?`).get(peerRow.id) || null;
268
+ const evidence = observePeerEvidence(ctx, peerRow, binding);
269
+ peerRow.evidence_state = evidence.state;
270
+ peerRow.evidence_reason = evidence.reason;
271
+ }
272
+ const graceUntil = readClockGraceUntil(db);
273
+ const locks = clockGraceSuppressed(t, graceUntil)
274
+ ? db.prepare('SELECT * FROM locks').all()
275
+ : db.prepare('SELECT * FROM locks WHERE expires_at > ?').all(t);
276
+ rows = annotateTasksWithLiveness(rows, peers, locks, t, ACTIVE_PEER_TTL, graceUntil);
277
+ printResult(ctx, rows, (data) => table(data, [
278
+ { label: 'id', value: (r) => `#${r.id}` },
279
+ { label: 'status', value: (r) => r.status },
280
+ { label: 'prio', value: (r) => r.priority },
281
+ { label: 'assignee', value: (r) => r.assignee || '' },
282
+ { label: 'owner', value: (r) => r.owner || '' },
283
+ { label: 'owner_state', value: (r) => taskOwnerStateText(r) },
284
+ { label: 'parent', value: (r) => r.parent_id ? `#${r.parent_id}` : '' },
285
+ { label: 'role', value: (r) => r.team_role || '' },
286
+ { label: 'title', value: (r) => r.title }
287
+ ]));
288
+ }
289
+
290
+ async function taskClaim(ctx, args) {
291
+ const opts = parseOpts(args, { booleans: ['force'], arrays: ['id', 'ids'] });
292
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
293
+ const peer = identity.id;
294
+ const ids = parseTaskIds(opts);
295
+ const db = connect(ctx);
296
+ touchCurrentPeer(db, ctx, identity, 'working', 'shell');
297
+ let tasks;
298
+ try {
299
+ tasks = claimTaskRowsForPeer(db, peer, ids, { force: Boolean(opts.force) });
300
+ } catch (err) {
301
+ notifyTaskOwnerConflict(ctx, err);
302
+ throw err;
303
+ }
304
+ printResult(ctx, ids.length === 1 ? tasks[0] : tasks, (data) => taskRowsText(Array.isArray(data) ? data : [data], 'claimed'));
305
+ }
306
+
307
+ function notifyTaskOwnerConflict(ctx, err) {
308
+ if (err?.code !== 'TASK_OWNED' || !err.extra?.notify_owner) return;
309
+ const { owner, task_id: taskId, attempted_by: attemptedBy, action } = err.extra;
310
+ if (!owner || !attemptedBy || owner === attemptedBy) return;
311
+ let db = null;
312
+ try {
313
+ db = connect(ctx);
314
+ sendMessage(db, attemptedBy, owner, taskId || null, 'task.owner-conflict',
315
+ `Task #${taskId} is owned by ${owner}; ${attemptedBy} attempted ${action || 'modify'} and hello-cc left ownership unchanged.`);
316
+ err.extra.notified = true;
317
+ } catch {
318
+ err.extra.notified = false;
319
+ } finally {
320
+ try { db?.close(); } catch {}
321
+ }
322
+ }
323
+
324
+ async function taskTakeover(ctx, args) {
325
+ const opts = parseOpts(args, { booleans: ['force'] });
326
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
327
+ const peer = identity.id;
328
+ const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
329
+ if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
330
+ const reason = required(opts, 'reason');
331
+ const policy = opts.force ? 'any' : (opts.policy || 'blocked-or-stale');
332
+ const staleAfter = positiveIntOpt(opts, 'stale-after', ACTIVE_PEER_TTL, { max: 86400 * 30 });
333
+ const db = connect(ctx);
334
+ touchCurrentPeer(db, ctx, identity, 'working', 'shell');
335
+ const task = takeOverTaskForPeer(db, peer, id, {
336
+ reason, policy, staleAfter,
337
+ ownerEvidenceFor: (_owner, _row, ownerRow, binding) => observePeerEvidence(ctx, ownerRow, binding)
338
+ });
339
+ printResult(ctx, task, (data) => `took over task #${data.id}: ${data.title}`);
340
+ }
341
+
342
+ async function taskNext(ctx, args) {
343
+ const opts = parseOpts(args, { booleans: ['force'] });
344
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
345
+ const peer = identity.id;
346
+ const count = positiveIntOpt(opts, 'count', 1, { max: 50 });
347
+ const db = connect(ctx);
348
+ touchCurrentPeer(db, ctx, identity, 'working', 'shell');
349
+ const result = claimNextTasksForPeer(db, peer, { force: Boolean(opts.force), count });
350
+ printResult(ctx, count === 1 ? (result.current || result.tasks[0] || null) : result, (data) => {
351
+ if (!data) return 'no pending task';
352
+ if (data.current === true) return `current task #${data.id}: ${data.title} (${data.status})`;
353
+ if (data.current) return `current task #${data.current.id}: ${data.current.title} (${data.current.status})`;
354
+ if (data.tasks) return data.tasks.length ? taskRowsText(data.tasks, 'claimed') : 'no pending task';
355
+ return `claimed task #${data.id}: ${data.title}`;
356
+ });
357
+ }
358
+
359
+ async function taskUpdate(ctx, args) {
360
+ const opts = parseOpts(args, { booleans: ['force'] });
361
+ const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
362
+ const peer = identity.id;
363
+ const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
364
+ const status = required(opts, 'status');
365
+ if (!['pending', 'claimed', 'running', 'review', 'blocked', 'done', 'abandoned'].includes(status)) {
366
+ throw new CliError('BAD_ARGS', `Unsupported status: ${status}`);
367
+ }
368
+ if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
369
+ const db = connect(ctx);
370
+ touchCurrentPeer(db, ctx, identity, status === 'done' ? 'idle' : 'working', 'shell');
371
+ let task;
372
+ try {
373
+ task = tx(db, () => {
374
+ const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
375
+ if (!row) throw new CliError('NOT_FOUND', `Task #${id} does not exist`);
376
+ assertTaskOwnerForMutation(db, peer, row, `update:${status}`);
377
+ const t = now();
378
+ const completedAt = status === 'done' ? t : row.completed_at;
379
+ db.prepare(`UPDATE tasks SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?`).run(status, completedAt, t, id);
380
+ addEvent(db, `task.${status}`, peer, id, { summary: opts.summary || opts.reason || '' });
381
+ if (opts.body) {
382
+ sendMessage(db, peer, opts.to || 'all', id, 'task.update', opts.body);
383
+ }
384
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
385
+ });
386
+ } catch (err) {
387
+ notifyTaskOwnerConflict(ctx, err);
388
+ throw err;
389
+ }
390
+ printResult(ctx, task, (data) => `task #${data.id} -> ${data.status}`);
391
+ }
392
+
393
+ async function taskStatusShortcut(ctx, status, args) {
394
+ return taskUpdate(ctx, args.concat(['--status', status]));
395
+ }
396
+
397
+ async function taskDone(ctx, args) {
398
+ const opts = parseOpts(args, { booleans: ['force'] });
399
+ opts.status = 'done';
400
+ if (opts.summary && !opts.body) opts.body = opts.summary;
401
+ return taskUpdate(ctx, args.concat(['--status', 'done']));
402
+ }
403
+
404
+ return { cmdTask, notifyTaskOwnerConflict };
405
+ }
@@ -0,0 +1,131 @@
1
+ // Team CLI commands extracted from bin/hcc.mjs.
2
+
3
+ export function createTeamCommands(deps) {
4
+ const {
5
+ connect, now, tx, addEvent, touchCurrentPeer, resolveCurrentPeer,
6
+ parseOpts, intOpt, wantsHelp, helpTeam, printResult, CliError,
7
+ CLI_NAME, sendMessage,
8
+ taskById, teamChildren, teamSummary,
9
+ inferTeamItems, assignTeamWorkers, expandTeamWorkers,
10
+ shellQuoteArg
11
+ } = deps;
12
+
13
+ async function cmdTeam(ctx, args) {
14
+ const sub = args[0];
15
+ if (!sub || wantsHelp(args)) return helpTeam();
16
+ if (sub === 'plan') return teamPlan(ctx, args.slice(1));
17
+ if (sub === 'start') return teamStart(ctx, args.slice(1));
18
+ if (sub === 'status') return teamStatus(ctx, args.slice(1));
19
+ throw new CliError('BAD_ARGS', `Unknown team command: ${sub}`);
20
+ }
21
+
22
+ async function teamPlan(ctx, args) {
23
+ const opts = parseOpts(args, { arrays: ['item'] });
24
+ const parentId = intOpt(opts, 'from-task', intOpt(opts, 'task', intOpt({ task: opts._[0] }, 'task')));
25
+ if (!parentId) throw new CliError('BAD_ARGS', 'Missing --from-task');
26
+ const db = connect(ctx);
27
+ const parent = taskById(db, parentId);
28
+ if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
29
+ const items = assignTeamWorkers(inferTeamItems(parent, opts), opts.workers, parentId);
30
+ const data = { parent, items };
31
+ printResult(ctx, data, (plan) => {
32
+ const lines = [`team plan for task #${plan.parent.id}: ${plan.parent.title}`];
33
+ plan.items.forEach((item, index) => {
34
+ lines.push(`${index + 1}. ${item.role}: ${item.title}${item.assignee ? ` -> ${item.assignee}` : ''}`);
35
+ });
36
+ lines.push('', `start: ${CLI_NAME} team start --from-task ${plan.parent.id} ${plan.items.map((item) => `--item ${shellQuoteArg(`${item.assignee ? `${item.assignee}:` : ''}${item.role}:${item.title}`)}`).join(' ')}`.trim());
37
+ return lines.join('\n');
38
+ });
39
+ }
40
+
41
+ async function teamStart(ctx, args) {
42
+ const opts = parseOpts(args, { arrays: ['item'], booleans: ['force'] });
43
+ const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
44
+ const actor = identity.id;
45
+ const parentId = intOpt(opts, 'from-task', intOpt(opts, 'task', intOpt({ task: opts._[0] }, 'task')));
46
+ if (!parentId) throw new CliError('BAD_ARGS', 'Missing --from-task');
47
+ const priorityBase = intOpt(opts, 'priority', 100);
48
+ const db = connect(ctx);
49
+ touchCurrentPeer(db, ctx, identity, null, 'shell');
50
+ const result = tx(db, () => {
51
+ const parent = taskById(db, parentId);
52
+ if (!parent) throw new CliError('NOT_FOUND', `Task #${parentId} does not exist`);
53
+ const existing = teamChildren(db, parentId);
54
+ if (existing.length && !opts.force) {
55
+ throw new CliError('TEAM_EXISTS', `Task #${parentId} already has ${existing.length} team subtask(s); use --force to add more`, {
56
+ parent_id: parentId,
57
+ children: existing.map((task) => task.id)
58
+ });
59
+ }
60
+ const items = assignTeamWorkers(inferTeamItems(parent, opts), opts.workers, parentId);
61
+ const t = now();
62
+ const children = [];
63
+ items.forEach((item, index) => {
64
+ const info = db.prepare(`
65
+ INSERT INTO tasks(title, body, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
66
+ VALUES (?, ?, 'pending', ?, NULL, ?, ?, ?, ?, ?, ?)
67
+ `).run(
68
+ item.title,
69
+ opts.body || `Subtask for #${parentId}: ${parent.title}`,
70
+ item.assignee || null,
71
+ parentId,
72
+ item.role || `worker-${index + 1}`,
73
+ priorityBase + index,
74
+ actor,
75
+ t,
76
+ t
77
+ );
78
+ const taskId = Number(info.lastInsertRowid);
79
+ addEvent(db, 'task.created', actor, taskId, {
80
+ title: item.title,
81
+ assignee: item.assignee || null,
82
+ priority: priorityBase + index,
83
+ parent_id: parentId,
84
+ team_role: item.role || null
85
+ });
86
+ if (item.assignee) sendMessage(db, actor, item.assignee, taskId, 'task', `Task #${taskId} assigned: ${item.title}`);
87
+ children.push(taskById(db, taskId));
88
+ });
89
+ addEvent(db, 'team.started', actor, parentId, {
90
+ child_tasks: children.map((task) => task.id),
91
+ workers: expandTeamWorkers(opts.workers || [], parentId)
92
+ });
93
+ return { parent, children };
94
+ });
95
+ printResult(ctx, result, (data) => {
96
+ const lines = [`started team for task #${data.parent.id}: ${data.children.length} subtask${data.children.length === 1 ? '' : 's'}`];
97
+ for (const child of data.children) {
98
+ lines.push(`- #${child.id} ${child.team_role || 'worker'}${child.assignee ? ` -> ${child.assignee}` : ''}: ${child.title}`);
99
+ }
100
+ return lines.join('\n');
101
+ });
102
+ }
103
+
104
+ async function teamStatus(ctx, args) {
105
+ const opts = parseOpts(args);
106
+ const parentId = intOpt(opts, 'task', intOpt(opts, 'from-task', intOpt({ task: opts._[0] }, 'task')));
107
+ if (!parentId) throw new CliError('BAD_ARGS', 'Missing --task');
108
+ const db = connect(ctx);
109
+ const data = teamSummary(db, parentId);
110
+ printResult(ctx, data, (summary) => {
111
+ const countText = Object.entries(summary.counts).map(([status, count]) => `${status}:${count}`).join(', ') || 'none';
112
+ const lines = [
113
+ `team task #${summary.parent.id}: ${summary.parent.title}`,
114
+ `parent status: ${summary.parent.status}`,
115
+ `subtasks: ${summary.children.length} (${countText})`
116
+ ];
117
+ for (const child of summary.children) {
118
+ lines.push(`- #${child.id} ${child.status} ${child.team_role || 'worker'}${child.owner ? ` owner=${child.owner}` : ''}${child.assignee ? ` assignee=${child.assignee}` : ''}: ${child.title}`);
119
+ }
120
+ if (summary.handoffs.length) {
121
+ lines.push('handoffs:');
122
+ for (const handoff of summary.handoffs.slice(-8)) {
123
+ lines.push(`- #${handoff.id} task #${handoff.task_id || ''} ${handoff.from_peer}${handoff.to_peer ? ` -> ${handoff.to_peer}` : ''}: ${handoff.summary}`);
124
+ }
125
+ }
126
+ return lines.join('\n');
127
+ });
128
+ }
129
+
130
+ return { cmdTeam };
131
+ }