wehandoff 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/bin/_whf/http.mjs +60 -0
- package/bin/_whf/registry.mjs +503 -0
- package/bin/_whf/shared-asset.mjs +124 -0
- package/bin/wehandoff.mjs +1682 -0
- package/package.json +22 -20
|
@@ -0,0 +1,1682 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve as resolvePath } from 'node:path';
|
|
4
|
+
import { resolveToken, resolveBaseUrl, callApi, callApiRaw } from './_whf/http.mjs';
|
|
5
|
+
import {
|
|
6
|
+
findEntry,
|
|
7
|
+
buildManifest,
|
|
8
|
+
renderHelpEntry,
|
|
9
|
+
renderTopHelp,
|
|
10
|
+
resolveResource,
|
|
11
|
+
} from './_whf/registry.mjs';
|
|
12
|
+
import {
|
|
13
|
+
assetDir,
|
|
14
|
+
clearMerged,
|
|
15
|
+
isDirty,
|
|
16
|
+
readMeta,
|
|
17
|
+
readWorkingCopy,
|
|
18
|
+
threeWayMerge,
|
|
19
|
+
writeMergedConflict,
|
|
20
|
+
writeMeta,
|
|
21
|
+
writeWorkingBody,
|
|
22
|
+
writeWorkingCopy,
|
|
23
|
+
} from './_whf/shared-asset.mjs';
|
|
24
|
+
|
|
25
|
+
// Where the local working copy for a shared asset lives — surfaced in `asset
|
|
26
|
+
// share/pull` output so the user knows which file to edit before `asset push`.
|
|
27
|
+
const assetDirHint = (assetId) => assetDir(assetId);
|
|
28
|
+
|
|
29
|
+
function ok(payload) {
|
|
30
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fail(message, code = 1) {
|
|
35
|
+
process.stdout.write(`${JSON.stringify({ error: message })}\n`);
|
|
36
|
+
process.exit(code);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Print a structured payload AND exit non-zero — for an expected non-success
|
|
40
|
+
// outcome that still carries actionable fields (e.g. an `asset push` 3-way
|
|
41
|
+
// conflict reporting the .merged path). Unlike fail(), it does NOT wrap the
|
|
42
|
+
// payload under { error }, so callers read the fields directly.
|
|
43
|
+
function failJson(payload, code = 1) {
|
|
44
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
45
|
+
process.exit(code);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const out = { _: [] };
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const arg = argv[i];
|
|
52
|
+
if (arg.startsWith('--')) {
|
|
53
|
+
const next = argv[i + 1];
|
|
54
|
+
if (next === undefined || next.startsWith('--')) {
|
|
55
|
+
out[arg.slice(2)] = true;
|
|
56
|
+
} else {
|
|
57
|
+
out[arg.slice(2)] = next;
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
out._.push(arg);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function firstParagraph(markdown) {
|
|
68
|
+
const withoutFrontmatter = markdown.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
|
|
69
|
+
return withoutFrontmatter
|
|
70
|
+
.split(/\n\s*\n/)
|
|
71
|
+
.map((p) => p.trim().replace(/\s+/g, ' '))
|
|
72
|
+
.find(Boolean);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readSkillSnapshot(name) {
|
|
76
|
+
const paths = [
|
|
77
|
+
resolvePath(process.cwd(), '.claude', 'skills', name, 'skill.md'),
|
|
78
|
+
resolvePath(process.cwd(), '.claude', 'skills', name, 'SKILL.md'),
|
|
79
|
+
resolvePath(process.cwd(), 'skills', name, 'skill.md'),
|
|
80
|
+
resolvePath(process.cwd(), 'skills', name, 'SKILL.md'),
|
|
81
|
+
];
|
|
82
|
+
const path = paths.find((p) => existsSync(p));
|
|
83
|
+
if (!path) fail(`skill not found: ${name}`);
|
|
84
|
+
const description = firstParagraph(readFileSync(path, 'utf8')) ?? '';
|
|
85
|
+
return { skill_name: name, skill_description: description.slice(0, 500) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function help() {
|
|
89
|
+
// Top-level help is generated from the command registry (single source of
|
|
90
|
+
// truth) so it can never drift from `which` or per-verb `--help` (NO.1535).
|
|
91
|
+
process.stdout.write(`${renderTopHelp()}\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// package.json version — surfaced in the `which` manifest so an agent sees
|
|
95
|
+
// exactly which CLI build it is talking to.
|
|
96
|
+
function cliVersion() {
|
|
97
|
+
try {
|
|
98
|
+
const pkgPath = resolvePath(import.meta.dirname ?? process.cwd(), '..', 'package.json');
|
|
99
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? 'unknown';
|
|
100
|
+
} catch {
|
|
101
|
+
return 'unknown';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function main() {
|
|
106
|
+
const argv = process.argv.slice(2);
|
|
107
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
108
|
+
help();
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolve back-compat resource aliases (DEC-191: `issue` → canonical `task`)
|
|
113
|
+
// ONCE here, so every downstream branch — the dispatch if-chain, per-verb help,
|
|
114
|
+
// and `which`'s --resource filter — sees the canonical noun. `whf issue …`
|
|
115
|
+
// keeps working silently (no nag); help/manifest advertise `task`.
|
|
116
|
+
const [rawResource, verb, ...rest] = argv;
|
|
117
|
+
const resource = resolveResource(rawResource);
|
|
118
|
+
|
|
119
|
+
// `which` is the capability manifest — it runs BEFORE the token gate so an
|
|
120
|
+
// agent can bootstrap the whole command surface with zero auth (redesign §3).
|
|
121
|
+
// It exposes verb/flag metadata + the resolved baseUrl, no data.
|
|
122
|
+
if (resource === 'which') {
|
|
123
|
+
const wargs = parseArgs([verb, ...rest].filter((x) => x !== undefined));
|
|
124
|
+
const resourceFilter =
|
|
125
|
+
typeof wargs.resource === 'string' ? resolveResource(wargs.resource) : undefined;
|
|
126
|
+
ok(buildManifest({ version: cliVersion(), baseUrl: resolveBaseUrl(), resourceFilter }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Per-verb `--help` is generated from the registry entry — also pre-token-gate
|
|
130
|
+
// (help needs no auth). Returns the single registry entry as JSON (usage/flags/output).
|
|
131
|
+
if (verb === '--help' || rest.includes('--help')) {
|
|
132
|
+
const entry = findEntry(resource, verb === '--help' ? undefined : verb);
|
|
133
|
+
if (entry) ok(renderHelpEntry(entry));
|
|
134
|
+
// no registry entry → fall through to legacy/usage handling below.
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// `workflow run <script> [...flags]` is a LOCAL command — it runs the TS orchestrator
|
|
138
|
+
// via tsx, needs no cloud token, so handle it before the token gate below.
|
|
139
|
+
if (resource === 'workflow' && verb === 'run') {
|
|
140
|
+
const { spawnSync } = await import('node:child_process');
|
|
141
|
+
const { fileURLToPath } = await import('node:url');
|
|
142
|
+
const nodePath = await import('node:path');
|
|
143
|
+
const repoRoot = nodePath.resolve(nodePath.dirname(fileURLToPath(import.meta.url)), '..');
|
|
144
|
+
const runCli = nodePath.join(repoRoot, 'apps/desktop/src/main/agent/orchestrator/run-cli.ts');
|
|
145
|
+
const r = spawnSync('pnpm', ['exec', 'tsx', runCli, ...rest], {
|
|
146
|
+
cwd: repoRoot,
|
|
147
|
+
stdio: 'inherit',
|
|
148
|
+
});
|
|
149
|
+
process.exit(r.status ?? 1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const args = parseArgs(rest);
|
|
153
|
+
const token = resolveToken();
|
|
154
|
+
if (!token) fail('no token: set WEHANDOFF_CLI_TOKEN or ~/.wehandoff/cli-token');
|
|
155
|
+
const baseUrl = resolveBaseUrl();
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// ask — agent reverse-asks the human with structured options (SPEC §15 /
|
|
159
|
+
// DEC-227 ask_card = AskUserQuestion group). channel form publishes a card +
|
|
160
|
+
// inbox item; session form is inbox-only. No sub-verb, so re-parse flags from
|
|
161
|
+
// [verb, ...rest].
|
|
162
|
+
//
|
|
163
|
+
// One ask = a group of 1..N questions. Two input shapes:
|
|
164
|
+
// --question <q> --options "a|b|c" a single-question group (N=1)
|
|
165
|
+
// --questions '[{"question":"…","options":["a","b"]}, …]' the full group
|
|
166
|
+
if (resource === 'ask') {
|
|
167
|
+
const a = parseArgs([verb, ...rest].filter(Boolean));
|
|
168
|
+
let questions;
|
|
169
|
+
if (a.questions) {
|
|
170
|
+
try {
|
|
171
|
+
questions = JSON.parse(String(a.questions));
|
|
172
|
+
} catch {
|
|
173
|
+
fail('--questions must be valid JSON (an array of { question, options })');
|
|
174
|
+
}
|
|
175
|
+
} else if (a.question && a.options) {
|
|
176
|
+
questions = [
|
|
177
|
+
{
|
|
178
|
+
question: String(a.question),
|
|
179
|
+
options: String(a.options)
|
|
180
|
+
.split('|')
|
|
181
|
+
.map((s) => s.trim())
|
|
182
|
+
.filter(Boolean),
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
if (!questions || !Array.isArray(questions) || (!a.channel && !a.session)) {
|
|
187
|
+
fail(
|
|
188
|
+
'usage: wehandoff ask (--question <q> --options "a|b|c" | --questions \'[{"question":"…","options":["a","b"]}]\') (--channel <id> --as-agent <id> [--user <id>] | --session <id>)'
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return ok(
|
|
192
|
+
await callApi(
|
|
193
|
+
{
|
|
194
|
+
method: 'POST',
|
|
195
|
+
path: '/api/cli/asks',
|
|
196
|
+
body: {
|
|
197
|
+
room_id: a.channel,
|
|
198
|
+
session_id: a.session,
|
|
199
|
+
user_id: a.user,
|
|
200
|
+
questions,
|
|
201
|
+
as_agent_id: a['as-agent'],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{ token, baseUrl }
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// inbox push — agent pushes a generic HITL card to a user's inbox (inbox
|
|
210
|
+
// spec §5 / §2.4: 一张卡=谁推的+从哪来+一句话+几个按钮). Buttons default to
|
|
211
|
+
// resolve actions (choice recorded server-side); channel/session 反问语义
|
|
212
|
+
// belongs to `wehandoff ask` above.
|
|
213
|
+
if (resource === 'inbox') {
|
|
214
|
+
if (verb !== 'push') fail(`unknown inbox subcommand: ${verb ?? '(none)'}`);
|
|
215
|
+
const options = String(args.options ?? '')
|
|
216
|
+
.split('|')
|
|
217
|
+
.map((s) => s.trim())
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
if (!args.title || options.length === 0) {
|
|
220
|
+
fail(
|
|
221
|
+
'usage: wehandoff inbox push --title <t> --options "a|b|c" [--user <id>] [--issue <id>] [--preview <id>] [--payload <json>]'
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
let payload;
|
|
225
|
+
if (args.payload) {
|
|
226
|
+
try {
|
|
227
|
+
payload = JSON.parse(String(args.payload));
|
|
228
|
+
} catch {
|
|
229
|
+
fail('--payload must be valid JSON');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return ok(
|
|
233
|
+
await callApi(
|
|
234
|
+
{
|
|
235
|
+
method: 'POST',
|
|
236
|
+
path: '/api/cli/inbox',
|
|
237
|
+
body: {
|
|
238
|
+
title: args.title,
|
|
239
|
+
options,
|
|
240
|
+
...(args.user ? { user_id: args.user } : {}),
|
|
241
|
+
...(args.issue ? { issue_id: args.issue } : {}),
|
|
242
|
+
...(args.preview ? { preview_id: args.preview } : {}),
|
|
243
|
+
...(payload ? { payload } : {}),
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{ token, baseUrl }
|
|
247
|
+
)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// message send / reply — agent (or its owner) posts a text message into a
|
|
252
|
+
// channel it belongs to (SPEC §16 「agent 也可显式 whf message send」).
|
|
253
|
+
// --image <path>: upload PNG/JPG → attach to the message so it renders as
|
|
254
|
+
// a real image in the forum/room (NO.2060). Mirrors task comment --image flow.
|
|
255
|
+
if (resource === 'message' && (verb === 'send' || verb === 'reply')) {
|
|
256
|
+
if (!args.channel || !args.text) {
|
|
257
|
+
fail(
|
|
258
|
+
`usage: wehandoff message ${verb} --channel <id> --text <t>${verb === 'reply' ? ' --to <msgId>' : ''} [--as-agent <id>] [--image <png>]`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (verb === 'reply' && !args.to)
|
|
262
|
+
fail('usage: wehandoff message reply --channel <id> --to <msgId> --text <t>');
|
|
263
|
+
|
|
264
|
+
// --image: upload the image bytes to the attachments bucket, then attach
|
|
265
|
+
// to the message. Three-step mirrors task comment --image (lines 629-668).
|
|
266
|
+
let attachments;
|
|
267
|
+
if (args.image) {
|
|
268
|
+
const imgPath = resolvePath(String(args.image));
|
|
269
|
+
if (!existsSync(imgPath)) fail(`image not found: ${imgPath}`);
|
|
270
|
+
const filename = imgPath.split(/[/\\]/).pop() || 'image.png';
|
|
271
|
+
const ext = (filename.split('.').pop() || '').toLowerCase();
|
|
272
|
+
const mime = {
|
|
273
|
+
png: 'image/png',
|
|
274
|
+
jpg: 'image/jpeg',
|
|
275
|
+
jpeg: 'image/jpeg',
|
|
276
|
+
webp: 'image/webp',
|
|
277
|
+
gif: 'image/gif',
|
|
278
|
+
avif: 'image/avif',
|
|
279
|
+
svg: 'image/svg+xml',
|
|
280
|
+
}[ext];
|
|
281
|
+
if (!mime) fail(`unsupported image type .${ext} (png/jpg/webp/gif/avif/svg)`);
|
|
282
|
+
const dataBase64 = readFileSync(imgPath).toString('base64');
|
|
283
|
+
const up = await callApi(
|
|
284
|
+
{
|
|
285
|
+
method: 'POST',
|
|
286
|
+
path: '/api/cli/messages/attachments',
|
|
287
|
+
body: { channelId: args.channel, filename, mime, dataBase64 },
|
|
288
|
+
},
|
|
289
|
+
{ token, baseUrl }
|
|
290
|
+
);
|
|
291
|
+
attachments = [
|
|
292
|
+
{ storagePath: up.storage_path, kind: 'image', name: filename, mime, size: up.size },
|
|
293
|
+
];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return ok(
|
|
297
|
+
await callApi(
|
|
298
|
+
{
|
|
299
|
+
method: 'POST',
|
|
300
|
+
path: '/api/cli/messages',
|
|
301
|
+
body: {
|
|
302
|
+
channelId: args.channel,
|
|
303
|
+
text: args.text,
|
|
304
|
+
asAgentId: args['as-agent'],
|
|
305
|
+
...(verb === 'reply' ? { quotedMessageId: args.to } : {}),
|
|
306
|
+
...(attachments ? { attachments } : {}),
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{ token, baseUrl }
|
|
310
|
+
)
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (resource === 'message' && verb === 'list') {
|
|
315
|
+
if (!args.channel) fail('usage: wehandoff message list --channel <id> [--limit <n>]');
|
|
316
|
+
const qs = `channel=${encodeURIComponent(args.channel)}${args.limit ? `&limit=${encodeURIComponent(args.limit)}` : ''}`;
|
|
317
|
+
return ok(
|
|
318
|
+
await callApi({ method: 'GET', path: `/api/cli/messages?${qs}` }, { token, baseUrl })
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// message get — fetch one message (the quote / pin target may live outside
|
|
323
|
+
// the loaded window). Channel-scoped + member-gated server-side. (whf-cli §4)
|
|
324
|
+
if (resource === 'message' && verb === 'get') {
|
|
325
|
+
if (!args.channel || !args.msg)
|
|
326
|
+
fail('usage: wehandoff message get --channel <id> --msg <id>');
|
|
327
|
+
return ok(
|
|
328
|
+
await callApi(
|
|
329
|
+
{
|
|
330
|
+
method: 'GET',
|
|
331
|
+
path: `/api/cli/messages/${encodeURIComponent(args.msg)}?channel=${encodeURIComponent(args.channel)}`,
|
|
332
|
+
},
|
|
333
|
+
{ token, baseUrl }
|
|
334
|
+
)
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// message edit — edit your own text message (author-gated server-side via
|
|
339
|
+
// editOwnMessage; mirrors the web PATCH route). (whf-cli §4)
|
|
340
|
+
if (resource === 'message' && verb === 'edit') {
|
|
341
|
+
if (!args.channel || !args.msg || !args.text) {
|
|
342
|
+
fail('usage: wehandoff message edit --channel <id> --msg <id> --text <t>');
|
|
343
|
+
}
|
|
344
|
+
return ok(
|
|
345
|
+
await callApi(
|
|
346
|
+
{
|
|
347
|
+
method: 'PATCH',
|
|
348
|
+
path: `/api/cli/messages/${encodeURIComponent(args.msg)}`,
|
|
349
|
+
body: { channel: args.channel, text: args.text },
|
|
350
|
+
},
|
|
351
|
+
{ token, baseUrl }
|
|
352
|
+
)
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// message delete — soft-delete your own message (author-gated, tombstone via
|
|
357
|
+
// softDeleteMessage). (whf-cli §4)
|
|
358
|
+
if (resource === 'message' && verb === 'delete') {
|
|
359
|
+
if (!args.channel || !args.msg)
|
|
360
|
+
fail('usage: wehandoff message delete --channel <id> --msg <id>');
|
|
361
|
+
return ok(
|
|
362
|
+
await callApi(
|
|
363
|
+
{
|
|
364
|
+
method: 'DELETE',
|
|
365
|
+
path: `/api/cli/messages/${encodeURIComponent(args.msg)}`,
|
|
366
|
+
body: { channel: args.channel },
|
|
367
|
+
},
|
|
368
|
+
{ token, baseUrl }
|
|
369
|
+
)
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// message forward — relay a message to an L3 session (SPEC §1.5 forward{…} /
|
|
374
|
+
// §16). The web producer (/api/groups/[id]/messages/[msgId]/forward → persistForward)
|
|
375
|
+
// inserts a `forward`-type message into the SOURCE room carrying the target
|
|
376
|
+
// session id; the target owner's daemon soft-interrupts that session. This is
|
|
377
|
+
// the CLI mirror over the `wh-cli:` token. `--note` = the forwarder's private
|
|
378
|
+
// interpretation (SPEC-SAMPLE 22 「补私下解读」).
|
|
379
|
+
if (resource === 'message' && verb === 'forward') {
|
|
380
|
+
if (!args.channel || !args.msg || !args['to-session']) {
|
|
381
|
+
fail(
|
|
382
|
+
'usage: wehandoff message forward --channel <id> --msg <msgId> --to-session <sessionId> [--note <md>]'
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return ok(
|
|
386
|
+
await callApi(
|
|
387
|
+
{
|
|
388
|
+
method: 'POST',
|
|
389
|
+
path: `/api/cli/messages/${encodeURIComponent(args.msg)}/forward`,
|
|
390
|
+
body: {
|
|
391
|
+
channel: args.channel,
|
|
392
|
+
targetSessionId: args['to-session'],
|
|
393
|
+
note: args.note,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
{ token, baseUrl }
|
|
397
|
+
)
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// message {merge-forward,download,search} + room search + thread messages —
|
|
402
|
+
// SPEC §16 says these need NEW/修 backend fns (FTS, merge-forward writer,
|
|
403
|
+
// attachment-fetch repair, by-root thread query) NOT built this round. Honest
|
|
404
|
+
// "not yet implemented" rather than a silent no-op.
|
|
405
|
+
if (
|
|
406
|
+
resource === 'message' &&
|
|
407
|
+
(verb === 'merge-forward' || verb === 'download' || verb === 'search')
|
|
408
|
+
) {
|
|
409
|
+
fail(
|
|
410
|
+
`not yet implemented: \`message ${verb}\` needs a new/repaired backend (SPEC §16, whf-cli design §4). See report.`,
|
|
411
|
+
2
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (resource === 'room' && verb === 'search') {
|
|
415
|
+
fail('not yet implemented: `room search` needs an FTS backend (SPEC §16). See report.', 2);
|
|
416
|
+
}
|
|
417
|
+
if (resource === 'thread' && verb === 'messages') {
|
|
418
|
+
fail(
|
|
419
|
+
'not yet implemented: `thread messages --root` needs a by-root thread query (SPEC §16). For forum threads use `message list --channel <threadId>`.',
|
|
420
|
+
2
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// reaction add/remove/list — emoji reactions (whf-cli §4 「reaction」). add/
|
|
425
|
+
// remove are idempotent (route converges via toggleReaction). --as-agent
|
|
426
|
+
// reacts AS an owned member agent (ownership verified server-side).
|
|
427
|
+
if (resource === 'reaction' && (verb === 'add' || verb === 'remove')) {
|
|
428
|
+
if (!args.channel || !args.msg || !args.emoji) {
|
|
429
|
+
fail(
|
|
430
|
+
`usage: wehandoff reaction ${verb} --channel <id> --msg <id> --emoji <e> [--as-agent <id>]`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return ok(
|
|
434
|
+
await callApi(
|
|
435
|
+
{
|
|
436
|
+
method: 'POST',
|
|
437
|
+
path: `/api/cli/messages/${encodeURIComponent(args.msg)}/reactions`,
|
|
438
|
+
body: {
|
|
439
|
+
channel: args.channel,
|
|
440
|
+
emoji: args.emoji,
|
|
441
|
+
op: verb,
|
|
442
|
+
asAgentId: args['as-agent'],
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{ token, baseUrl }
|
|
446
|
+
)
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (resource === 'reaction' && verb === 'list') {
|
|
450
|
+
if (!args.channel || !args.msg)
|
|
451
|
+
fail('usage: wehandoff reaction list --channel <id> --msg <id>');
|
|
452
|
+
return ok(
|
|
453
|
+
await callApi(
|
|
454
|
+
{
|
|
455
|
+
method: 'GET',
|
|
456
|
+
path: `/api/cli/messages/${encodeURIComponent(args.msg)}/reactions?channel=${encodeURIComponent(args.channel)}`,
|
|
457
|
+
},
|
|
458
|
+
{ token, baseUrl }
|
|
459
|
+
)
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// reaction batch — reactions across many messages at once (lark
|
|
464
|
+
// reactions.batch_query). --msgs is a comma-separated id list. (whf-cli §4)
|
|
465
|
+
if (resource === 'reaction' && verb === 'batch') {
|
|
466
|
+
if (!args.channel || !args.msgs)
|
|
467
|
+
fail('usage: wehandoff reaction batch --channel <id> --msgs <id,id,...>');
|
|
468
|
+
const qs = `channel=${encodeURIComponent(args.channel)}&msgs=${encodeURIComponent(args.msgs)}`;
|
|
469
|
+
return ok(
|
|
470
|
+
await callApi(
|
|
471
|
+
{ method: 'GET', path: `/api/cli/messages/reactions?${qs}` },
|
|
472
|
+
{ token, baseUrl }
|
|
473
|
+
)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// pin add/remove/list — pin messages (whf-cli §4 「pin」). add/remove are
|
|
478
|
+
// idempotent (route converges via togglePin). Any channel member may pin.
|
|
479
|
+
if (resource === 'pin' && (verb === 'add' || verb === 'remove')) {
|
|
480
|
+
if (!args.channel || !args.msg)
|
|
481
|
+
fail(`usage: wehandoff pin ${verb} --channel <id> --msg <id>`);
|
|
482
|
+
return ok(
|
|
483
|
+
await callApi(
|
|
484
|
+
{
|
|
485
|
+
method: 'POST',
|
|
486
|
+
path: '/api/cli/pins',
|
|
487
|
+
body: { channel: args.channel, msg: args.msg, op: verb },
|
|
488
|
+
},
|
|
489
|
+
{ token, baseUrl }
|
|
490
|
+
)
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (resource === 'pin' && verb === 'list') {
|
|
494
|
+
if (!args.channel) fail('usage: wehandoff pin list --channel <id>');
|
|
495
|
+
return ok(
|
|
496
|
+
await callApi(
|
|
497
|
+
{ method: 'GET', path: `/api/cli/pins?channel=${encodeURIComponent(args.channel)}` },
|
|
498
|
+
{ token, baseUrl }
|
|
499
|
+
)
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (resource === 'room' && verb === 'create') {
|
|
504
|
+
if (!args.name) fail('usage: wehandoff room create --name <name> [--type text|forum]');
|
|
505
|
+
if (args.type && args.type !== 'text' && args.type !== 'forum')
|
|
506
|
+
fail('channel create: --type must be text or forum');
|
|
507
|
+
const { conversation_id } = await callApi(
|
|
508
|
+
{
|
|
509
|
+
method: 'POST',
|
|
510
|
+
path: '/api/cli/groups',
|
|
511
|
+
body: { name: args.name, ...(args.type ? { type: args.type } : {}) },
|
|
512
|
+
},
|
|
513
|
+
{ token, baseUrl }
|
|
514
|
+
);
|
|
515
|
+
return ok({ channel_id: conversation_id });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// channel list — channels the caller owns/belongs to (owner/membership-gated
|
|
519
|
+
// server-side). Read passthrough: prints the route's JSON verbatim. (whf-cli §4)
|
|
520
|
+
if (resource === 'room' && verb === 'list') {
|
|
521
|
+
return ok(await callApi({ method: 'GET', path: '/api/cli/groups' }, { token, baseUrl }));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// channel get — fetch one channel row (member-gated server-side). (whf-cli §4)
|
|
525
|
+
if (resource === 'room' && verb === 'get') {
|
|
526
|
+
if (!args.channel) fail('usage: wehandoff room get --channel <id>');
|
|
527
|
+
return ok(
|
|
528
|
+
await callApi(
|
|
529
|
+
{ method: 'GET', path: `/api/cli/groups/${encodeURIComponent(args.channel)}` },
|
|
530
|
+
{ token, baseUrl }
|
|
531
|
+
)
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// channel members / agents — list a channel's members (member-gated). `agents`
|
|
536
|
+
// is the same list filtered to member_type === 'agent' (lark chat.members.bots).
|
|
537
|
+
if (resource === 'room' && (verb === 'members' || verb === 'agents')) {
|
|
538
|
+
if (!args.channel) fail(`usage: wehandoff room ${verb} --channel <id>`);
|
|
539
|
+
const { channel, members } = await callApi(
|
|
540
|
+
{ method: 'GET', path: `/api/cli/groups/${encodeURIComponent(args.channel)}?with=members` },
|
|
541
|
+
{ token, baseUrl }
|
|
542
|
+
);
|
|
543
|
+
const filtered =
|
|
544
|
+
verb === 'agents'
|
|
545
|
+
? (members ?? []).filter((m) => m.memberType === 'agent')
|
|
546
|
+
: (members ?? []);
|
|
547
|
+
return ok({ channel, members: filtered });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// channel update — rename a channel (owner-gated server-side via renameGroup).
|
|
551
|
+
if (resource === 'room' && verb === 'update') {
|
|
552
|
+
if (!args.channel || !args.name)
|
|
553
|
+
fail('usage: wehandoff room update --channel <id> --name <name>');
|
|
554
|
+
return ok(
|
|
555
|
+
await callApi(
|
|
556
|
+
{
|
|
557
|
+
method: 'PATCH',
|
|
558
|
+
path: `/api/cli/groups/${encodeURIComponent(args.channel)}`,
|
|
559
|
+
body: { name: args.name },
|
|
560
|
+
},
|
|
561
|
+
{ token, baseUrl }
|
|
562
|
+
)
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// channel delete — archive (default) or hard-delete (--hard) the WHOLE channel
|
|
567
|
+
// (owner-gated server-side via archiveGroup/deleteGroup). Hits the `/delete`
|
|
568
|
+
// SUB-route, NOT bare `/groups/<id>` (which is member-kick) — and sends NO body,
|
|
569
|
+
// so it can never be mistaken for a member removal. (whf-cli §4, B4)
|
|
570
|
+
if (resource === 'room' && verb === 'delete') {
|
|
571
|
+
if (!args.channel) fail('usage: wehandoff room delete --channel <id> [--hard]');
|
|
572
|
+
const qs = args.hard ? '?hard=1' : '';
|
|
573
|
+
return ok(
|
|
574
|
+
await callApi(
|
|
575
|
+
{
|
|
576
|
+
method: 'DELETE',
|
|
577
|
+
path: `/api/cli/groups/${encodeURIComponent(args.channel)}/delete${qs}`,
|
|
578
|
+
},
|
|
579
|
+
{ token, baseUrl }
|
|
580
|
+
)
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// channel remove-member — kick a member (owner-gated server-side via kickMember).
|
|
585
|
+
// --member-type defaults to 'user'; pass 'agent' to remove an agent member.
|
|
586
|
+
if (resource === 'room' && verb === 'remove-member') {
|
|
587
|
+
if (!args.channel || !args.member)
|
|
588
|
+
fail(
|
|
589
|
+
'usage: wehandoff room remove-member --channel <id> --member <id> [--member-type user|agent]'
|
|
590
|
+
);
|
|
591
|
+
return ok(
|
|
592
|
+
await callApi(
|
|
593
|
+
{
|
|
594
|
+
method: 'DELETE',
|
|
595
|
+
path: `/api/cli/groups/${encodeURIComponent(args.channel)}`,
|
|
596
|
+
body: { member: args.member, member_type: args['member-type'] ?? 'user' },
|
|
597
|
+
},
|
|
598
|
+
{ token, baseUrl }
|
|
599
|
+
)
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (resource === 'task' && verb === 'create') {
|
|
604
|
+
if (!args.title)
|
|
605
|
+
fail(
|
|
606
|
+
'usage: wehandoff task create --title <t> [--description <d>] [--domain <科室>] [--as-agent <id>]'
|
|
607
|
+
);
|
|
608
|
+
return ok(
|
|
609
|
+
await callApi(
|
|
610
|
+
{
|
|
611
|
+
method: 'POST',
|
|
612
|
+
path: '/api/cli/tasks',
|
|
613
|
+
body: {
|
|
614
|
+
title: args.title,
|
|
615
|
+
description: args.description,
|
|
616
|
+
// --domain: 科室 (NO.1847). Optional here — if omitted the server derives from an
|
|
617
|
+
// area= tag in title/description and rejects the task if it can't (never null).
|
|
618
|
+
...(args.domain ? { domain: args.domain } : {}),
|
|
619
|
+
// --as-agent: open the issue AS an agent the caller owns (NO.348/243,
|
|
620
|
+
// §0 agent=人). Ownership verified server-side against agent.owner_id.
|
|
621
|
+
...(args['as-agent'] ? { creatorType: 'agent', creatorId: args['as-agent'] } : {}),
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
{ token, baseUrl }
|
|
625
|
+
)
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// issue get — read one issue (creator-gated server-side: only the issue's
|
|
630
|
+
// creator may read it via the CLI surface). Returns { issue }. (NO.984 route)
|
|
631
|
+
if (resource === 'task' && verb === 'get') {
|
|
632
|
+
if (!args.issue) fail('usage: wehandoff task get --issue <id>');
|
|
633
|
+
return ok(
|
|
634
|
+
await callApi({ method: 'GET', path: `/api/cli/tasks/${args.issue}` }, { token, baseUrl })
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// issue update — edit title/description ONLY. Status is intentionally NOT
|
|
639
|
+
// settable here: it has its own free-set path via `issue writeback`, so an
|
|
640
|
+
// edit never silently moves status (the route's IssueEdit excludes it).
|
|
641
|
+
// Sends only the fields actually passed. Returns { issue }. (NO.984 route)
|
|
642
|
+
if (resource === 'task' && verb === 'update') {
|
|
643
|
+
if (!args.issue || (args.title === undefined && args.description === undefined)) {
|
|
644
|
+
fail('usage: wehandoff task update --issue <id> [--title <t>] [--description <d>]');
|
|
645
|
+
}
|
|
646
|
+
return ok(
|
|
647
|
+
await callApi(
|
|
648
|
+
{
|
|
649
|
+
method: 'PATCH',
|
|
650
|
+
path: `/api/cli/tasks/${args.issue}`,
|
|
651
|
+
body: {
|
|
652
|
+
...(args.title !== undefined ? { title: args.title } : {}),
|
|
653
|
+
...(args.description !== undefined ? { description: args.description } : {}),
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
{ token, baseUrl }
|
|
657
|
+
)
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// issue delete — hard-delete the issue (creator-gated server-side via
|
|
662
|
+
// deleteIssue). Returns { issue_id, deleted:true }. (NO.984 route)
|
|
663
|
+
if (resource === 'task' && verb === 'delete') {
|
|
664
|
+
if (!args.issue) fail('usage: wehandoff task delete --issue <id>');
|
|
665
|
+
return ok(
|
|
666
|
+
await callApi(
|
|
667
|
+
{ method: 'DELETE', path: `/api/cli/tasks/${args.issue}` },
|
|
668
|
+
{ token, baseUrl }
|
|
669
|
+
)
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// task goal — set or show the task's goal field (feat/goal-adversarial-verification).
|
|
674
|
+
// Show: whf task goal --issue <id> → prints kind + text/storyId
|
|
675
|
+
// Set: whf task goal --issue <id> --prose "<t>" → kind:prose, text:<t>
|
|
676
|
+
// whf task goal --issue <id> --story <id> → kind:story, storyId:<id>
|
|
677
|
+
if (resource === 'task' && verb === 'goal') {
|
|
678
|
+
if (!args.issue)
|
|
679
|
+
fail('usage: wehandoff task goal --issue <id> [--prose "<text>" | --story <storyId>]');
|
|
680
|
+
// show — no mutation flag present
|
|
681
|
+
if (args.prose === undefined && args.story === undefined) {
|
|
682
|
+
const { task } = await callApi(
|
|
683
|
+
{ method: 'GET', path: `/api/cli/tasks/${args.issue}` },
|
|
684
|
+
{ token, baseUrl }
|
|
685
|
+
);
|
|
686
|
+
const g = task?.goal;
|
|
687
|
+
if (!g) return ok({ goal: null });
|
|
688
|
+
const summary =
|
|
689
|
+
g.kind === 'story'
|
|
690
|
+
? `kind: story · ${g.storyId ?? '(no storyId)'}`
|
|
691
|
+
: `kind: prose · ${g.text ?? '(empty)'}`;
|
|
692
|
+
return ok({ goal: g, summary });
|
|
693
|
+
}
|
|
694
|
+
// set — exactly one of --prose / --story
|
|
695
|
+
if (args.prose !== undefined && args.story !== undefined) {
|
|
696
|
+
fail('--prose and --story are mutually exclusive');
|
|
697
|
+
}
|
|
698
|
+
const goal =
|
|
699
|
+
args.prose !== undefined
|
|
700
|
+
? { kind: 'prose', text: String(args.prose) }
|
|
701
|
+
: { kind: 'story', storyId: String(args.story) };
|
|
702
|
+
return ok(
|
|
703
|
+
await callApi(
|
|
704
|
+
{
|
|
705
|
+
method: 'PATCH',
|
|
706
|
+
path: `/api/cli/tasks/${args.issue}`,
|
|
707
|
+
body: { goal },
|
|
708
|
+
},
|
|
709
|
+
{ token, baseUrl }
|
|
710
|
+
)
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// forum post — author a forum thread (SPEC §1.6 / §16, NO.553). Author defaults
|
|
715
|
+
// to the CLI caller (user); pass --as-agent <id> to post AS an agent the caller
|
|
716
|
+
// owns (ownership verified server-side, mirrors `issue create --as-agent`).
|
|
717
|
+
if (resource === 'forum' && verb === 'post') {
|
|
718
|
+
if (!args.forum || !args.title || !args.body) {
|
|
719
|
+
fail('usage: wehandoff forum post --forum <id> --title <t> --body <t> [--as-agent <id>]');
|
|
720
|
+
}
|
|
721
|
+
return ok(
|
|
722
|
+
await callApi(
|
|
723
|
+
{
|
|
724
|
+
method: 'POST',
|
|
725
|
+
path: '/api/cli/forum/posts',
|
|
726
|
+
body: {
|
|
727
|
+
forumId: args.forum,
|
|
728
|
+
title: args.title,
|
|
729
|
+
body: args.body,
|
|
730
|
+
asAgentId: args['as-agent'],
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
{ token, baseUrl }
|
|
734
|
+
)
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// issue comment — one activity-feed entry (SPEC §6): optional status推进,
|
|
739
|
+
// 挂 artifact (报告) / preview (证据) / user_story (回归声明). 自由态, 无闸.
|
|
740
|
+
// --image <path> 上传截图: PNG/JPG bytes → cloud `image` artifact → 挂到 comment,
|
|
741
|
+
// 让接活 agent 读得到 (NO.1678). 与 --artifact 互斥 (一条 comment 挂一个 artifact).
|
|
742
|
+
if (resource === 'task' && verb === 'comment') {
|
|
743
|
+
if (!args.issue)
|
|
744
|
+
fail(
|
|
745
|
+
'usage: wehandoff task comment --issue <id> [--body <t>] [--status <s>] [--artifact <id>] [--image <png>] [--preview <id>] [--us <id,id>] [--as-agent <id>]'
|
|
746
|
+
);
|
|
747
|
+
const userStoryIds = args.us
|
|
748
|
+
? String(args.us)
|
|
749
|
+
.split(',')
|
|
750
|
+
.map((s) => s.trim())
|
|
751
|
+
.filter(Boolean)
|
|
752
|
+
: undefined;
|
|
753
|
+
|
|
754
|
+
// --image: upload the screenshot → create an `image` artifact → hang it on
|
|
755
|
+
// the comment. Three-step because the artifact id must exist before the
|
|
756
|
+
// comment references it (mirrors the web sign→PUT→artifact flow, but the
|
|
757
|
+
// CLI ships bytes to a serviceRole route since it has no Supabase session).
|
|
758
|
+
let artifactId = args.artifact;
|
|
759
|
+
if (args.image) {
|
|
760
|
+
if (args.artifact)
|
|
761
|
+
fail('--image and --artifact are mutually exclusive (one artifact per comment)');
|
|
762
|
+
const imgPath = resolvePath(String(args.image));
|
|
763
|
+
if (!existsSync(imgPath)) fail(`image not found: ${imgPath}`);
|
|
764
|
+
const filename = imgPath.split(/[/\\]/).pop() || 'screenshot.png';
|
|
765
|
+
const ext = (filename.split('.').pop() || '').toLowerCase();
|
|
766
|
+
const mime = {
|
|
767
|
+
png: 'image/png',
|
|
768
|
+
jpg: 'image/jpeg',
|
|
769
|
+
jpeg: 'image/jpeg',
|
|
770
|
+
webp: 'image/webp',
|
|
771
|
+
gif: 'image/gif',
|
|
772
|
+
avif: 'image/avif',
|
|
773
|
+
svg: 'image/svg+xml',
|
|
774
|
+
}[ext];
|
|
775
|
+
if (!mime) fail(`unsupported image type .${ext} (png/jpg/webp/gif/avif/svg)`);
|
|
776
|
+
const dataBase64 = readFileSync(imgPath).toString('base64');
|
|
777
|
+
const up = await callApi(
|
|
778
|
+
{
|
|
779
|
+
method: 'POST',
|
|
780
|
+
path: `/api/cli/tasks/${args.issue}/attachments`,
|
|
781
|
+
body: { filename, mime, dataBase64 },
|
|
782
|
+
},
|
|
783
|
+
{ token, baseUrl }
|
|
784
|
+
);
|
|
785
|
+
const art = await callApi(
|
|
786
|
+
{
|
|
787
|
+
method: 'POST',
|
|
788
|
+
path: '/api/cli/artifacts',
|
|
789
|
+
body: {
|
|
790
|
+
kind: 'image',
|
|
791
|
+
title: filename,
|
|
792
|
+
storagePath: up.storage_path,
|
|
793
|
+
authorAgentId: args['as-agent'],
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
{ token, baseUrl }
|
|
797
|
+
);
|
|
798
|
+
artifactId = art.artifact_id;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const commentResult = await callApiRaw(
|
|
802
|
+
{
|
|
803
|
+
method: 'POST',
|
|
804
|
+
path: `/api/cli/tasks/${args.issue}/comment`,
|
|
805
|
+
body: {
|
|
806
|
+
body: args.body,
|
|
807
|
+
status: args.status,
|
|
808
|
+
artifactId,
|
|
809
|
+
previewId: args.preview,
|
|
810
|
+
userStoryIds,
|
|
811
|
+
authorAgentId: args['as-agent'],
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
{ token, baseUrl }
|
|
815
|
+
);
|
|
816
|
+
if (commentResult.status === 422 && commentResult.json?.error === 'verification_failed') {
|
|
817
|
+
const attacks = Array.isArray(commentResult.json.attacks) ? commentResult.json.attacks : [];
|
|
818
|
+
process.stdout.write('✗ 验证未通过 (goal 未达成):\n');
|
|
819
|
+
for (const attack of attacks) {
|
|
820
|
+
process.stdout.write(` - ${attack}\n`);
|
|
821
|
+
}
|
|
822
|
+
process.exit(1);
|
|
823
|
+
}
|
|
824
|
+
if (!commentResult.ok) {
|
|
825
|
+
fail(
|
|
826
|
+
commentResult.json?.error ?? String(commentResult.json) ?? `HTTP ${commentResult.status}`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
return ok(commentResult.json);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// issue comment-delete — remove one activity-feed comment (SPEC §6). Caller
|
|
833
|
+
// may only delete a comment they authored (user) or one authored by an agent
|
|
834
|
+
// they own (server-side authz, mirrors `issue comment --as-agent`). Status
|
|
835
|
+
// set by the comment is NOT rolled back (free-set state, not derived).
|
|
836
|
+
if (resource === 'task' && verb === 'comment-delete') {
|
|
837
|
+
if (!args.issue || !args.comment)
|
|
838
|
+
fail('usage: wehandoff task comment-delete --issue <id> --comment <commentId>');
|
|
839
|
+
return ok(
|
|
840
|
+
await callApi(
|
|
841
|
+
{
|
|
842
|
+
method: 'DELETE',
|
|
843
|
+
path: `/api/cli/tasks/${args.issue}/comment/${args.comment}`,
|
|
844
|
+
},
|
|
845
|
+
{ token, baseUrl }
|
|
846
|
+
)
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// task comments — read the activity feed (chronological task_comment rows) for an
|
|
851
|
+
// issue, including image artifacts as `artifact.href` (public URL from the
|
|
852
|
+
// `attachments` bucket, NO.1678). Lets an agent fetch comment images without the
|
|
853
|
+
// desktop app/daemon. Creator-gated server-side (mirrors `task get`).
|
|
854
|
+
if (resource === 'task' && verb === 'comments') {
|
|
855
|
+
if (!args.issue) fail('usage: wehandoff task comments --issue <id>');
|
|
856
|
+
return ok(
|
|
857
|
+
await callApi(
|
|
858
|
+
{ method: 'GET', path: `/api/cli/tasks/${args.issue}/comments` },
|
|
859
|
+
{ token, baseUrl }
|
|
860
|
+
)
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// issue writeback — §9 执行→通告: the session-completion caller. Push the issue
|
|
865
|
+
// to a delivered status AND/OR POST an artifact_card back to the issue's ORIGIN
|
|
866
|
+
// channel (the 提为Issue provenance). `--deploy-url` here = the §9 acceptance
|
|
867
|
+
// evidence URL (recorded in the activity feed), not a platform deploy URL
|
|
868
|
+
// (DEC-189 — the platform tracks none). Returns { status, announced }.
|
|
869
|
+
if (resource === 'task' && verb === 'writeback') {
|
|
870
|
+
if (!args.issue)
|
|
871
|
+
fail(
|
|
872
|
+
'usage: wehandoff task writeback --issue <id> [--status <s>] [--deploy-url <u>] [--commit <sha>]'
|
|
873
|
+
);
|
|
874
|
+
const writebackResult = await callApiRaw(
|
|
875
|
+
{
|
|
876
|
+
method: 'POST',
|
|
877
|
+
path: `/api/cli/tasks/${args.issue}/writeback`,
|
|
878
|
+
body: {
|
|
879
|
+
status: args.status,
|
|
880
|
+
deployUrl: args['deploy-url'],
|
|
881
|
+
commitSha: args.commit,
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
{ token, baseUrl }
|
|
885
|
+
);
|
|
886
|
+
if (writebackResult.status === 422 && writebackResult.json?.error === 'verification_failed') {
|
|
887
|
+
const attacks = Array.isArray(writebackResult.json.attacks)
|
|
888
|
+
? writebackResult.json.attacks
|
|
889
|
+
: [];
|
|
890
|
+
process.stdout.write('✗ 验证未通过 (goal 未达成):\n');
|
|
891
|
+
for (const attack of attacks) {
|
|
892
|
+
process.stdout.write(` - ${attack}\n`);
|
|
893
|
+
}
|
|
894
|
+
process.exit(1);
|
|
895
|
+
}
|
|
896
|
+
if (!writebackResult.ok) {
|
|
897
|
+
fail(
|
|
898
|
+
writebackResult.json?.error ??
|
|
899
|
+
String(writebackResult.json) ??
|
|
900
|
+
`HTTP ${writebackResult.status}`
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
return ok(writebackResult.json);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// issue push-card — SPEC §1.5 / DEC-24: push an issue_card into a channel.
|
|
907
|
+
// NOT a deterministic status-change trigger — the AGENT decides when/which
|
|
908
|
+
// channel (judgment=agent, execution=code, Karpathy §5). --as-agent posts the
|
|
909
|
+
// card AS an agent the caller owns; absent → posts as the caller (user).
|
|
910
|
+
if (resource === 'task' && verb === 'push-card') {
|
|
911
|
+
if (!args.issue || !args.channel) {
|
|
912
|
+
fail('usage: wehandoff task push-card --issue <id> --channel <id> [--as-agent <id>]');
|
|
913
|
+
}
|
|
914
|
+
return ok(
|
|
915
|
+
await callApi(
|
|
916
|
+
{
|
|
917
|
+
method: 'POST',
|
|
918
|
+
path: `/api/cli/tasks/${args.issue}/push-card`,
|
|
919
|
+
body: {
|
|
920
|
+
channelId: args.channel,
|
|
921
|
+
asAgentId: args['as-agent'],
|
|
922
|
+
},
|
|
923
|
+
},
|
|
924
|
+
{ token, baseUrl }
|
|
925
|
+
)
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// issue push-verification-card — SPEC §6 activity / NO.496: record a
|
|
930
|
+
// verification/sign-off card into a channel. Same producer-edge shape as
|
|
931
|
+
// push-card (judgment=agent, execution=code) plus optional --preview-url
|
|
932
|
+
// and --note (markdown). --as-agent posts AS an owned agent; absent → user.
|
|
933
|
+
if (resource === 'task' && verb === 'push-verification-card') {
|
|
934
|
+
if (!args.issue || !args.channel) {
|
|
935
|
+
fail(
|
|
936
|
+
'usage: wehandoff task push-verification-card --issue <id> --channel <id> [--preview-url <u>] [--note <md>] [--as-agent <id>]'
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
return ok(
|
|
940
|
+
await callApi(
|
|
941
|
+
{
|
|
942
|
+
method: 'POST',
|
|
943
|
+
path: `/api/cli/tasks/${args.issue}/push-verification-card`,
|
|
944
|
+
body: {
|
|
945
|
+
channelId: args.channel,
|
|
946
|
+
previewUrl: args['preview-url'],
|
|
947
|
+
noteMd: args.note,
|
|
948
|
+
asAgentId: args['as-agent'],
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
{ token, baseUrl }
|
|
952
|
+
)
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// artifact create — session 产物 (kind html/md/text). 挂到 comment 用 --artifact.
|
|
957
|
+
if (resource === 'artifact' && verb === 'create') {
|
|
958
|
+
if (!args.kind)
|
|
959
|
+
fail(
|
|
960
|
+
'usage: wehandoff artifact create --kind html|md|text [--title <t>] [--content <c>|--file <p>] [--project <id>] [--as-agent <id>]'
|
|
961
|
+
);
|
|
962
|
+
const content = args.file ? readFileSync(args.file, 'utf8') : args.content;
|
|
963
|
+
return ok(
|
|
964
|
+
await callApi(
|
|
965
|
+
{
|
|
966
|
+
method: 'POST',
|
|
967
|
+
path: '/api/cli/artifacts',
|
|
968
|
+
body: {
|
|
969
|
+
kind: args.kind,
|
|
970
|
+
title: args.title,
|
|
971
|
+
content,
|
|
972
|
+
projectId: args.project,
|
|
973
|
+
authorAgentId: args['as-agent'],
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
{ token, baseUrl }
|
|
977
|
+
)
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (resource === 'room' && verb === 'add-agent') {
|
|
982
|
+
if (!args.channel || !args.agent) fail('--channel and --agent are required');
|
|
983
|
+
return ok(
|
|
984
|
+
await callApi(
|
|
985
|
+
{
|
|
986
|
+
method: 'POST',
|
|
987
|
+
path: `/api/cli/groups/${args.channel}/agents`,
|
|
988
|
+
body: { agent_id: args.agent },
|
|
989
|
+
},
|
|
990
|
+
{ token, baseUrl }
|
|
991
|
+
)
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (resource === 'room' && verb === 'add-member') {
|
|
996
|
+
if (!args.channel || !args.user) fail('--channel and --user are required');
|
|
997
|
+
return ok(
|
|
998
|
+
await callApi(
|
|
999
|
+
{
|
|
1000
|
+
method: 'POST',
|
|
1001
|
+
path: `/api/cli/groups/${args.channel}/members`,
|
|
1002
|
+
body: { user_id: args.user },
|
|
1003
|
+
},
|
|
1004
|
+
{ token, baseUrl }
|
|
1005
|
+
)
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (resource === 'service' && verb === 'search') {
|
|
1010
|
+
const query = args._.join(' ').trim();
|
|
1011
|
+
if (!query) fail('usage: wehandoff service search <query>');
|
|
1012
|
+
return ok(
|
|
1013
|
+
await callApi(
|
|
1014
|
+
{ method: 'GET', path: `/api/cli/services/search?q=${encodeURIComponent(query)}` },
|
|
1015
|
+
{ token, baseUrl }
|
|
1016
|
+
)
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (resource === 'service' && verb === 'create') {
|
|
1021
|
+
if (!args.agent || !args.title)
|
|
1022
|
+
fail(
|
|
1023
|
+
'usage: wehandoff service create --agent <id> --title <t> [--description <d>] [--skill <name>]'
|
|
1024
|
+
);
|
|
1025
|
+
const skillSnapshot = args.skill ? readSkillSnapshot(String(args.skill)) : {};
|
|
1026
|
+
return ok(
|
|
1027
|
+
await callApi(
|
|
1028
|
+
{
|
|
1029
|
+
method: 'POST',
|
|
1030
|
+
path: '/api/cli/services',
|
|
1031
|
+
body: {
|
|
1032
|
+
agent_id: args.agent,
|
|
1033
|
+
title: args.title,
|
|
1034
|
+
description: args.description ?? '',
|
|
1035
|
+
...skillSnapshot,
|
|
1036
|
+
},
|
|
1037
|
+
},
|
|
1038
|
+
{ token, baseUrl }
|
|
1039
|
+
)
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (resource === 'service' && (verb === 'publish' || verb === 'unpublish')) {
|
|
1044
|
+
if (!args.service) fail(`usage: wehandoff service ${verb} --service <id>`);
|
|
1045
|
+
return ok(
|
|
1046
|
+
await callApi(
|
|
1047
|
+
{
|
|
1048
|
+
method: 'POST',
|
|
1049
|
+
path: `/api/cli/services/${args.service}/publish`,
|
|
1050
|
+
body: { published: verb === 'publish' },
|
|
1051
|
+
},
|
|
1052
|
+
{ token, baseUrl }
|
|
1053
|
+
)
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (resource === 'service' && verb === 'list') {
|
|
1058
|
+
return ok(await callApi({ method: 'GET', path: '/api/cli/services' }, { token, baseUrl }));
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// matching start — one-shot matchmaking (NO.1999, KEYSTONE G3). Given a demand
|
|
1062
|
+
// (task id + need text), in ONE step: creates a forum room, sources candidates
|
|
1063
|
+
// via runSourcing (promote + post demand + fan-out), and returns the room id
|
|
1064
|
+
// plus candidateAgentIds. Callers that already have a room: use `matching source`.
|
|
1065
|
+
if (resource === 'matching' && verb === 'start') {
|
|
1066
|
+
if (!args.issue || !args.need) {
|
|
1067
|
+
fail('usage: wehandoff matching start --issue <taskId> --need <text> [--name <roomName>]');
|
|
1068
|
+
}
|
|
1069
|
+
return ok(
|
|
1070
|
+
await callApi(
|
|
1071
|
+
{
|
|
1072
|
+
method: 'POST',
|
|
1073
|
+
path: '/api/cli/matching/start',
|
|
1074
|
+
body: {
|
|
1075
|
+
taskId: args.issue,
|
|
1076
|
+
need: args.need,
|
|
1077
|
+
...(args.name ? { name: args.name } : {}),
|
|
1078
|
+
},
|
|
1079
|
+
},
|
|
1080
|
+
{ token, baseUrl }
|
|
1081
|
+
)
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (resource === 'matching' && verb === 'source') {
|
|
1086
|
+
if (!args.issue || !args.room || !args.query) {
|
|
1087
|
+
fail(
|
|
1088
|
+
'usage: wehandoff matching source --issue <id> --room <id> --query <q> [--demand <text>]'
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
return ok(
|
|
1092
|
+
await callApi(
|
|
1093
|
+
{
|
|
1094
|
+
method: 'POST',
|
|
1095
|
+
path: '/api/cli/matching/source',
|
|
1096
|
+
// task⊥room (overturns DEC-179): the demand room is passed explicitly,
|
|
1097
|
+
// not derived from a task-side binding.
|
|
1098
|
+
body: {
|
|
1099
|
+
taskId: args.issue,
|
|
1100
|
+
roomId: args.room,
|
|
1101
|
+
query: args.query,
|
|
1102
|
+
demandText: args.demand,
|
|
1103
|
+
},
|
|
1104
|
+
},
|
|
1105
|
+
{ token, baseUrl }
|
|
1106
|
+
)
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// matching refresh — the /api/cli/matching/refresh route is NOT wired yet
|
|
1111
|
+
// (NO.2099). Honest exit-2 not-yet-implemented, matching the `stub` manifest,
|
|
1112
|
+
// instead of a raw 404. Use `matching start` / `matching source` meanwhile.
|
|
1113
|
+
if (resource === 'matching' && verb === 'refresh') {
|
|
1114
|
+
fail(
|
|
1115
|
+
'not yet implemented: `matching refresh` route (/api/cli/matching/refresh) is not wired (NO.2099). Use `matching start` / `matching source`.',
|
|
1116
|
+
2
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (resource === 'project' && verb === 'create') {
|
|
1121
|
+
if (!args.name || !args.slug || !args['repo-url']) {
|
|
1122
|
+
fail(
|
|
1123
|
+
'usage: wehandoff project create --name <n> --slug <s> --repo-url <u> [--repo-kind <k>] [--default-branch <b>]'
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
return ok(
|
|
1127
|
+
await callApi(
|
|
1128
|
+
{
|
|
1129
|
+
method: 'POST',
|
|
1130
|
+
path: '/api/cli/projects',
|
|
1131
|
+
body: {
|
|
1132
|
+
name: args.name,
|
|
1133
|
+
slug: args.slug,
|
|
1134
|
+
repo_url: args['repo-url'],
|
|
1135
|
+
...(args['repo-kind'] ? { repo_kind: args['repo-kind'] } : {}),
|
|
1136
|
+
...(args['default-branch'] ? { default_branch: args['default-branch'] } : {}),
|
|
1137
|
+
// Handoff provenance passthrough (§4.1 / NO.550) — receiver chain threads these.
|
|
1138
|
+
...(args['source-project'] ? { source_project_id: args['source-project'] } : {}),
|
|
1139
|
+
...(args['source-issue'] ? { source_issue_id: args['source-issue'] } : {}),
|
|
1140
|
+
},
|
|
1141
|
+
},
|
|
1142
|
+
{ token, baseUrl }
|
|
1143
|
+
)
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// user resolve — email → canonical user_id (P0b). The cross-person primitive
|
|
1148
|
+
// that survives DEC-117: L1 add-member takes a bare user_id, so callers need
|
|
1149
|
+
// to turn an email into one. --handle is not supported in v1 (no column).
|
|
1150
|
+
if (resource === 'user' && verb === 'resolve') {
|
|
1151
|
+
if (!args.email) fail('usage: wehandoff user resolve --email <e>');
|
|
1152
|
+
return ok(
|
|
1153
|
+
await callApi(
|
|
1154
|
+
{ method: 'GET', path: `/api/cli/users/resolve?email=${encodeURIComponent(args.email)}` },
|
|
1155
|
+
{ token, baseUrl }
|
|
1156
|
+
)
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// workspace list — workspaces the caller belongs to (member-scoped server-side).
|
|
1161
|
+
// Fills the A13 vacuum (NO.2100): SPEC §16 「agent 经 whf 够到全状态」 covers the
|
|
1162
|
+
// workspace container. Read-only — member/role management stays on the web.
|
|
1163
|
+
if (resource === 'workspace' && verb === 'list') {
|
|
1164
|
+
return ok(await callApi({ method: 'GET', path: '/api/cli/workspaces' }, { token, baseUrl }));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// workspace members — list a workspace's members (member-gated server-side).
|
|
1168
|
+
if (resource === 'workspace' && verb === 'members') {
|
|
1169
|
+
if (!args.workspace) fail('usage: wehandoff workspace members --workspace <id>');
|
|
1170
|
+
return ok(
|
|
1171
|
+
await callApi(
|
|
1172
|
+
{
|
|
1173
|
+
method: 'GET',
|
|
1174
|
+
path: `/api/cli/workspaces/${encodeURIComponent(args.workspace)}/members`,
|
|
1175
|
+
},
|
|
1176
|
+
{ token, baseUrl }
|
|
1177
|
+
)
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// project list — projects owned by the caller (owner-filtered server-side). (whf-cli §4.1)
|
|
1182
|
+
if (resource === 'project' && verb === 'list') {
|
|
1183
|
+
return ok(await callApi({ method: 'GET', path: '/api/cli/projects' }, { token, baseUrl }));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// project get — fetch one project row (owner-gated). (whf-cli §4.1)
|
|
1187
|
+
if (resource === 'project' && verb === 'get') {
|
|
1188
|
+
if (!args.project) fail('usage: wehandoff project get --project <id>');
|
|
1189
|
+
return ok(
|
|
1190
|
+
await callApi(
|
|
1191
|
+
{ method: 'GET', path: `/api/cli/projects/${encodeURIComponent(args.project)}` },
|
|
1192
|
+
{ token, baseUrl }
|
|
1193
|
+
)
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// project update — partial update of name / repo_url / default_branch (owner-gated). (whf-cli §4.1)
|
|
1198
|
+
if (resource === 'project' && verb === 'update') {
|
|
1199
|
+
if (!args.project)
|
|
1200
|
+
fail(
|
|
1201
|
+
'usage: wehandoff project update --project <id> [--name <n>] [--repo-url <u>] [--default-branch <b>]'
|
|
1202
|
+
);
|
|
1203
|
+
const body = {
|
|
1204
|
+
...(args.name ? { name: args.name } : {}),
|
|
1205
|
+
...(args['repo-url'] ? { repo_url: args['repo-url'] } : {}),
|
|
1206
|
+
...(args['default-branch'] ? { default_branch: args['default-branch'] } : {}),
|
|
1207
|
+
};
|
|
1208
|
+
if (Object.keys(body).length === 0)
|
|
1209
|
+
fail(
|
|
1210
|
+
'usage: wehandoff project update --project <id> [--name <n>] [--repo-url <u>] [--default-branch <b>]'
|
|
1211
|
+
);
|
|
1212
|
+
return ok(
|
|
1213
|
+
await callApi(
|
|
1214
|
+
{
|
|
1215
|
+
method: 'PATCH',
|
|
1216
|
+
path: `/api/cli/projects/${encodeURIComponent(args.project)}`,
|
|
1217
|
+
body,
|
|
1218
|
+
},
|
|
1219
|
+
{ token, baseUrl }
|
|
1220
|
+
)
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// project delete — archive (soft) by default; --hard goes ?hard=1 (true delete). (whf-cli §4.1/§2.4)
|
|
1225
|
+
if (resource === 'project' && verb === 'delete') {
|
|
1226
|
+
if (!args.project) fail('usage: wehandoff project delete --project <id> [--hard]');
|
|
1227
|
+
const path = `/api/cli/projects/${encodeURIComponent(args.project)}${args.hard ? '?hard=1' : ''}`;
|
|
1228
|
+
return ok(await callApi({ method: 'DELETE', path }, { token, baseUrl }));
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// story steps — fetch the project's user_story e2e blueprint to run as acceptance regression (§6/§17).
|
|
1232
|
+
if (resource === 'story' && verb === 'steps') {
|
|
1233
|
+
if (!args.issue) fail('usage: wehandoff story steps --issue <id>');
|
|
1234
|
+
return ok(
|
|
1235
|
+
await callApi(
|
|
1236
|
+
{ method: 'GET', path: `/api/cli/tasks/${args.issue}/story-steps` },
|
|
1237
|
+
{ token, baseUrl }
|
|
1238
|
+
)
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// userstory list — enumerate all user_story rows for a project (NO.1496).
|
|
1243
|
+
if ((resource === 'userstory' || resource === 'story') && verb === 'list') {
|
|
1244
|
+
if (!args.project) fail('usage: wehandoff userstory list --project <id>');
|
|
1245
|
+
return ok(
|
|
1246
|
+
await callApi(
|
|
1247
|
+
{ method: 'GET', path: `/api/cli/projects/${args.project}/stories` },
|
|
1248
|
+
{ token, baseUrl }
|
|
1249
|
+
)
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// story get — fetch a single user_story by id (cloud-restore §4). Owner-gated
|
|
1254
|
+
// server-side (story → project → owner). Top-level id route, no --project.
|
|
1255
|
+
if ((resource === 'userstory' || resource === 'story') && verb === 'get') {
|
|
1256
|
+
if (!args.story) fail('usage: wehandoff story get --story <id>');
|
|
1257
|
+
return ok(
|
|
1258
|
+
await callApi({ method: 'GET', path: `/api/cli/stories/${args.story}` }, { token, baseUrl })
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// userstory create — author a project's e2e acceptance blueprint (SPEC §1.2).
|
|
1263
|
+
// `story create` is accepted as an alias (matches the `story steps` verb).
|
|
1264
|
+
// --steps is a JSON array of {action, expect}; the route validates the shape.
|
|
1265
|
+
if ((resource === 'userstory' || resource === 'story') && verb === 'create') {
|
|
1266
|
+
if (!args.project || !args.title || !args.steps) {
|
|
1267
|
+
fail(
|
|
1268
|
+
'usage: wehandoff userstory create --project <id> --title <t> --steps <json [{action,expect}]>'
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
let steps;
|
|
1272
|
+
try {
|
|
1273
|
+
steps = JSON.parse(args.steps);
|
|
1274
|
+
} catch {
|
|
1275
|
+
fail('--steps must be valid JSON, e.g. \'[{"action":"open /","expect":"hero loads"}]\'');
|
|
1276
|
+
}
|
|
1277
|
+
return ok(
|
|
1278
|
+
await callApi(
|
|
1279
|
+
{
|
|
1280
|
+
method: 'POST',
|
|
1281
|
+
path: `/api/cli/projects/${args.project}/stories`,
|
|
1282
|
+
body: { title: args.title, steps },
|
|
1283
|
+
},
|
|
1284
|
+
{ token, baseUrl }
|
|
1285
|
+
)
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// userstory update — edit a story's title and/or steps (NO.504). At least one
|
|
1290
|
+
// of --title / --steps is required; --steps is the same JSON shape as create.
|
|
1291
|
+
// Owner-gated server-side. (`story update` alias, matching create.)
|
|
1292
|
+
if ((resource === 'userstory' || resource === 'story') && verb === 'update') {
|
|
1293
|
+
if (!args.project || !args.story || (!args.title && !args.steps)) {
|
|
1294
|
+
fail(
|
|
1295
|
+
'usage: wehandoff userstory update --project <id> --story <id> [--title <t>] [--steps <json [{action,expect}]>]'
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
const body = {};
|
|
1299
|
+
if (args.title) body.title = args.title;
|
|
1300
|
+
if (args.steps) {
|
|
1301
|
+
try {
|
|
1302
|
+
body.steps = JSON.parse(args.steps);
|
|
1303
|
+
} catch {
|
|
1304
|
+
fail('--steps must be valid JSON, e.g. \'[{"action":"open /","expect":"hero loads"}]\'');
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return ok(
|
|
1308
|
+
await callApi(
|
|
1309
|
+
{
|
|
1310
|
+
method: 'PATCH',
|
|
1311
|
+
path: `/api/cli/projects/${args.project}/stories/${args.story}`,
|
|
1312
|
+
body,
|
|
1313
|
+
},
|
|
1314
|
+
{ token, baseUrl }
|
|
1315
|
+
)
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// userstory delete — hard-delete a story (NO.504). Owner-gated server-side.
|
|
1320
|
+
// (`story delete` alias, matching create.)
|
|
1321
|
+
if ((resource === 'userstory' || resource === 'story') && verb === 'delete') {
|
|
1322
|
+
if (!args.project || !args.story) {
|
|
1323
|
+
fail('usage: wehandoff userstory delete --project <id> --story <id>');
|
|
1324
|
+
}
|
|
1325
|
+
return ok(
|
|
1326
|
+
await callApi(
|
|
1327
|
+
{
|
|
1328
|
+
method: 'DELETE',
|
|
1329
|
+
path: `/api/cli/projects/${args.project}/stories/${args.story}`,
|
|
1330
|
+
},
|
|
1331
|
+
{ token, baseUrl }
|
|
1332
|
+
)
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// acceptance report — agent reports a per-criterion verdict after running the
|
|
1337
|
+
// e2e regression on the real product page (§6/§17). The /api/cli/tasks/<id>/
|
|
1338
|
+
// acceptance route is NOT wired yet (NO.2099) — there is no acceptance-write
|
|
1339
|
+
// persistence lib, only a read-overlay. Honest exit-2 not-yet-implemented,
|
|
1340
|
+
// matching the `stub` manifest, instead of a raw 404.
|
|
1341
|
+
if (resource === 'acceptance' && verb === 'report') {
|
|
1342
|
+
fail(
|
|
1343
|
+
'not yet implemented: `acceptance report` route (/api/cli/tasks/<id>/acceptance) is not wired (NO.2099).',
|
|
1344
|
+
2
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// asset share/pull/push/grant/revoke — the shared-asset sharing layer
|
|
1349
|
+
// (P1.4 / spec §4/§5). `share` promotes a local issue md into a cloud
|
|
1350
|
+
// `shared_asset`; `pull`/`push` sync a LOCAL working copy under
|
|
1351
|
+
// ~/.wehandoff/shared/<id>/ against the cloud HEAD via server CAS
|
|
1352
|
+
// (expected_rev → 409) + a node-diff3 3-way merge. The client NEVER picks
|
|
1353
|
+
// rev itself; server-side CAS stays in the DB layer (we react to 409).
|
|
1354
|
+
if (resource === 'asset') {
|
|
1355
|
+
// share — POST /api/cli/assets: promote a local asset to a shared_asset,
|
|
1356
|
+
// then seed the local working copy + .meta.json from the route's response
|
|
1357
|
+
// (initial cloud_rev=0, base_body=body just shared). constitution is
|
|
1358
|
+
// rejected server-side (422); --file reads the body from disk.
|
|
1359
|
+
if (verb === 'share') {
|
|
1360
|
+
if (!args.channel || !args.title || (args.body === undefined && !args.file)) {
|
|
1361
|
+
fail(
|
|
1362
|
+
'usage: wehandoff asset share --channel <id> --title <t> (--body <t> | --file <p>) [--kind <k>] [--source-path <p>]'
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
const assetBody = args.file ? readFileSync(args.file, 'utf8') : String(args.body);
|
|
1366
|
+
const { asset } = await callApi(
|
|
1367
|
+
{
|
|
1368
|
+
method: 'POST',
|
|
1369
|
+
path: '/api/cli/assets',
|
|
1370
|
+
body: {
|
|
1371
|
+
channel: args.channel,
|
|
1372
|
+
title: args.title,
|
|
1373
|
+
body: assetBody,
|
|
1374
|
+
...(args.kind ? { kind: args.kind } : {}),
|
|
1375
|
+
...(args['source-path'] ? { source_path: args['source-path'] } : {}),
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
{ token, baseUrl }
|
|
1379
|
+
);
|
|
1380
|
+
// Seed the working copy at the freshly-shared body + its cloud_rev so a
|
|
1381
|
+
// later `push` knows the expected_rev and diff3 has a base ancestor.
|
|
1382
|
+
writeWorkingCopy({ assetId: asset.id, body: assetBody, cloudRev: asset.cloud_rev });
|
|
1383
|
+
return ok({ asset, working_copy: assetDirHint(asset.id) });
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// pull — GET /api/cli/assets/<id>: fetch cloud HEAD body+rev into the
|
|
1387
|
+
// local working copy. clean (working == base, or no copy yet) → fast-
|
|
1388
|
+
// forward to remote + advance base. dirty (un-pushed local edits) → do
|
|
1389
|
+
// NOT clobber: keep the working copy, refresh the base ancestor to remote
|
|
1390
|
+
// so the next push's diff3 is against the latest HEAD (spec §4 触发式 pull).
|
|
1391
|
+
if (verb === 'pull') {
|
|
1392
|
+
if (!args.asset) fail('usage: wehandoff asset pull --asset <id>');
|
|
1393
|
+
const { asset } = await callApi(
|
|
1394
|
+
{ method: 'GET', path: `/api/cli/assets/${encodeURIComponent(args.asset)}` },
|
|
1395
|
+
{ token, baseUrl }
|
|
1396
|
+
);
|
|
1397
|
+
const dirty = isDirty({ assetId: args.asset });
|
|
1398
|
+
if (!dirty) {
|
|
1399
|
+
// FF: working copy tracks remote exactly.
|
|
1400
|
+
writeWorkingCopy({ assetId: args.asset, body: asset.body, cloudRev: asset.cloud_rev });
|
|
1401
|
+
clearMerged({ assetId: args.asset });
|
|
1402
|
+
return ok({ asset, pulled: 'fast-forward', working_copy: assetDirHint(args.asset) });
|
|
1403
|
+
}
|
|
1404
|
+
// dirty: preserve local edits; advance the merge base to remote HEAD.
|
|
1405
|
+
writeMeta({ assetId: args.asset, cloudRev: asset.cloud_rev, baseBody: asset.body });
|
|
1406
|
+
return ok({
|
|
1407
|
+
asset: { id: asset.id, cloud_rev: asset.cloud_rev, title: asset.title },
|
|
1408
|
+
pulled: 'kept-local',
|
|
1409
|
+
note: 'local working copy has un-pushed edits and was preserved; merge base advanced to cloud HEAD — run `asset push` to merge + upload.',
|
|
1410
|
+
working_copy: assetDirHint(args.asset),
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// push — POST /api/cli/assets/<id>/push with expected_rev (CAS). On 200 the
|
|
1415
|
+
// push landed: advance base := pushed body, rev := server rev. On 409 the
|
|
1416
|
+
// cloud HEAD moved: re-pull remote, 3-way merge {base, local, remote} via
|
|
1417
|
+
// node-diff3, and re-push the merged body if clean. A residual conflict is
|
|
1418
|
+
// written to .merged (git-style markers) and surfaced — NOT re-pushed.
|
|
1419
|
+
if (verb === 'push') {
|
|
1420
|
+
if (!args.asset) fail('usage: wehandoff asset push --asset <id>');
|
|
1421
|
+
const meta = readMeta({ assetId: args.asset });
|
|
1422
|
+
const local = readWorkingCopy({ assetId: args.asset });
|
|
1423
|
+
if (meta == null || local == null) {
|
|
1424
|
+
fail(
|
|
1425
|
+
`no local working copy for asset ${args.asset} — run \`asset pull --asset <id>\` first`
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// 1st push at the rev our base_body is synced to.
|
|
1430
|
+
const first = await callApiRaw(
|
|
1431
|
+
{
|
|
1432
|
+
method: 'POST',
|
|
1433
|
+
path: `/api/cli/assets/${encodeURIComponent(args.asset)}/push`,
|
|
1434
|
+
body: { expected_rev: meta.cloud_rev, body: local },
|
|
1435
|
+
},
|
|
1436
|
+
{ token, baseUrl }
|
|
1437
|
+
);
|
|
1438
|
+
|
|
1439
|
+
if (first.ok) {
|
|
1440
|
+
// clean push: base advances to exactly what we uploaded (spec §4 ①).
|
|
1441
|
+
writeWorkingCopy({ assetId: args.asset, body: local, cloudRev: first.json.cloud_rev });
|
|
1442
|
+
clearMerged({ assetId: args.asset });
|
|
1443
|
+
return ok({ pushed: 'clean', cloud_rev: first.json.cloud_rev, asset_id: args.asset });
|
|
1444
|
+
}
|
|
1445
|
+
if (first.status !== 409) {
|
|
1446
|
+
// a non-CAS error (403/404/400/...) — surface verbatim, don't merge.
|
|
1447
|
+
fail(
|
|
1448
|
+
typeof first.json?.error === 'string' ? first.json.error : JSON.stringify(first.json)
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// 409: cloud HEAD moved. Re-pull remote body+rev, 3-way merge.
|
|
1453
|
+
const { asset: remote } = await callApi(
|
|
1454
|
+
{ method: 'GET', path: `/api/cli/assets/${encodeURIComponent(args.asset)}` },
|
|
1455
|
+
{ token, baseUrl }
|
|
1456
|
+
);
|
|
1457
|
+
const { conflict, body: merged } = threeWayMerge(meta.base_body, local, remote.body);
|
|
1458
|
+
if (conflict) {
|
|
1459
|
+
// Unresolved overlap: write .merged with markers, surface, do NOT push.
|
|
1460
|
+
const path = writeMergedConflict({ assetId: args.asset, body: merged });
|
|
1461
|
+
// Keep the working copy as the user's local edits; advance base to the
|
|
1462
|
+
// remote we just merged against so a hand-resolved re-push is clean.
|
|
1463
|
+
writeMeta({ assetId: args.asset, cloudRev: remote.cloud_rev, baseBody: remote.body });
|
|
1464
|
+
failJson(
|
|
1465
|
+
{
|
|
1466
|
+
status: 'conflict',
|
|
1467
|
+
asset_id: args.asset,
|
|
1468
|
+
cloud_rev: remote.cloud_rev,
|
|
1469
|
+
merged_file: path,
|
|
1470
|
+
note: 'unresolved 3-way conflict — resolve the <<<<<<< markers in .merged, copy into issue.md, then re-run `asset push`.',
|
|
1471
|
+
},
|
|
1472
|
+
1
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// clean merge: re-push the merged body at the remote rev we merged against.
|
|
1477
|
+
const second = await callApiRaw(
|
|
1478
|
+
{
|
|
1479
|
+
method: 'POST',
|
|
1480
|
+
path: `/api/cli/assets/${encodeURIComponent(args.asset)}/push`,
|
|
1481
|
+
body: { expected_rev: remote.cloud_rev, body: merged },
|
|
1482
|
+
},
|
|
1483
|
+
{ token, baseUrl }
|
|
1484
|
+
);
|
|
1485
|
+
if (!second.ok) {
|
|
1486
|
+
// The HEAD moved again between our re-pull and re-push (rare). Surface
|
|
1487
|
+
// so the caller re-runs push; base stays at the remote we merged on.
|
|
1488
|
+
writeWorkingBody({ assetId: args.asset, body: merged });
|
|
1489
|
+
writeMeta({ assetId: args.asset, cloudRev: remote.cloud_rev, baseBody: remote.body });
|
|
1490
|
+
fail(
|
|
1491
|
+
typeof second.json?.error === 'string'
|
|
1492
|
+
? `re-push raced (HEAD moved again) — re-run \`asset push\`: ${second.json.error}`
|
|
1493
|
+
: JSON.stringify(second.json)
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
// base advances to exactly the merged body we pushed (spec §4 ③).
|
|
1497
|
+
writeWorkingCopy({ assetId: args.asset, body: merged, cloudRev: second.json.cloud_rev });
|
|
1498
|
+
clearMerged({ assetId: args.asset });
|
|
1499
|
+
return ok({ pushed: 'merged', cloud_rev: second.json.cloud_rev, asset_id: args.asset });
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// grant — POST /api/cli/assets/<id>/grants: owner grants a named account
|
|
1503
|
+
// viewer|editor. owner/role gates live in the DB layer (403 on violation).
|
|
1504
|
+
if (verb === 'grant') {
|
|
1505
|
+
if (!args.asset || !args.grantee || !args.role) {
|
|
1506
|
+
fail('usage: wehandoff asset grant --asset <id> --grantee <userId> --role viewer|editor');
|
|
1507
|
+
}
|
|
1508
|
+
return ok(
|
|
1509
|
+
await callApi(
|
|
1510
|
+
{
|
|
1511
|
+
method: 'POST',
|
|
1512
|
+
path: `/api/cli/assets/${encodeURIComponent(args.asset)}/grants`,
|
|
1513
|
+
body: { grantee: args.grantee, role: args.role },
|
|
1514
|
+
},
|
|
1515
|
+
{ token, baseUrl }
|
|
1516
|
+
)
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// revoke — DELETE /api/cli/assets/<id>/grants: owner revokes a named grant
|
|
1521
|
+
// (can't revoke the owner). owner/anti-orphan gates live in the DB layer.
|
|
1522
|
+
if (verb === 'revoke') {
|
|
1523
|
+
if (!args.asset || !args.grantee) {
|
|
1524
|
+
fail('usage: wehandoff asset revoke --asset <id> --grantee <userId>');
|
|
1525
|
+
}
|
|
1526
|
+
return ok(
|
|
1527
|
+
await callApi(
|
|
1528
|
+
{
|
|
1529
|
+
method: 'DELETE',
|
|
1530
|
+
path: `/api/cli/assets/${encodeURIComponent(args.asset)}/grants`,
|
|
1531
|
+
body: { grantee: args.grantee },
|
|
1532
|
+
},
|
|
1533
|
+
{ token, baseUrl }
|
|
1534
|
+
)
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
fail(`unknown asset verb: ${verb ?? ''}`);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (resource === 'constitution') {
|
|
1542
|
+
const agentHeaders = args['as-agent'] ? { 'x-wh-agent': args['as-agent'] } : undefined;
|
|
1543
|
+
const q = (obj) =>
|
|
1544
|
+
Object.entries(obj)
|
|
1545
|
+
.filter(([, v]) => v != null && v !== true)
|
|
1546
|
+
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
|
1547
|
+
.join('&');
|
|
1548
|
+
|
|
1549
|
+
if (verb === 'list') {
|
|
1550
|
+
if (!args.project) fail('--project is required');
|
|
1551
|
+
return ok(
|
|
1552
|
+
await callApi(
|
|
1553
|
+
{ method: 'GET', path: `/api/cli/constitution/list?${q({ project: args.project })}` },
|
|
1554
|
+
{ token, baseUrl }
|
|
1555
|
+
)
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
if (verb === 'drift') {
|
|
1559
|
+
if (!args.project) fail('--project is required');
|
|
1560
|
+
return ok(
|
|
1561
|
+
await callApi(
|
|
1562
|
+
{
|
|
1563
|
+
method: 'GET',
|
|
1564
|
+
path: `/api/cli/constitution/drift?${q({ project: args.project })}`,
|
|
1565
|
+
headers: agentHeaders,
|
|
1566
|
+
},
|
|
1567
|
+
{ token, baseUrl }
|
|
1568
|
+
)
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
if (verb === 'show') {
|
|
1572
|
+
if (!args.project || !args.slug) fail('--project and --slug are required');
|
|
1573
|
+
return ok(
|
|
1574
|
+
await callApi(
|
|
1575
|
+
{
|
|
1576
|
+
method: 'GET',
|
|
1577
|
+
path: `/api/cli/constitution/show?${q({ project: args.project, slug: args.slug, heading: args.heading })}`,
|
|
1578
|
+
headers: agentHeaders,
|
|
1579
|
+
},
|
|
1580
|
+
{ token, baseUrl }
|
|
1581
|
+
)
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
if (verb === 'add') {
|
|
1585
|
+
if (!args.project || !args.slug || !args.title)
|
|
1586
|
+
fail('--project, --slug and --title are required');
|
|
1587
|
+
const body = {
|
|
1588
|
+
project: args.project,
|
|
1589
|
+
slug: args.slug,
|
|
1590
|
+
kind: args.kind ?? 'other',
|
|
1591
|
+
title: args.title,
|
|
1592
|
+
source_path: args['source-path'],
|
|
1593
|
+
};
|
|
1594
|
+
if (args.file) body.full_text = readFileSync(args.file, 'utf8');
|
|
1595
|
+
if (args['no-gate']) body.drift_gated = false;
|
|
1596
|
+
return ok(
|
|
1597
|
+
await callApi(
|
|
1598
|
+
{ method: 'POST', path: '/api/cli/constitution/add', body },
|
|
1599
|
+
{ token, baseUrl }
|
|
1600
|
+
)
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
if (verb === 'publish') {
|
|
1604
|
+
if (!args.project || !args.slug || !args.file)
|
|
1605
|
+
fail('--project, --slug and --file are required');
|
|
1606
|
+
// 先取当前 head 的 section hashes,作为 base_hashes 传给 publish,防静默覆盖(NO.893)。
|
|
1607
|
+
// 首次 publish(doc 无 head)返回 null,publish 允许 null 跳过冲突检测。
|
|
1608
|
+
const { base_hashes } = await callApi(
|
|
1609
|
+
{
|
|
1610
|
+
method: 'GET',
|
|
1611
|
+
path: `/api/cli/constitution/hashes?${q({ project: args.project, slug: args.slug })}`,
|
|
1612
|
+
},
|
|
1613
|
+
{ token, baseUrl }
|
|
1614
|
+
);
|
|
1615
|
+
const body = {
|
|
1616
|
+
project: args.project,
|
|
1617
|
+
slug: args.slug,
|
|
1618
|
+
full_text: readFileSync(args.file, 'utf8'),
|
|
1619
|
+
base_hashes,
|
|
1620
|
+
};
|
|
1621
|
+
return ok(
|
|
1622
|
+
await callApi(
|
|
1623
|
+
{ method: 'POST', path: '/api/cli/constitution/publish', body },
|
|
1624
|
+
{ token, baseUrl }
|
|
1625
|
+
)
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
// sync — 建待审修宪提案(NO.873 spec §2)。
|
|
1629
|
+
// 读本地文件 → 拉当前 head hashes + rev → 推 proposal(不直接 publish);
|
|
1630
|
+
// 路由在 3-way 检测通过后建 proposal 并返回 proposal_id + section_diff。
|
|
1631
|
+
// 人在 web UI 点 [通过] 才 publish;CLI 永远不能直接 publish(guard 在路由)。
|
|
1632
|
+
if (verb === 'sync') {
|
|
1633
|
+
if (!args.project || !args.slug || !args.file)
|
|
1634
|
+
fail('--project, --slug and --file are required');
|
|
1635
|
+
// 1. 拉当前 head hashes + rev
|
|
1636
|
+
const hashesResp = await callApi(
|
|
1637
|
+
{
|
|
1638
|
+
method: 'GET',
|
|
1639
|
+
path: `/api/cli/constitution/hashes?${q({ project: args.project, slug: args.slug })}`,
|
|
1640
|
+
},
|
|
1641
|
+
{ token, baseUrl }
|
|
1642
|
+
);
|
|
1643
|
+
const baseHashes = hashesResp.base_hashes ?? null;
|
|
1644
|
+
const baseRev = typeof hashesResp.rev === 'number' ? hashesResp.rev : 0;
|
|
1645
|
+
// 2. 推 proposal
|
|
1646
|
+
const body = {
|
|
1647
|
+
project: args.project,
|
|
1648
|
+
slug: args.slug,
|
|
1649
|
+
full_text: readFileSync(args.file, 'utf8'),
|
|
1650
|
+
base_rev: baseRev,
|
|
1651
|
+
base_hashes: baseHashes,
|
|
1652
|
+
};
|
|
1653
|
+
return ok(
|
|
1654
|
+
await callApi(
|
|
1655
|
+
{ method: 'POST', path: '/api/cli/constitution/sync', body },
|
|
1656
|
+
{ token, baseUrl }
|
|
1657
|
+
)
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
// append — DEC-56 品味沉淀:把人确认后的一行规则追加进 head 文本再走 publish(rev 递增)。
|
|
1661
|
+
// 写入归 CLI(确定性);判断「该不该入宪 / 人同意了吗」归 agent + 人(§0,不自落宪)。
|
|
1662
|
+
if (verb === 'append') {
|
|
1663
|
+
if (!args.project || !args.slug || !args.line)
|
|
1664
|
+
fail('--project, --slug and --line are required');
|
|
1665
|
+
const body = { project: args.project, slug: args.slug, line: args.line };
|
|
1666
|
+
return ok(
|
|
1667
|
+
await callApi(
|
|
1668
|
+
{ method: 'POST', path: '/api/cli/constitution/append', body },
|
|
1669
|
+
{ token, baseUrl }
|
|
1670
|
+
)
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
fail(`unknown constitution verb: ${verb ?? ''}`);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
fail(`unknown command: ${resource ?? ''} ${verb ?? ''}. Run \`wehandoff --help\`.`);
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
main();
|