@logicseek/hello-cc 0.1.4 → 0.1.5

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/docs/commands.md CHANGED
@@ -36,7 +36,7 @@ want coordination without the Web console or shims.
36
36
  ```text
37
37
  hcc peers
38
38
  hcc status [--peer ID]
39
- hcc state [--peer ID] [--resource PATH] [--intent work|stop|finish]
39
+ hcc state [--peer ID] [--resource PATH] [--scope SCOPE] [--intent read|review|work|write|stop|finish]
40
40
  hcc scan [--register]
41
41
  hcc prompt --peer ID [--kind codex|claude|shell|other] [--role ROLE]
42
42
  hcc join --peer ID [--kind codex|claude|shell|other] [--role ROLE]
@@ -51,7 +51,9 @@ coordination actions such as acking messages, claiming tasks, acquiring locks, o
51
51
  creating handoffs. It adds a unified collaboration timeline plus
52
52
  `automation.next_action.argv`, a machine-readable next coordination command for
53
53
  agents to execute explicitly. `automation.current_task` records the peer's active
54
- task when one exists.
54
+ task when one exists. Use `--intent read` or `--intent review` for snapshot
55
+ inspection that should not acquire locks; use `--scope` with write/work intents
56
+ to coordinate one region of a larger shared resource.
55
57
 
56
58
  ## Browser-Controllable Terminals
57
59
 
@@ -92,8 +94,9 @@ sender by default. Use `msg thread` to inspect the full thread for one message.
92
94
  ```text
93
95
  hcc task create --title TEXT [--body TEXT] [--from ID] [--to ID] [--priority N]
94
96
  hcc task list [--status S] [--peer ID] [--all]
95
- hcc task claim [--peer ID] --id N
96
- hcc task next [--peer ID] [--force]
97
+ hcc task claim [--peer ID] --id N[,N] [--id N] [--ids N,N] [--force]
98
+ hcc task takeover [--peer ID] --id N --reason TEXT [--policy any|blocked|stale|blocked-or-stale] [--stale-after SECONDS]
99
+ hcc task next [--peer ID] [--force] [--count N]
97
100
  hcc task create --title TEXT --parent N [--team-role ROLE]
98
101
  hcc task update [--peer ID] --id N --status running|review|blocked|done|abandoned [--summary TEXT] [--body TEXT] [--to ID]
99
102
  hcc task done [--peer ID] --id N --summary TEXT
@@ -102,7 +105,13 @@ hcc task done [--peer ID] --id N --summary TEXT
102
105
  Tasks are shared project facts. They remain visible until marked `done` or
103
106
  `abandoned`. `task next` returns your existing claimed/running/review/blocked
104
107
  task before claiming a new pending task; use `--force` only when intentionally
105
- taking another pending task.
108
+ taking additional pending tasks, and combine it with `--count N` for explicit
109
+ batch claims. `task claim` also accepts repeated `--id` values and comma-separated
110
+ `--id` or `--ids` lists. Use `task takeover` when explicitly taking a
111
+ non-complete task from another owner; it records the previous owner, requires a
112
+ reason, and notifies them. Add `--policy blocked`, `stale`, or `blocked-or-stale`
113
+ when takeover should only proceed under that auditable precondition. The default
114
+ policy is `any` for compatibility.
106
115
 
107
116
  ## Teams
108
117
 
@@ -121,9 +130,9 @@ explicit peer IDs or kind counts such as `codex:2,claude:1`.
121
130
  ## Locks, Handoffs, And Events
122
131
 
