@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/CHANGELOG.md +51 -0
- package/bin/hcc.mjs +651 -2505
- package/docs/commands.md +21 -9
- package/docs/commands.zh-CN.md +19 -9
- package/lib/cli-args.mjs +89 -0
- package/lib/db-schema.mjs +324 -0
- package/lib/errors.mjs +7 -0
- package/lib/format.mjs +39 -0
- package/lib/guidance.mjs +10 -2
- package/lib/json-file.mjs +14 -0
- package/lib/runtime-paths.mjs +45 -0
- package/lib/setup.mjs +3 -474
- package/lib/shell-path.mjs +69 -0
- package/lib/shim-script.mjs +384 -0
- package/lib/web-http.mjs +55 -0
- package/lib/web-runtime.mjs +108 -0
- package/lib/web-ui-template.mjs +1909 -0
- package/package.json +1 -1
- package/scripts/regression.mjs +284 -19
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
|
|
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
|
|
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.
|
|
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.
|
package/docs/commands.zh-CN.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
锁是带 TTL 的协作式 advisory lock。不传 `--scope` 表示锁住整个资源。同一个
|
|
134
|
+
资源的不同 scope 可以并行持有,例如 `--resource bin/hcc.mjs --scope db-schema`
|
|
135
|
+
和 `--scope web-ui`;但整资源锁会和所有 scope 冲突。交接记录用于保存结果、
|
|
136
|
+
测试、变更文件和剩余风险,方便工作在多个 peer 之间继续。
|
package/lib/cli-args.mjs
ADDED
|
@@ -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
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
|
|
57
|
-
|
|
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
|
+
}
|