@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.
- package/CHANGELOG.md +130 -0
- package/README.md +21 -3
- package/README.zh-CN.md +21 -3
- package/assets/screenshots/web-console.png +0 -0
- package/bin/hcc.mjs +1943 -3519
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +3 -1
- package/docs/architecture-split-review.md +217 -0
- package/docs/architecture.md +385 -0
- package/docs/architecture.zh-CN.md +364 -0
- package/docs/layout-split-stack-audit.md +298 -0
- package/lib/automation.mjs +8 -0
- package/lib/cli-args.mjs +1 -1
- package/lib/cli-runtime.mjs +45 -0
- package/lib/coordination-state.mjs +218 -0
- package/lib/core/coordination/automation.mjs +222 -0
- package/lib/core/coordination/locks.mjs +46 -0
- package/lib/core/coordination/messages.mjs +116 -0
- package/lib/core/coordination/tasks.mjs +240 -0
- package/lib/core/coordination/teams.mjs +73 -0
- package/lib/core/coordination/timeline.mjs +196 -0
- package/lib/core/peers/bindings.mjs +83 -0
- package/lib/core/peers/format.mjs +13 -0
- package/lib/core/peers/identity.mjs +61 -0
- package/lib/core/peers/liveness.mjs +90 -0
- package/lib/core/peers/reconcile.mjs +238 -0
- package/lib/core/peers/session.mjs +14 -0
- package/lib/core/sessions/launch.mjs +89 -0
- package/lib/db/schema.mjs +324 -0
- package/lib/db/stores/peers.mjs +231 -0
- package/lib/db-schema.mjs +7 -324
- package/lib/errors.mjs +1 -7
- package/lib/handoff.mjs +26 -0
- package/lib/help.mjs +3 -0
- package/lib/integrations/hooks.mjs +171 -0
- package/lib/integrations/peers/identity.mjs +208 -0
- package/lib/integrations/peers/processes.mjs +151 -0
- package/lib/integrations/providers.mjs +282 -0
- package/lib/integrations/shims/script.mjs +384 -0
- package/lib/integrations/shims.mjs +106 -0
- package/lib/json-file.mjs +1 -14
- package/lib/locks.mjs +10 -0
- package/lib/messages.mjs +3 -0
- package/lib/package-meta.mjs +6 -24
- package/lib/peer-bindings.mjs +14 -0
- package/lib/peer-format.mjs +4 -0
- package/lib/peer-identity.mjs +15 -0
- package/lib/project-context.mjs +6 -0
- package/lib/project-registry.mjs +7 -0
- package/lib/provider-commands.mjs +13 -0
- package/lib/release/package-meta.mjs +31 -0
- package/lib/release/release-notes.mjs +40 -0
- package/lib/release-notes.mjs +6 -40
- package/lib/runtime/client.mjs +42 -0
- package/lib/runtime/paths.mjs +45 -0
- package/lib/runtime/project-context.mjs +32 -0
- package/lib/runtime/projects.mjs +76 -0
- package/lib/runtime/state.mjs +128 -0
- package/lib/runtime-client.mjs +3 -0
- package/lib/runtime-paths.mjs +11 -45
- package/lib/runtime-state.mjs +11 -0
- package/lib/session-launch.mjs +17 -0
- package/lib/setup.mjs +17 -293
- package/lib/shared/errors.mjs +7 -0
- package/lib/shared/json-file.mjs +14 -0
- package/lib/shim-script.mjs +1 -384
- package/lib/state-render.mjs +5 -0
- package/lib/task-cli.mjs +45 -0
- package/lib/task-liveness.mjs +8 -0
- package/lib/task-store.mjs +3 -0
- package/lib/team-planning.mjs +7 -0
- package/lib/terminal/tmux.mjs +372 -0
- package/lib/timeline.mjs +12 -0
- package/lib/tmux.mjs +18 -0
- package/lib/ui/help.mjs +419 -0
- package/lib/ui/state-render.mjs +61 -0
- package/lib/web/http.mjs +55 -0
- package/lib/web/peer-actions.mjs +306 -0
- package/lib/web/runtime.mjs +154 -0
- package/lib/web/ui-template.mjs +2057 -0
- package/lib/web-http.mjs +7 -55
- package/lib/web-peer-actions.mjs +3 -0
- package/lib/web-runtime.mjs +17 -108
- package/lib/web-ui-template.mjs +3 -1909
- package/package.json +1 -1
- package/scripts/github-release.mjs +2 -2
- package/scripts/regression.mjs +2901 -79
- 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
|
+
}
|