123
132
  ```text
124
- hcc lock acquire [--peer ID] --resource PATH [--task N] [--ttl SEC] [--reason TEXT]
125
- hcc lock renew [--peer ID] --resource PATH [--ttl SEC]
126
- hcc lock release [--peer ID] --resource PATH [--force]
133
+ hcc lock acquire [--peer ID] --resource PATH [--scope SCOPE] [--task N] [--ttl SEC] [--reason TEXT]
134
+ hcc lock renew [--peer ID] --resource PATH [--scope SCOPE] [--ttl SEC]
135
+ hcc lock release [--peer ID] --resource PATH [--scope SCOPE] [--force]
127
136
  hcc lock list [--all]
128
137
  hcc handoff create [--from ID] --summary TEXT [--task N] [--to ID] [--changed-files JSON_OR_CSV] [--tests TEXT] [--risks TEXT]
129
138
  hcc handoff list [--task N] [--limit N]
@@ -131,5 +140,8 @@ hcc event tail [--limit N]
131
140
  hcc gc [--older-than DAYS] [--yes]
132
141
  ```
133
142
 
134
- Locks are advisory coordination locks with TTLs. Handoffs preserve the result,
143
+ Locks are advisory coordination locks with TTLs. Omitting `--scope` locks the
144
+ whole resource. Different scopes on the same resource can be held concurrently,
145
+ for example `--resource bin/hcc.mjs --scope db-schema` and `--scope web-ui`, but
146
+ a whole-resource lock conflicts with every scope. Handoffs preserve the result,
135
147
  tests, changed files, and remaining risks when work moves between peers.
@@ -33,7 +33,7 @@ URL token;首次使用时自动生成并保存。用 `--local` 可只绑定 `1
33
33
  ```text
34
34
  hcc peers
35
35
  hcc status [--peer ID]
36
- hcc state [--peer ID] [--resource PATH] [--intent work|stop|finish]
36
+ hcc state [--peer ID] [--resource PATH] [--scope SCOPE] [--intent read|review|work|write|stop|finish]
37
37
  hcc scan [--register]
38
38
  hcc prompt --peer ID [--kind codex|claude|shell|other] [--role ROLE]
39
39
  hcc join --peer ID [--kind codex|claude|shell|other] [--role ROLE]
@@ -47,6 +47,8 @@ hcc run --peer ID --kind codex|claude|shell --role ROLE -- COMMAND [ARGS...]
47
47
  创建 handoff 这类协作动作;它会返回统一协作时间线,以及
48
48
  `automation.next_action.argv` 这种机器可读的下一步协作命令,供 agent 显式执行
49
49
  并留下审计记录。`automation.current_task` 会记录当前 peer 已经拥有的活动任务。
50
+ 使用 `--intent read` 或 `--intent review` 表示只做快照检查,不应获取文件锁;
51
+ 写入/工作意图下可用 `--scope` 协调同一个大资源中的某个区域。
50
52
 
51
53
  ## Web 可控终端
52
54
 
@@ -85,8 +87,9 @@ hcc broadcast MESSAGE [--from ID] [--task N] [--inject]
85
87
  ```text
86
88
  hcc task create --title TEXT [--body TEXT] [--from ID] [--to ID] [--priority N]
87
89
  hcc task list [--status S] [--peer ID] [--all]
88
- hcc task claim [--peer ID] --id N
89
- hcc task next [--peer ID] [--force]
90
+ hcc task claim [--peer ID] --id N[,N] [--id N] [--ids N,N] [--force]
91
+ hcc task takeover [--peer ID] --id N --reason TEXT [--policy any|blocked|stale|blocked-or-stale] [--stale-after SECONDS]
92
+ hcc task next [--peer ID] [--force] [--count N]
90
93
  hcc task create --title TEXT --parent N [--team-role ROLE]
91
94
  hcc task update [--peer ID] --id N --status running|review|blocked|done|abandoned [--summary TEXT] [--body TEXT] [--to ID]
92
95
  hcc task done [--peer ID] --id N --summary TEXT
@@ -94,7 +97,12 @@ hcc task done [--peer ID] --id N --summary TEXT
94
97
 
95
98
  任务是项目共享事实。任务会一直可见,直到被标记为 `done` 或 `abandoned`。
96
99
  `task next` 会优先返回当前 peer 已经认领、运行、审查或阻塞中的任务;只有明确
97
- 要再接一个 pending 任务时才使用 `--force`。
100
+ 要再接 pending 任务时才使用 `--force`,并可结合 `--count N` 做显式批量认领。
101
+ `task claim` 也支持重复 `--id`、逗号分隔的 `--id` 或 `--ids` 列表。明确要从
102
+ 其他 owner 手里接管未完成任务时,使用 `task takeover`;它要求填写 reason,会
103
+ 记录原 owner 并通知对方。需要更保守时,可加 `--policy blocked`、`stale` 或
104
+ `blocked-or-stale`,要求任务符合可审计的阻塞/陈旧条件后才允许接管。默认 policy
105
+ 仍是 `any`,以保持兼容。
98
106
 
99
107
  ## 团队
100
108
 
@@ -112,9 +120,9 @@ hcc team status --task N
112
120
  ## 锁、交接和事件
113
121
 
114
122
  ```text
