@logicseek/hello-cc 0.1.5 → 0.1.7

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 (88) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +21 -3
  3. package/README.zh-CN.md +21 -3
  4. package/assets/screenshots/web-console.png +0 -0
  5. package/bin/hcc.mjs +1943 -3519
  6. package/docs/README.md +2 -0
  7. package/docs/README.zh-CN.md +3 -1
  8. package/docs/architecture-split-review.md +217 -0
  9. package/docs/architecture.md +385 -0
  10. package/docs/architecture.zh-CN.md +364 -0
  11. package/docs/layout-split-stack-audit.md +298 -0
  12. package/lib/automation.mjs +8 -0
  13. package/lib/cli-args.mjs +1 -1
  14. package/lib/cli-runtime.mjs +45 -0
  15. package/lib/coordination-state.mjs +218 -0
  16. package/lib/core/coordination/automation.mjs +222 -0
  17. package/lib/core/coordination/locks.mjs +46 -0
  18. package/lib/core/coordination/messages.mjs +116 -0
  19. package/lib/core/coordination/tasks.mjs +240 -0
  20. package/lib/core/coordination/teams.mjs +73 -0
  21. package/lib/core/coordination/timeline.mjs +196 -0
  22. package/lib/core/peers/bindings.mjs +83 -0
  23. package/lib/core/peers/format.mjs +13 -0
  24. package/lib/core/peers/identity.mjs +61 -0
  25. package/lib/core/peers/liveness.mjs +90 -0
  26. package/lib/core/peers/reconcile.mjs +238 -0
  27. package/lib/core/peers/session.mjs +14 -0
  28. package/lib/core/sessions/launch.mjs +89 -0
  29. package/lib/db/schema.mjs +324 -0
  30. package/lib/db/stores/peers.mjs +231 -0
  31. package/lib/db-schema.mjs +7 -324
  32. package/lib/errors.mjs +1 -7
  33. package/lib/handoff.mjs +26 -0
  34. package/lib/help.mjs +3 -0
  35. package/lib/integrations/hooks.mjs +171 -0
  36. package/lib/integrations/peers/identity.mjs +208 -0
  37. package/lib/integrations/peers/processes.mjs +151 -0
  38. package/lib/integrations/providers.mjs +282 -0
  39. package/lib/integrations/shims/script.mjs +384 -0
  40. package/lib/integrations/shims.mjs +106 -0
  41. package/lib/json-file.mjs +1 -14
  42. package/lib/locks.mjs +10 -0
  43. package/lib/messages.mjs +3 -0
  44. package/lib/package-meta.mjs +6 -24
  45. package/lib/peer-bindings.mjs +14 -0
  46. package/lib/peer-format.mjs +4 -0
  47. package/lib/peer-identity.mjs +15 -0
  48. package/lib/project-context.mjs +6 -0
  49. package/lib/project-registry.mjs +7 -0
  50. package/lib/provider-commands.mjs +13 -0
  51. package/lib/release/package-meta.mjs +31 -0
  52. package/lib/release/release-notes.mjs +40 -0
  53. package/lib/release-notes.mjs +6 -40
  54. package/lib/runtime/client.mjs +42 -0
  55. package/lib/runtime/paths.mjs +45 -0
  56. package/lib/runtime/project-context.mjs +32 -0
  57. package/lib/runtime/projects.mjs +76 -0
  58. package/lib/runtime/state.mjs +128 -0
  59. package/lib/runtime-client.mjs +3 -0
  60. package/lib/runtime-paths.mjs +11 -45
  61. package/lib/runtime-state.mjs +11 -0
  62. package/lib/session-launch.mjs +17 -0
  63. package/lib/setup.mjs +17 -293
  64. package/lib/shared/errors.mjs +7 -0
  65. package/lib/shared/json-file.mjs +14 -0
  66. package/lib/shim-script.mjs +1 -384
  67. package/lib/state-render.mjs +5 -0
  68. package/lib/task-cli.mjs +45 -0
  69. package/lib/task-liveness.mjs +8 -0
  70. package/lib/task-store.mjs +3 -0
  71. package/lib/team-planning.mjs +7 -0
  72. package/lib/terminal/tmux.mjs +372 -0
  73. package/lib/timeline.mjs +12 -0
  74. package/lib/tmux.mjs +18 -0
  75. package/lib/ui/help.mjs +419 -0
  76. package/lib/ui/state-render.mjs +61 -0
  77. package/lib/web/http.mjs +55 -0
  78. package/lib/web/peer-actions.mjs +306 -0
  79. package/lib/web/runtime.mjs +154 -0
  80. package/lib/web/ui-template.mjs +2057 -0
  81. package/lib/web-http.mjs +7 -55
  82. package/lib/web-peer-actions.mjs +3 -0
  83. package/lib/web-runtime.mjs +17 -108
  84. package/lib/web-ui-template.mjs +3 -1909
  85. package/package.json +1 -1
  86. package/scripts/github-release.mjs +2 -2
  87. package/scripts/regression.mjs +2901 -79
  88. package/scripts/release-notes.mjs +2 -2
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { projectDbPath } from './runtime/paths.mjs';
5
+
6
+ export function createContext(global = {}, deps = {}) {
7
+ const cwd = deps.cwd || process.cwd();
8
+ const detectRoot = deps.detectRoot;
9
+ if (typeof detectRoot !== 'function') throw new TypeError('createContext requires deps.detectRoot');
10
+ const root = detectRoot(cwd, global.root);
11
+ const envDb = global.root ? null : process.env.HCC_DB;
12
+ const dbPath = path.resolve(global.db || envDb || projectDbPath(root));
13
+ return { cwd, root, dbPath, json: global.json, explicitRoot: Boolean(global.root || process.env.HCC_ROOT) };
14
+ }
15
+
16
+ export function shellCommand(args, quoteArg) {
17
+ if (typeof quoteArg !== 'function') throw new TypeError('shellCommand requires quoteArg');
18
+ return args.map(quoteArg).join(' ');
19
+ }
20
+
21
+ export function tailFile(file, maxBytes = 12000) {
22
+ try {
23
+ const stat = fs.statSync(file);
24
+ const size = Math.min(stat.size, maxBytes);
25
+ const fd = fs.openSync(file, 'r');
26
+ try {
27
+ const buf = Buffer.alloc(size);
28
+ fs.readSync(fd, buf, 0, size, Math.max(0, stat.size - size));
29
+ return buf.toString('utf8').trim();
30
+ } finally {
31
+ fs.closeSync(fd);
32
+ }
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ export function commandPath(argv = process.argv) {
39
+ try { return fs.realpathSync(argv[1]); }
40
+ catch { return path.resolve(argv[1]); }
41
+ }
42
+
43
+ export function packageRoot(argv = process.argv) {
44
+ return path.resolve(path.dirname(commandPath(argv)), '..');
45
+ }
@@ -0,0 +1,218 @@
1
+ import {
2
+ deriveAutomation,
3
+ renderAutomationContext
4
+ } from './core/coordination/automation.mjs';
5
+ import {
6
+ annotateTasksWithLiveness,
7
+ formatOpenTaskLine
8
+ } from './core/peers/liveness.mjs';
9
+ import { lockLabel } from './core/coordination/locks.mjs';
10
+ import { timelineFromRows } from './core/coordination/timeline.mjs';
11
+
12
+ const DEFAULT_ACTIVE_PEER_TTL = 600;
13
+ const DEFAULT_LOCK_TTL = 900;
14
+ const DEFAULT_CLI_NAME = 'hcc';
15
+
16
+ function defaultNow() {
17
+ return Math.floor(Date.now() / 1000);
18
+ }
19
+
20
+ function requireFn(value, name) {
21
+ if (typeof value !== 'function') throw new TypeError(`createCoordinationState requires deps.${name}`);
22
+ return value;
23
+ }
24
+
25
+ export function createCoordinationState(deps = {}) {
26
+ const activePeerTtl = Number(deps.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
27
+ const cliName = deps.cliName || DEFAULT_CLI_NAME;
28
+ const connect = requireFn(deps.connect, 'connect');
29
+ const defaultLockTtl = Number(deps.defaultLockTtl ?? DEFAULT_LOCK_TTL);
30
+ const now = typeof deps.now === 'function' ? deps.now : defaultNow;
31
+ const queryInbox = requireFn(deps.queryInbox, 'queryInbox');
32
+ const queryOpenTasks = requireFn(deps.queryOpenTasks, 'queryOpenTasks');
33
+ const queryTimelineMessages = requireFn(deps.queryTimelineMessages, 'queryTimelineMessages');
34
+ const touchCurrentPeer = typeof deps.touchCurrentPeer === 'function'
35
+ ? deps.touchCurrentPeer
36
+ : () => {};
37
+
38
+ function collectStateSnapshot(db, ctx, peer = null, opts = {}) {
39
+ const t = now();
40
+ const peers = db.prepare(`
41
+ SELECT id, kind, role, status, worktree, branch, pid, capabilities,
42
+ created_at, last_seen_at, (? - last_seen_at) AS age_sec
43
+ FROM peers
44
+ ORDER BY last_seen_at DESC, id ASC
45
+ LIMIT 200
46
+ `).all(t);
47
+ const taskRows = queryOpenTasks(db, 200);
48
+ const locks = db.prepare(`
49
+ SELECT resource, base_resource, scope, owner, task_id, reason, expires_at, created_at
50
+ FROM locks
51
+ WHERE expires_at > ?
52
+ ORDER BY resource ASC
53
+ LIMIT 200
54
+ `).all(t);
55
+ const tasks = annotateTasksWithLiveness(taskRows, peers, locks, t, activePeerTtl);
56
+ const messages = peer
57
+ ? queryInbox(db, peer, false, 50)
58
+ : db.prepare(`
59
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
60
+ FROM messages
61
+ ORDER BY id DESC
62
+ LIMIT 50
63
+ `).all().reverse();
64
+ const timelineMessages = queryTimelineMessages(db, peer, 80);
65
+ const handoffs = db.prepare(`
66
+ SELECT id, task_id, from_peer, to_peer, summary, changed_files, tests, risks, created_at
67
+ FROM handoffs
68
+ ORDER BY id DESC
69
+ LIMIT 50
70
+ `).all().reverse();
71
+ const events = db.prepare(`
72
+ SELECT id, type, actor, task_id, payload, created_at
73
+ FROM events
74
+ ORDER BY id DESC
75
+ LIMIT 80
76
+ `).all().reverse();
77
+ const snapshot = {
78
+ root: ctx.root,
79
+ db: ctx.dbPath,
80
+ now: t,
81
+ active_peer_ttl: activePeerTtl,
82
+ peers,
83
+ tasks,
84
+ locks,
85
+ messages,
86
+ handoffs,
87
+ events
88
+ };
89
+ snapshot.timeline = timelineFromRows({ messages: timelineMessages, handoffs, tasks, locks, events }, peer);
90
+ snapshot.automation = deriveAutomation(snapshot, peer, opts, {
91
+ activePeerTtl,
92
+ defaultLockTtl,
93
+ cliName
94
+ });
95
+ return snapshot;
96
+ }
97
+
98
+ function buildHookCoordinationContext(db, ctx, peerId) {
99
+ const snapshot = collectStateSnapshot(db, ctx, peerId);
100
+ const openTasks = snapshot.tasks.slice(0, 8);
101
+ const unread = snapshot.messages.slice(0, 5);
102
+ const peers = snapshot.peers.slice(0, 8);
103
+ const parts = [
104
+ '[hello-cc coordination]',
105
+ `peer: ${peerId}`,
106
+ 'This is live project coordination context injected by hello-cc.',
107
+ 'You are not isolated for project coordination: hcc is the source of truth for other Claude/Codex/shell sessions in this project.',
108
+ 'If the user asks what other sessions are doing, what tasks exist, or whether you can see other sessions, do not answer from generic model knowledge and do not say sessions are isolated. Run hcc status, hcc state, hcc peers, hcc task list, hcc msg inbox, and hcc lock list, then answer from those results.',
109
+ 'Tasks are project work facts, not read/unread items. Open tasks stay relevant to every session until they are marked done or abandoned. Messages are the unread/ack notification channel.',
110
+ 'If you already own a current task, continue that task until handoff/done/blocked; do not claim a different task just because a new prompt arrived.',
111
+ 'When a hello-cc message asks for a response, reply with hcc msg reply --id <message-id> --body "<answer>" so the answer stays in the same thread.'
112
+ ];
113
+
114
+ if (peers.length > 0) {
115
+ parts.push('[hello-cc known peers]');
116
+ parts.push(...peers.map((peer) => {
117
+ const age = Math.max(0, snapshot.now - Number(peer.last_seen_at || 0));
118
+ const active = age <= activePeerTtl ? 'active' : 'stale';
119
+ return `- ${peer.id} ${peer.kind || 'other'} ${peer.status || 'idle'} ${active}`;
120
+ }));
121
+ } else {
122
+ parts.push('[hello-cc known peers]\n(none)');
123
+ }
124
+
125
+ if (snapshot.automation.current_task) {
126
+ const task = snapshot.automation.current_task;
127
+ parts.push('[hello-cc current task]');
128
+ parts.push(`#${task.id} ${task.status}: ${task.title}`);
129
+ }
130
+
131
+ if (openTasks.length > 0) {
132
+ parts.push('[hello-cc open tasks]');
133
+ parts.push(...openTasks.map((task) => `- ${formatOpenTaskLine(task)}`));
134
+ } else {
135
+ parts.push('[hello-cc open tasks]\n(none)');
136
+ }
137
+
138
+ if (unread.length > 0) {
139
+ parts.push('[hello-cc unread messages]');
140
+ parts.push(...unread.map((m) =>
141
+ `- #${m.id} from ${m.sender}${m.task_id ? ` task #${m.task_id}` : ''}${m.reply_to ? ` reply #${m.reply_to}` : ''}: ${m.body}`
142
+ ));
143
+ } else {
144
+ parts.push('[hello-cc unread messages]\n(none)');
145
+ }
146
+
147
+ if (snapshot.locks.length > 0) {
148
+ parts.push('[hello-cc active locks]');
149
+ parts.push(...snapshot.locks.slice(0, 8).map((lock) =>
150
+ `- ${lockLabel(lock)} owner=${lock.owner}${lock.task_id ? ` task #${lock.task_id}` : ''}`));
151
+ } else {
152
+ parts.push('[hello-cc active locks]\n(none)');
153
+ }
154
+
155
+ parts.push(renderAutomationContext(snapshot.automation));
156
+
157
+ return { text: parts.join('\n'), messages: unread };
158
+ }
159
+
160
+ function ackMessages(db, peerId, messages) {
161
+ if (!messages.length) return;
162
+ const t = now();
163
+ for (const m of messages) {
164
+ db.prepare(`
165
+ INSERT INTO message_reads(message_id, peer, read_at)
166
+ VALUES (?, ?, ?)
167
+ ON CONFLICT(message_id, peer) DO UPDATE SET read_at = excluded.read_at
168
+ `).run(m.id, peerId, t);
169
+ }
170
+ }
171
+
172
+ function statusSummary(ctx, peer = null, identity = null) {
173
+ const db = connect(ctx);
174
+ try {
175
+ if (identity) touchCurrentPeer(db, ctx, identity, null, 'shell');
176
+ const t = now();
177
+ const activePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at >= ?').get(t - activePeerTtl).n;
178
+ const stalePeers = db.prepare('SELECT COUNT(*) AS n FROM peers WHERE last_seen_at < ?').get(t - activePeerTtl).n;
179
+ const taskRows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status ORDER BY status').all();
180
+ const locks = db.prepare('SELECT COUNT(*) AS n FROM locks WHERE expires_at > ?').get(t).n;
181
+ const unread = peer ? queryInbox(db, peer, false, 1000).length : null;
182
+ const recent = db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 8').all().reverse();
183
+ return {
184
+ root: ctx.root,
185
+ db: ctx.dbPath,
186
+ active_peers: activePeers,
187
+ stale_peers: stalePeers,
188
+ tasks: taskRows,
189
+ active_locks: locks,
190
+ unread,
191
+ recent_events: recent
192
+ };
193
+ } finally {
194
+ db.close();
195
+ }
196
+ }
197
+
198
+ function statusSnapshot(ctx, peer = null, opts = {}) {
199
+ const db = connect(ctx);
200
+ try {
201
+ return collectStateSnapshot(db, ctx, peer, opts);
202
+ } finally {
203
+ try {
204
+ db.close();
205
+ } catch {
206
+ // Ignore close failures for short-lived snapshots.
207
+ }
208
+ }
209
+ }
210
+
211
+ return {
212
+ ackMessages,
213
+ buildHookCoordinationContext,
214
+ collectStateSnapshot,
215
+ statusSnapshot,
216
+ statusSummary
217
+ };
218
+ }
@@ -0,0 +1,222 @@
1
+ import { shellQuoteArg } from '../../format.mjs';
2
+ import {
3
+ lockArgv,
4
+ lockBaseResource,
5
+ lockLabel,
6
+ lockScope,
7
+ locksConflict,
8
+ normalizeLockScope,
9
+ scopedLockResource
10
+ } from './locks.mjs';
11
+ import {
12
+ shouldHideTimelineMessage,
13
+ uniqueList
14
+ } from './timeline.mjs';
15
+ import { summarizeTask } from '../peers/liveness.mjs';
16
+
17
+ const DEFAULT_CLI_NAME = 'hcc';
18
+ const DEFAULT_ACTIVE_PEER_TTL = 600;
19
+ const DEFAULT_LOCK_TTL = 900;
20
+
21
+ function nowSec() {
22
+ return Math.floor(Date.now() / 1000);
23
+ }
24
+
25
+ export function actionCommand(argv, cliName = DEFAULT_CLI_NAME) {
26
+ if (!argv?.length) return '';
27
+ return [cliName, ...argv].map(shellQuoteArg).join(' ');
28
+ }
29
+
30
+ export function makeAction(kind, argv, reason, mutates = true, extra = {}, config = {}) {
31
+ return {
32
+ kind,
33
+ reason,
34
+ mutates,
35
+ argv,
36
+ command: actionCommand(argv, config.cliName || DEFAULT_CLI_NAME),
37
+ ...extra
38
+ };
39
+ }
40
+
41
+ export function looksLikeMultiTask(task) {
42
+ if (!task) return false;
43
+ const text = `${task.title || ''}\n${task.body || ''}`;
44
+ const bullets = text.split('\n').filter((line) => /^\s*(?:[-*]|\d+[.)])\s+\S+/.test(line)).length;
45
+ const separators = (text.match(/[,,;;、]/g) || []).length;
46
+ return bullets >= 2 || separators >= 3 || /多任务|并行|团队|分工|several tasks|multiple tasks|parallel|team/i.test(text);
47
+ }
48
+
49
+ export function selectCurrentTask(tasks, peerId) {
50
+ if (!peerId) return null;
51
+ const statusRank = { running: 0, claimed: 1, review: 2, blocked: 3 };
52
+ const openTasks = (tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
53
+ const ownedTasks = openTasks
54
+ .filter((task) => task.owner === peerId)
55
+ .sort((a, b) =>
56
+ (statusRank[a.status] ?? 9) - (statusRank[b.status] ?? 9) ||
57
+ Number(a.priority || 0) - Number(b.priority || 0) ||
58
+ Number(a.id || 0) - Number(b.id || 0));
59
+ return ownedTasks.find((task) => ['running', 'claimed', 'review', 'blocked'].includes(task.status)) || ownedTasks[0] || null;
60
+ }
61
+
62
+ export function deriveAutomation(snapshot, peer = null, opts = {}, config = {}) {
63
+ const cliName = config.cliName || DEFAULT_CLI_NAME;
64
+ const activePeerTtl = Number(config.activePeerTtl ?? snapshot.active_peer_ttl ?? DEFAULT_ACTIVE_PEER_TTL);
65
+ const defaultLockTtl = Number(config.defaultLockTtl ?? DEFAULT_LOCK_TTL);
66
+ const makeAutomationAction = (kind, argv, reason, mutates = true, extra = {}) =>
67
+ makeAction(kind, argv, reason, mutates, extra, { cliName });
68
+
69
+ const peerId = peer || '';
70
+ const t = Number(snapshot.now || nowSec());
71
+ const peerRow = peerId ? snapshot.peers.find((row) => row.id === peerId) : null;
72
+ const openTasks = (snapshot.tasks || []).filter((task) => !['done', 'abandoned'].includes(task.status));
73
+ const assignedTasks = peerId ? openTasks.filter((task) => !task.owner && task.assignee === peerId) : [];
74
+ const availableTasks = openTasks.filter((task) => !task.owner && !task.assignee);
75
+ const ownedTask = selectCurrentTask(openTasks, peerId);
76
+ const takeoverReadyTasks = peerId
77
+ ? openTasks.filter((task) => task.owner && task.owner !== peerId && task.takeover_ready)
78
+ : [];
79
+ const intent = String(opts.intent || 'work').toLowerCase();
80
+ const scope = normalizeLockScope(opts.scope || opts.lock_scope);
81
+ const resources = uniqueList(Array.isArray(opts.resources) ? opts.resources : (opts.resource ? [opts.resource] : []));
82
+ const ownLocks = peerId ? (snapshot.locks || []).filter((lock) => lock.owner === peerId) : [];
83
+ const requestedLocks = resources.map((resource) => {
84
+ const requested = scopedLockResource(resource, scope);
85
+ const lock = (snapshot.locks || []).find((row) => Number(row.expires_at || 0) > t && locksConflict(row, requested));
86
+ const exact = (snapshot.locks || []).find((row) => Number(row.expires_at || 0) > t && lockBaseResource(row) === requested.base_resource && lockScope(row) === requested.scope);
87
+ return { ...requested, lock, exact };
88
+ });
89
+ const conflict = requestedLocks.find((entry) => entry.lock && entry.lock.owner !== peerId);
90
+ const missingLock = requestedLocks.find((entry) => !entry.lock);
91
+ const unread = peerId ? (snapshot.messages || []).filter((message) =>
92
+ message.read_at === null || message.read_at === undefined) : [];
93
+ const warnings = [];
94
+ if (peerId && !peerRow) warnings.push(`peer ${peerId} is not registered in this project`);
95
+ const lockActions = [];
96
+ const messageActions = [];
97
+ const taskActions = [];
98
+ const automation = {
99
+ schema_version: 1,
100
+ peer: peerId ? {
101
+ id: peerId,
102
+ known: Boolean(peerRow),
103
+ active: Boolean(peerRow && Number(peerRow.age_sec || 0) <= activePeerTtl),
104
+ age_sec: peerRow ? Number(peerRow.age_sec || 0) : null
105
+ } : null,
106
+ current_task: summarizeTask(ownedTask),
107
+ phase: 'idle',
108
+ next_action: makeAutomationAction('none', [], 'no immediate coordination action', false),
109
+ actions: [],
110
+ finish_actions: [],
111
+ warnings
112
+ };
113
+
114
+ const orderedUnread = unread
115
+ .filter((message) => message.sender !== peerId)
116
+ .filter((message) => !shouldHideTimelineMessage(message))
117
+ .sort((a, b) => (a.kind === 'task' ? 1 : 0) - (b.kind === 'task' ? 1 : 0) || a.id - b.id);
118
+ for (const message of orderedUnread) {
119
+ if (ownedTask && message.kind === 'task') continue;
120
+ const kind = message.kind === 'task' ? 'msg.inbox' : 'msg.reply';
121
+ const argv = kind === 'msg.reply'
122
+ ? ['msg', 'reply', '--from', peerId, '--id', String(message.id), '--body', '<answer>']
123
+ : ['msg', 'inbox', '--peer', peerId];
124
+ messageActions.push(makeAutomationAction(kind, argv, `unread message #${message.id} from ${message.sender}`, kind !== 'msg.inbox', {
125
+ message_id: message.id,
126
+ task_id: message.task_id || null
127
+ }));
128
+ }
129
+
130
+ if (!ownedTask && assignedTasks.length) {
131
+ const task = assignedTasks[0];
132
+ taskActions.push(makeAutomationAction('task.claim', ['task', 'claim', '--peer', peerId, '--id', String(task.id)], `assigned task #${task.id}`, true, { task_id: task.id }));
133
+ } else if (!ownedTask && takeoverReadyTasks.length) {
134
+ const task = takeoverReadyTasks[0];
135
+ taskActions.push(makeAutomationAction(
136
+ 'task.takeover',
137
+ ['task', 'takeover', '--peer', peerId, '--id', String(task.id), '--reason', 'owner stale and no active related locks', '--policy', 'stale'],
138
+ `task #${task.id} owner ${task.owner} is stale and has no active related locks`,
139
+ true,
140
+ { task_id: task.id, owner: task.owner, owner_age_sec: task.owner_age_sec ?? null }
141
+ ));
142
+ } else if (!ownedTask && availableTasks.length) {
143
+ taskActions.push(makeAutomationAction('task.next', ['task', 'next', '--peer', peerId], 'available pending task exists', true));
144
+ }
145
+
146
+ if (ownedTask) {
147
+ const readOnlyIntent = ['read', 'review', 'inspect'].includes(intent);
148
+ if (readOnlyIntent && resources.length) {
149
+ warnings.push(`intent=${intent} is read-only; do not acquire file locks for snapshot inspection`);
150
+ } else if (conflict) {
151
+ const requestedLabel = lockLabel(conflict);
152
+ const heldLabel = lockLabel(conflict.lock);
153
+ lockActions.push(makeAutomationAction(
154
+ 'msg.send',
155
+ ['msg', 'send', '--from', peerId, '--to', conflict.lock.owner, '--task', String(ownedTask.id), '--body', `Please coordinate ${requestedLabel}; ${heldLabel} is locked by ${conflict.lock.owner}. If our edits are separate, split to scoped locks before final tests/commit.`],
156
+ `${requestedLabel} conflicts with ${heldLabel} held by ${conflict.lock.owner}`,
157
+ true,
158
+ { task_id: ownedTask.id, resource: conflict.base_resource, scope: conflict.scope, lock_owner: conflict.lock.owner, lock_resource: conflict.lock.resource, lock_scope: lockScope(conflict.lock) }
159
+ ));
160
+ } else if (missingLock) {
161
+ lockActions.push(makeAutomationAction(
162
+ 'lock.acquire',
163
+ ['lock', 'acquire', '--peer', peerId, '--task', String(ownedTask.id), ...lockArgv(missingLock.base_resource, missingLock.scope), '--ttl', String(defaultLockTtl), '--reason', '<work>'],
164
+ `task #${ownedTask.id} needs ${lockLabel(missingLock)} lock`,
165
+ true,
166
+ { task_id: ownedTask.id, resource: missingLock.base_resource, scope: missingLock.scope }
167
+ ));
168
+ }
169
+ if (ownedTask.status === 'claimed') {
170
+ taskActions.push(makeAutomationAction('task.update', ['task', 'update', '--peer', peerId, '--id', String(ownedTask.id), '--status', 'running', '--summary', '<started>'], `task #${ownedTask.id} is claimed but not running`, true, { task_id: ownedTask.id }));
171
+ }
172
+ if (!ownedTask.parent_id && looksLikeMultiTask(ownedTask)) {
173
+ const childCount = openTasks.filter((task) => task.parent_id === ownedTask.id && !['done', 'abandoned'].includes(task.status)).length;
174
+ if (!childCount) {
175
+ taskActions.push(makeAutomationAction('team.plan', ['team', 'plan', '--from-task', String(ownedTask.id)], `task #${ownedTask.id} looks splittable; plan explicit team subtasks`, false, { task_id: ownedTask.id }));
176
+ }
177
+ }
178
+ automation.finish_actions.push(makeAutomationAction('handoff.create', ['handoff', 'create', '--from', peerId, '--task', String(ownedTask.id), '--summary', '<summary>', '--tests', '<tests>', '--risks', '<risks>'], `handoff task #${ownedTask.id} before stopping`, true, { task_id: ownedTask.id }));
179
+ automation.finish_actions.push(makeAutomationAction('task.done', ['task', 'done', '--peer', peerId, '--id', String(ownedTask.id), '--summary', '<summary>'], `mark task #${ownedTask.id} done after handoff`, true, { task_id: ownedTask.id }));
180
+ for (const lock of ownLocks) {
181
+ automation.finish_actions.push(makeAutomationAction('lock.release', ['lock', 'release', '--peer', peerId, ...lockArgv(lockBaseResource(lock), lockScope(lock))], `release ${lockLabel(lock)}`, true, { task_id: lock.task_id || null, resource: lockBaseResource(lock), scope: lockScope(lock) }));
182
+ }
183
+ }
184
+ automation.actions.push(...lockActions, ...messageActions, ...taskActions);
185
+
186
+ if (opts.intent === 'finish' || opts.intent === 'stop') {
187
+ automation.phase = ownedTask ? 'handoff' : 'idle';
188
+ automation.next_action = automation.finish_actions[0] || automation.next_action;
189
+ return automation;
190
+ }
191
+ automation.next_action = automation.actions[0] || (ownedTask
192
+ ? makeAutomationAction('none', [], `continue task #${ownedTask.id}`, false, { task_id: ownedTask.id })
193
+ : automation.next_action);
194
+ if (automation.next_action.kind === 'msg.reply' || automation.next_action.kind === 'msg.inbox') automation.phase = 'reply_message';
195
+ else if (automation.next_action.kind === 'task.claim' || automation.next_action.kind === 'task.next') automation.phase = 'claim_task';
196
+ else if (automation.next_action.kind === 'task.takeover') automation.phase = 'takeover_task';
197
+ else if (automation.next_action.kind === 'lock.acquire') automation.phase = 'acquire_lock';
198
+ else if (automation.next_action.kind === 'msg.send') automation.phase = 'coordinate_lock';
199
+ else if (automation.next_action.kind === 'team.plan') automation.phase = 'team_plan';
200
+ else if (ownedTask) automation.phase = ownedTask.status === 'review' ? 'handoff' : 'work';
201
+ return automation;
202
+ }
203
+
204
+ export function renderAutomationContext(automation) {
205
+ if (!automation) return '';
206
+ const lines = [
207
+ '[hello-cc next action]',
208
+ `phase: ${automation.phase}`,
209
+ automation.current_task ? `current_task: #${automation.current_task.id} ${automation.current_task.status} ${automation.current_task.title}` : null,
210
+ `next: ${automation.next_action.command || automation.next_action.kind}`,
211
+ `why: ${automation.next_action.reason}`
212
+ ].filter(Boolean);
213
+ if (automation.finish_actions?.length) {
214
+ lines.push('finish:');
215
+ for (const action of automation.finish_actions.slice(0, 4)) lines.push(`- ${action.command}`);
216
+ }
217
+ if (automation.warnings?.length) {
218
+ lines.push('warnings:');
219
+ for (const warning of automation.warnings.slice(0, 4)) lines.push(`- ${warning}`);
220
+ }
221
+ return lines.join('\n');
222
+ }
@@ -0,0 +1,46 @@
1
+ import { CliError } from '../../shared/errors.mjs';
2
+
3
+ export const WHOLE_LOCK_SCOPE = '*';
4
+
5
+ export function normalizeLockScope(scope) {
6
+ const text = String(scope || '').trim();
7
+ return text || WHOLE_LOCK_SCOPE;
8
+ }
9
+
10
+ export function scopedLockResource(resource, scope = WHOLE_LOCK_SCOPE) {
11
+ const baseResource = String(resource || '').trim();
12
+ const normalizedScope = normalizeLockScope(scope);
13
+ if (!baseResource) throw new CliError('BAD_ARGS', 'Missing --resource');
14
+ return {
15
+ resource: normalizedScope === WHOLE_LOCK_SCOPE
16
+ ? baseResource
17
+ : `scoped:${Buffer.from(JSON.stringify([baseResource, normalizedScope]), 'utf8').toString('base64url')}`,
18
+ base_resource: baseResource,
19
+ scope: normalizedScope
20
+ };
21
+ }
22
+
23
+ export function lockBaseResource(lock) {
24
+ return lock?.base_resource || lock?.resource || '';
25
+ }
26
+
27
+ export function lockScope(lock) {
28
+ return normalizeLockScope(lock?.scope);
29
+ }
30
+
31
+ export function lockLabel(lock) {
32
+ const base = lockBaseResource(lock);
33
+ const scope = lockScope(lock);
34
+ return scope === WHOLE_LOCK_SCOPE ? base : `${base} [${scope}]`;
35
+ }
36
+
37
+ export function lockArgv(resource, scope) {
38
+ const argv = ['--resource', resource];
39
+ if (normalizeLockScope(scope) !== WHOLE_LOCK_SCOPE) argv.push('--scope', normalizeLockScope(scope));
40
+ return argv;
41
+ }
42
+
43
+ export function locksConflict(a, b) {
44
+ return lockBaseResource(a) === lockBaseResource(b) &&
45
+ (lockScope(a) === WHOLE_LOCK_SCOPE || lockScope(b) === WHOLE_LOCK_SCOPE || lockScope(a) === lockScope(b));
46
+ }
@@ -0,0 +1,116 @@
1
+ import { CliError } from '../../shared/errors.mjs';
2
+
3
+ function defaultNow() {
4
+ return Math.floor(Date.now() / 1000);
5
+ }
6
+
7
+ function noopEvent() {}
8
+
9
+ export function createMessageStore(deps = {}) {
10
+ const now = typeof deps.now === 'function' ? deps.now : defaultNow;
11
+ const addEvent = typeof deps.addEvent === 'function' ? deps.addEvent : noopEvent;
12
+
13
+ function sendMessage(db, sender, recipient, taskId, kind, body, meta = {}) {
14
+ const replyTo = meta.reply_to || null;
15
+ const threadId = meta.thread_id || null;
16
+ const info = db.prepare(`
17
+ INSERT INTO messages(sender, recipient, task_id, kind, body, reply_to, thread_id, created_at)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
19
+ `).run(sender, recipient || 'all', taskId || null, kind || 'note', body, replyTo, threadId, now());
20
+ const messageId = Number(info.lastInsertRowid);
21
+ if (!threadId) {
22
+ db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(messageId, messageId);
23
+ }
24
+ addEvent(db, 'message.sent', sender, taskId || null, {
25
+ message_id: messageId,
26
+ recipient: recipient || 'all',
27
+ kind: kind || 'note',
28
+ reply_to: replyTo,
29
+ thread_id: threadId || messageId
30
+ });
31
+ return messageId;
32
+ }
33
+
34
+ function queryInbox(db, peer, includeAll, limit) {
35
+ return db.prepare(`
36
+ SELECT
37
+ m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
38
+ m.reply_to, m.thread_id, m.created_at, r.read_at
39
+ FROM messages m
40
+ LEFT JOIN message_reads r
41
+ ON r.message_id = m.id AND r.peer = ?
42
+ WHERE
43
+ (m.recipient IS NULL OR m.recipient = '' OR m.recipient = 'all' OR m.recipient = ?)
44
+ AND (? = 1 OR r.read_at IS NULL)
45
+ ORDER BY m.id ASC
46
+ LIMIT ?
47
+ `).all(peer, peer, includeAll ? 1 : 0, limit);
48
+ }
49
+
50
+ function queryTimelineMessages(db, peer, limit) {
51
+ if (!peer) {
52
+ return db.prepare(`
53
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
54
+ FROM messages
55
+ ORDER BY id DESC
56
+ LIMIT ?
57
+ `).all(limit).reverse();
58
+ }
59
+ return db.prepare(`
60
+ SELECT
61
+ m.id, m.sender, m.recipient, m.task_id, m.kind, m.body,
62
+ m.reply_to, m.thread_id, m.created_at, r.read_at
63
+ FROM messages m
64
+ LEFT JOIN message_reads r
65
+ ON r.message_id = m.id AND r.peer = ?
66
+ WHERE
67
+ m.sender = ?
68
+ OR m.recipient IS NULL
69
+ OR m.recipient = ''
70
+ OR m.recipient = 'all'
71
+ OR m.recipient = ?
72
+ ORDER BY m.id DESC
73
+ LIMIT ?
74
+ `).all(peer, peer, peer, limit).reverse();
75
+ }
76
+
77
+ function getMessage(db, id) {
78
+ return db.prepare(`
79
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
80
+ FROM messages
81
+ WHERE id = ?
82
+ `).get(id);
83
+ }
84
+
85
+ function queryMessageThread(db, messageId, limit) {
86
+ const message = getMessage(db, messageId);
87
+ if (!message) throw new CliError('NOT_FOUND', `Message #${messageId} does not exist`);
88
+ const threadId = message.thread_id || message.id;
89
+ const rows = db.prepare(`
90
+ SELECT id, sender, recipient, task_id, kind, body, reply_to, thread_id, created_at
91
+ FROM messages
92
+ WHERE id = ? OR thread_id = ?
93
+ ORDER BY id ASC
94
+ LIMIT ?
95
+ `).all(message.id, threadId, limit);
96
+ return { message, thread_id: threadId, messages: rows };
97
+ }
98
+
99
+ function ackMessage(db, peer, message) {
100
+ db.prepare(`
101
+ INSERT INTO message_reads(message_id, peer, read_at)
102
+ VALUES (?, ?, ?)
103
+ ON CONFLICT(message_id, peer) DO UPDATE SET read_at = excluded.read_at
104
+ `).run(message.id, peer, now());
105
+ addEvent(db, 'message.ack', peer, message.task_id || null, { message_id: message.id });
106
+ }
107
+
108
+ return {
109
+ ackMessage,
110
+ getMessage,
111
+ queryInbox,
112
+ queryMessageThread,
113
+ queryTimelineMessages,
114
+ sendMessage
115
+ };
116
+ }