115
- hcc lock acquire [--peer ID] --resource PATH [--task N] [--ttl SEC] [--reason TEXT]
116
- hcc lock renew [--peer ID] --resource PATH [--ttl SEC]
117
- hcc lock release [--peer ID] --resource PATH [--force]
123
+ hcc lock acquire [--peer ID] --resource PATH [--scope SCOPE] [--task N] [--ttl SEC] [--reason TEXT]
124
+ hcc lock renew [--peer ID] --resource PATH [--scope SCOPE] [--ttl SEC]
125
+ hcc lock release [--peer ID] --resource PATH [--scope SCOPE] [--force]
118
126
  hcc lock list [--all]
119
127
  hcc handoff create [--from ID] --summary TEXT [--task N] [--to ID] [--changed-files JSON_OR_CSV] [--tests TEXT] [--risks TEXT]
120
128
  hcc handoff list [--task N] [--limit N]
@@ -122,5 +130,7 @@ hcc event tail [--limit N]
122
130
  hcc gc [--older-than DAYS] [--yes]
123
131
  ```
124
132
 
125
- 锁是带 TTL 的协作式 advisory lock。交接记录用于保存结果、测试、变更文件和
126
- 剩余风险,方便工作在多个 peer 之间继续。
133
+ 锁是带 TTL 的协作式 advisory lock。不传 `--scope` 表示锁住整个资源。同一个
134
+ 资源的不同 scope 可以并行持有,例如 `--resource bin/hcc.mjs --scope db-schema`
135
+ 和 `--scope web-ui`;但整资源锁会和所有 scope 冲突。交接记录用于保存结果、
136
+ 测试、变更文件和剩余风险,方便工作在多个 peer 之间继续。
@@ -0,0 +1,89 @@
1
+ import process from 'node:process';
2
+ import { CliError } from './errors.mjs';
3
+
4
+ export function splitGlobalArgs(argv) {
5
+ const global = { json: false, root: null, db: null };
6
+ const rest = [];
7
+ for (let i = 0; i < argv.length; i += 1) {
8
+ const arg = argv[i];
9
+ if (arg === '--json') {
10
+ global.json = true;
11
+ } else if (arg === '--root') {
12
+ global.root = argv[++i];
13
+ } else if (arg.startsWith('--root=')) {
14
+ global.root = arg.slice('--root='.length);
15
+ } else if (arg === '--db') {
16
+ global.db = argv[++i];
17
+ } else if (arg.startsWith('--db=')) {
18
+ global.db = arg.slice('--db='.length);
19
+ } else {
20
+ rest.push(arg);
21
+ }
22
+ }
23
+ return { global, rest };
24
+ }
25
+
26
+ export function parseOpts(args, spec = {}) {
27
+ const booleans = new Set(spec.booleans || []);
28
+ const arrays = new Set(spec.arrays || []);
29
+ const opts = { _: [] };
30
+ for (let i = 0; i < args.length; i += 1) {
31
+ const arg = args[i];
32
+ if (!arg.startsWith('--') || arg === '--') {
33
+ opts._.push(arg);
34
+ continue;
35
+ }
36
+ let key;
37
+ let value;
38
+ const eq = arg.indexOf('=');
39
+ if (eq >= 0) {
40
+ key = arg.slice(2, eq);
41
+ value = arg.slice(eq + 1);
42
+ } else {
43
+ key = arg.slice(2);
44
+ if (booleans.has(key)) {
45
+ value = true;
46
+ } else {
47
+ value = args[++i];
48
+ }
49
+ }
50
+ if (!key) throw new CliError('BAD_ARGS', `Invalid option: ${arg}`);
51
+ if (value === undefined) throw new CliError('BAD_ARGS', `Missing value for --${key}`);
52
+ if (arrays.has(key)) {
53
+ if (!opts[key]) opts[key] = [];
54
+ opts[key].push(value);
55
+ } else {
56
+ opts[key] = value;
57
+ }
58
+ }
59
+ return opts;
60
+ }
61
+
62
+ export function wantsHelp(args) {
63
+ const stop = args.indexOf('--');
64
+ const scan = stop >= 0 ? args.slice(0, stop) : args;
65
+ const index = scan.findIndex((arg) => arg === '--help' || arg === '-h');
66
+ if (index < 0) return false;
67
+ return index <= 1 || !String(scan[index - 1] || '').startsWith('--');
68
+ }
69
+
70
+ export function validateOpts(command, opts, allowed = []) {
71
+ const allowedSet = new Set(['_', ...allowed]);
72
+ for (const key of Object.keys(opts)) {
73
+ if (!allowedSet.has(key)) throw new CliError('BAD_ARGS', `${command}: unknown option --${key}`);
74
+ }
75
+ if (opts._?.length) throw new CliError('BAD_ARGS', `${command}: unexpected argument ${opts._[0]}`);
76
+ }
77
+
78
+ export function required(opts, key, envName = null) {
79
+ const value = opts[key] || (envName ? process.env[envName] : null);
80
+ if (!value) throw new CliError('BAD_ARGS', `Missing --${key}${envName ? ` or $${envName}` : ''}`);
81
+ return value;
82
+ }
83
+
84
+ export function intOpt(opts, key, fallback = null) {
85
+ if (opts[key] === undefined || opts[key] === null || opts[key] === '') return fallback;
86
+ const value = Number.parseInt(String(opts[key]), 10);
87
+ if (!Number.isFinite(value)) throw new CliError('BAD_ARGS', `--${key} must be an integer`);
88
+ return value;
89
+ }
@@ -0,0 +1,324 @@
1
+ import { CliError } from './errors.mjs';
2
+
3
+ export const DB_SCHEMA_VERSION = 5;
4
+
5
+ const sleepBuffer = new SharedArrayBuffer(4);
6
+ const sleepView = new Int32Array(sleepBuffer);
7
+
8
+ function now() {
9
+ return Math.floor(Date.now() / 1000);
10
+ }
11
+
12
+ function sleepSync(ms) {
13
+ Atomics.wait(sleepView, 0, 0, ms);
14
+ }
15
+
16
+ function isSqliteBusy(err) {
17
+ const text = `${err?.code || ''} ${err?.message || ''} ${err?.errstr || ''}`;
18
+ return err?.errcode === 5 ||
19
+ err?.errcode === 6 ||
20
+ err?.errcode === 261 ||
21
+ /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database is busy/i.test(text);
22
+ }
23
+
24
+ export function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy = false } = {}) {
25
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
26
+ try {
27
+ db.exec(sql);
28
+ return true;
29
+ } catch (err) {
30
+ if (!isSqliteBusy(err)) throw err;
31
+ if (attempt === attempts - 1) {
32
+ if (ignoreBusy) return false;
33
+ throw err;
34
+ }
35
+ sleepSync(delayMs);
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ export function tx(db, fn) {
42
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE;');
43
+ try {
44
+ const result = fn();
45
+ db.exec('COMMIT;');
46
+ return result;
47
+ } catch (err) {
48
+ try {
49
+ db.exec('ROLLBACK;');
50
+ } catch {
51
+ // Ignore rollback failures; the original error is more useful.
52
+ }
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ export function initSchema(db, { beforePostMigrationIndexes = null } = {}) {
58
+ const existingVersion = readSchemaVersion(db);
59
+ if (existingVersion > DB_SCHEMA_VERSION) {
60
+ throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${existingVersion} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
61
+ db_schema_version: existingVersion,
62
+ supported_schema_version: DB_SCHEMA_VERSION
63
+ });
64
+ }
65
+ createBaseSchema(db);
66
+ runSchemaMigrations(db);
67
+ if (typeof beforePostMigrationIndexes === 'function') beforePostMigrationIndexes(db);
68
+ createPostMigrationIndexes(db);
69
+ }
70
+
71
+ function createBaseSchema(db) {
72
+ execWithBusyRetry(db, `
73
+ CREATE TABLE IF NOT EXISTS meta (
74
+ key TEXT PRIMARY KEY,
75
+ value TEXT NOT NULL
76
+ );
77
+
78
+ CREATE TABLE IF NOT EXISTS schema_migrations (
79
+ version INTEGER PRIMARY KEY,
80
+ name TEXT NOT NULL,
81
+ applied_at INTEGER NOT NULL
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS peers (
85
+ id TEXT PRIMARY KEY,
86
+ kind TEXT NOT NULL,
87
+ role TEXT,
88
+ worktree TEXT,
89
+ branch TEXT,
90
+ pid INTEGER,
91
+ status TEXT NOT NULL DEFAULT 'idle',
92
+ capabilities TEXT,
93
+ created_at INTEGER NOT NULL,
94
+ last_seen_at INTEGER NOT NULL
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS peer_bindings (
98
+ peer TEXT PRIMARY KEY,
99
+ provider TEXT NOT NULL,
100
+ provider_session_id TEXT,
101
+ provider_session_name TEXT,
102
+ resume_mode TEXT NOT NULL DEFAULT 'new',
103
+ resume_arg TEXT,
104
+ command TEXT,
105
+ transport TEXT NOT NULL,
106
+ runtime_session_id TEXT,
107
+ runtime_target TEXT,
108
+ created_at INTEGER NOT NULL,
109
+ updated_at INTEGER NOT NULL,
110
+ FOREIGN KEY (peer) REFERENCES peers(id) ON DELETE CASCADE
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS tasks (
114
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
115
+ title TEXT NOT NULL,
116
+ body TEXT,
117
+ status TEXT NOT NULL DEFAULT 'pending',
118
+ assignee TEXT,
119
+ owner TEXT,
120
+ parent_id INTEGER,
121
+ team_role TEXT,
122
+ priority INTEGER NOT NULL DEFAULT 100,
123
+ created_by TEXT,
124
+ claimed_at INTEGER,
125
+ completed_at INTEGER,
126
+ created_at INTEGER NOT NULL,
127
+ updated_at INTEGER NOT NULL
128
+ );
129
+
130
+ CREATE TABLE IF NOT EXISTS messages (
131
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
132
+ sender TEXT NOT NULL,
133
+ recipient TEXT,
134
+ task_id INTEGER,
135
+ kind TEXT NOT NULL DEFAULT 'note',
136
+ body TEXT NOT NULL,
137
+ reply_to INTEGER,
138
+ thread_id INTEGER,
139
+ created_at INTEGER NOT NULL
140
+ );
141
+
142
+ CREATE TABLE IF NOT EXISTS message_reads (
143
+ message_id INTEGER NOT NULL,
144
+ peer TEXT NOT NULL,
145
+ read_at INTEGER NOT NULL,
146
+ PRIMARY KEY (message_id, peer),
147
+ FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
148
+ );
149
+
150
+ CREATE TABLE IF NOT EXISTS locks (
151
+ resource TEXT PRIMARY KEY,
152
+ base_resource TEXT,
153
+ scope TEXT NOT NULL DEFAULT '*',
154
+ owner TEXT NOT NULL,
155
+ task_id INTEGER,
156
+ reason TEXT,
157
+ expires_at INTEGER NOT NULL,
158
+ created_at INTEGER NOT NULL
159
+ );
160
+
161
+ CREATE TABLE IF NOT EXISTS handoffs (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ task_id INTEGER,
164
+ from_peer TEXT NOT NULL,
165
+ to_peer TEXT,
166
+ summary TEXT NOT NULL,
167
+ changed_files TEXT,
168
+ tests TEXT,
169
+ risks TEXT,
170
+ created_at INTEGER NOT NULL
171
+ );
172
+
173
+ CREATE TABLE IF NOT EXISTS events (
174
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
175
+ type TEXT NOT NULL,
176
+ actor TEXT,
177
+ task_id INTEGER,
178
+ payload TEXT,
179
+ created_at INTEGER NOT NULL
180
+ );
181
+
182
+ CREATE INDEX IF NOT EXISTS idx_tasks_status_priority ON tasks(status, priority, id);
183
+ CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner);
184
+ CREATE INDEX IF NOT EXISTS idx_messages_recipient_id ON messages(recipient, id);
185
+ CREATE INDEX IF NOT EXISTS idx_events_id ON events(id);
186
+ CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
187
+ `);
188
+ }
189
+
190
+ function createPostMigrationIndexes(db) {
191
+ execWithBusyRetry(db, `
192
+ CREATE INDEX IF NOT EXISTS idx_peer_bindings_provider_session ON peer_bindings(provider, provider_session_id);
193
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_id_unique
194
+ ON peer_bindings(provider, provider_session_id)
195
+ WHERE provider_session_id IS NOT NULL;
196
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_name_unique
197
+ ON peer_bindings(provider, provider_session_name)
198
+ WHERE provider_session_name IS NOT NULL;
199
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_runtime_target_unique
200
+ ON peer_bindings(runtime_target)
201
+ WHERE runtime_target IS NOT NULL;
202
+ `);
203
+ }
204
+
205
+ function tableColumns(db, tableName) {
206
+ return new Set(db.prepare(`PRAGMA table_info(${tableName})`).all().map((col) => col.name));
207
+ }
208
+
209
+ function tableExists(db, tableName) {
210
+ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(tableName));
211
+ }
212
+
213
+ export function readSchemaVersion(db) {
214
+ const metaExists = tableExists(db, 'meta');
215
+ const metaVersion = metaExists ? Number(db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value || 0) : 0;
216
+ const pragmaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version || 0);
217
+ const migrationVersion = tableExists(db, 'schema_migrations')
218
+ ? Number(db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version || 0)
219
+ : 0;
220
+ return Math.max(metaVersion, pragmaVersion, migrationVersion);
221
+ }
222
+
223
+ function writeSchemaVersion(db, version) {
224
+ db.prepare(`
225
+ INSERT INTO meta(key, value) VALUES ('schema_version', ?)
226
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
227
+ `).run(String(version));
228
+ db.exec(`PRAGMA user_version = ${Number(version)}`);
229
+ }
230
+
231
+ function recordSchemaMigration(db, version, name) {
232
+ db.prepare(`
233
+ INSERT INTO schema_migrations(version, name, applied_at)
234
+ VALUES (?, ?, ?)
235
+ ON CONFLICT(version) DO NOTHING
236
+ `).run(version, name, now());
237
+ }
238
+
239
+ function addColumnIfMissing(db, tableName, columnName, ddl) {
240
+ const columns = tableColumns(db, tableName);
241
+ if (!columns.has(columnName)) db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${ddl}`);
242
+ }
243
+
244
+ function runSchemaMigrations(db) {
245
+ const migrations = [
246
+ {
247
+ version: 1,
248
+ name: 'baseline',
249
+ up() {
250
+ // createBaseSchema already created the baseline tables.
251
+ }
252
+ },
253
+ {
254
+ version: 2,
255
+ name: 'peer binding runtime targets',
256
+ up(database) {
257
+ addColumnIfMissing(database, 'peer_bindings', 'runtime_target', 'runtime_target TEXT');
258
+ }
259
+ },
260
+ {
261
+ version: 3,
262
+ name: 'threaded messages',
263
+ up(database) {
264
+ addColumnIfMissing(database, 'messages', 'reply_to', 'reply_to INTEGER');
265
+ addColumnIfMissing(database, 'messages', 'thread_id', 'thread_id INTEGER');
266
+ database.exec(`
267
+ UPDATE messages SET thread_id = id WHERE thread_id IS NULL;
268
+ CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, id);
269
+ `);
270
+ }
271
+ },
272
+ {
273
+ version: 4,
274
+ name: 'team task hierarchy',
275
+ up(database) {
276
+ addColumnIfMissing(database, 'tasks', 'parent_id', 'parent_id INTEGER');
277
+ addColumnIfMissing(database, 'tasks', 'team_role', 'team_role TEXT');
278
+ database.exec('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id, priority, id);');
279
+ }
280
+ },
281
+ {
282
+ version: 5,
283
+ name: 'scoped advisory locks',
284
+ up(database) {
285
+ addColumnIfMissing(database, 'locks', 'base_resource', 'base_resource TEXT');
286
+ addColumnIfMissing(database, 'locks', 'scope', "scope TEXT NOT NULL DEFAULT '*'");
287
+ database.exec(`
288
+ UPDATE locks
289
+ SET base_resource = resource
290
+ WHERE base_resource IS NULL OR base_resource = '';
291
+ UPDATE locks
292
+ SET scope = '*'
293
+ WHERE scope IS NULL OR scope = '';
294
+ CREATE INDEX IF NOT EXISTS idx_locks_base_scope ON locks(base_resource, scope);
295
+ `);
296
+ }
297
+ }
298
+ ];
299
+ let version = readSchemaVersion(db);
300
+ if (version > DB_SCHEMA_VERSION) {
301
+ throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${version} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
302
+ db_schema_version: version,
303
+ supported_schema_version: DB_SCHEMA_VERSION
304
+ });
305
+ }
306
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
307
+ tx(db, () => {
308
+ for (const migration of migrations) {
309
+ if (version >= migration.version && applied.has(migration.version)) continue;
310
+ migration.up(db);
311
+ recordSchemaMigration(db, migration.version, migration.name);
312
+ applied.add(migration.version);
313
+ version = migration.version;
314
+ writeSchemaVersion(db, version);
315
+ }
316
+ if (version === 0) {
317
+ version = DB_SCHEMA_VERSION;
318
+ }
319
+ for (const migration of migrations) {
320
+ if (migration.version <= version) recordSchemaMigration(db, migration.version, migration.name);
321
+ }
322
+ writeSchemaVersion(db, version);
323
+ });
324
+ }
package/lib/errors.mjs ADDED
@@ -0,0 +1,7 @@
1
+ export class CliError extends Error {
2
+ constructor(code, message, extra = {}) {
3
+ super(message);
4
+ this.code = code;
5
+ this.extra = extra;
6
+ }
7
+ }
package/lib/format.mjs ADDED
@@ -0,0 +1,39 @@
1
+ export function compactText(value, limit = 160) {
2
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
3
+ return text.length > limit ? `${text.slice(0, limit - 1)}\u2026` : text;
4
+ }
5
+
6
+ export function formatJson(ok, dataOrError) {
7
+ if (ok) return JSON.stringify({ ok: true, data: dataOrError }, null, 2);
8
+ return JSON.stringify({ ok: false, error: dataOrError }, null, 2);
9
+ }
10
+
11
+ export function printResult(ctx, data, render) {
12
+ if (ctx.json) {
13
+ console.log(formatJson(true, data));
14
+ } else {
15
+ const output = render ? render(data) : String(data ?? '');
16
+ if (output) console.log(output);
17
+ }
18
+ }
19
+
20
+ export function shellQuoteArg(value) {
21
+ const text = String(value);
22
+ if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(text)) return text;
23
+ return `'${text.replace(/'/g, `'\\''`)}'`;
24
+ }
25
+
26
+ export function shellExports(values) {
27
+ return Object.entries(values)
28
+ .map(([key, value]) => `export ${key}=${shellQuoteArg(value)}`)
29
+ .join('\n');
30
+ }
31
+
32
+ export function table(rows, columns) {
33
+ if (!rows.length) return '(none)';
34
+ const widths = columns.map((col) => Math.max(col.label.length, ...rows.map((row) => String(col.value(row) ?? '').length)));
35
+ const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' ');
36
+ const sep = widths.map((w) => '-'.repeat(w)).join(' ');
37
+ const body = rows.map((row) => columns.map((col, i) => String(col.value(row) ?? '').padEnd(widths[i])).join(' '));
38
+ return [header, sep, ...body].join('\n');
39
+ }
package/lib/guidance.mjs CHANGED
@@ -53,8 +53,16 @@ Before mutating work:
53
53
  explicitly; do not create or claim hidden extra tasks.
54
54
 
55
55
  Before editing or mutating shared resources:
56
- - Acquire an advisory lock for the file, directory, module, or shared resource.
57
- - If another live peer holds the lock, message that peer instead of editing.
56
+ - Acquire an advisory lock for the file, directory, module scope, or shared
57
+ resource.
58
+ - Locks are coordination signals, not task ownership and not read barriers.
59
+ A broad lock such as \`--resource bin/hcc.mjs\` protects the whole file, while
60
+ scoped locks such as \`--resource bin/hcc.mjs --scope db-schema\` and
61
+ \`--scope web-ui\` can be held by different peers at the same time.
62
+ - If another live peer holds a conflicting lock, message that peer before
63
+ editing. When the work is in separate regions of the same file, agree on
64
+ narrower scoped locks and wait for all related scopes to hand off or release
65
+ before final tests, formatting, staging, or commit.
58
66
  - Acquire locks only before writes or shared-resource mutation, including file
59
67
  edits, formatting, staging/committing through \`.git/index\`, intentional DB
60
68
  data/schema changes, starting/stopping/restarting shared runtimes, or taking
@@ -0,0 +1,14 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function readJsonSafe(filePath) {
5
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
6
+ catch { return null; }
7
+ }
8
+
9
+ export function writeJsonSafe(filePath, data) {
10
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
11
+ const tmpPath = filePath + '.tmp.' + process.pid;
12
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n');
13
+ fs.renameSync(tmpPath, filePath);
14
+ }
@@ -0,0 +1,45 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export function projectStateDir(root) {
5
+ return path.join(root, '.hello-cc');
6
+ }
7
+
8
+ export function projectDbPath(root) {
9
+ return path.join(projectStateDir(root), 'mesh.db');
10
+ }
11
+
12
+ export function runtimePath(ctx) {
13
+ return path.join(projectStateDir(ctx.root), 'runtime.json');
14
+ }
15
+
16
+ export function webLogPath(ctx) {
17
+ return path.join(projectStateDir(ctx.root), 'web.log');
18
+ }
19
+
20
+ export function globalStateDir() {
21
+ return path.join(os.homedir(), '.hello-cc');
22
+ }
23
+
24
+ export function globalRuntimePath() {
25
+ return path.join(globalStateDir(), 'runtime.json');
26
+ }
27
+
28
+ export function globalWebTokenPath() {
29
+ return path.join(globalStateDir(), 'web-token');
30
+ }
31
+
32
+ export function projectRegistryPath() {
33
+ return path.join(globalStateDir(), 'projects.json');
34
+ }
35
+
36
+ export function contextForProject(root, dbPath = null, base = {}) {
37
+ const resolvedRoot = path.resolve(root);
38
+ return {
39
+ cwd: base.cwd || resolvedRoot,
40
+ root: resolvedRoot,
41
+ dbPath: path.resolve(dbPath || projectDbPath(resolvedRoot)),
42
+ json: Boolean(base.json),
43
+ explicitRoot: true
44
+ };
45
+ }