@officexapp/vidfarm-devcli 0.21.58 → 0.21.60
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/.agents/skills/vidfarm/SKILL.md +3 -3
- package/.agents/skills/vidfarm/recipes/retheme-template.md +6 -5
- package/.agents/skills/vidfarm/references/content-ideas.md +97 -14
- package/.agents/skills/vidfarm/references/core-workflows.md +4 -0
- package/.agents/skills/vidfarm/references/editor-workflows.md +25 -0
- package/SKILL.director.md +135 -22
- package/SKILL.md +9 -2
- package/clipper.md +7 -3
- package/dist/src/cli.js +201 -16
- package/dist/src/devcli/clipper-panel.js +1828 -0
- package/dist/src/devcli/clipper-run.js +643 -126
- package/dist/src/devcli/marketplace-gigs.js +307 -4
- package/dist/src/devcli/proof-verify.js +465 -0
- package/dist/src/devcli/skill-docs.js +14 -0
- package/experimental/flash-harness.md +142 -18
- package/experimental/meme-recaption.md +1044 -79
- package/experimental/sticker-slideshow-tips.md +511 -86
- package/marketplace.md +427 -12
- package/package.json +6 -1
- package/update.md +21 -1
|
@@ -35,13 +35,26 @@
|
|
|
35
35
|
//
|
|
36
36
|
// Backend-free (Node built-ins only) so it ships in the public cloud-only CLI.
|
|
37
37
|
import { parseArgs } from "node:util";
|
|
38
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
39
39
|
import path from "node:path";
|
|
40
40
|
const BOLD = "\x1b[1m";
|
|
41
41
|
const DIM = "\x1b[2m";
|
|
42
42
|
const GREEN = "\x1b[32m";
|
|
43
43
|
const YELLOW = "\x1b[33m";
|
|
44
44
|
const RESET = "\x1b[0m";
|
|
45
|
+
/**
|
|
46
|
+
* An expected, user-facing condition — "that task does not exist", "that id is
|
|
47
|
+
* already taken", "answer the gate first". Every throw in this file is one of
|
|
48
|
+
* these: they are the CLI talking to a human, not a bug. The entry point prints
|
|
49
|
+
* `.message` and exits 1 instead of dumping a Node stack and filing a crash
|
|
50
|
+
* report, which is what a wrong task id used to produce.
|
|
51
|
+
*/
|
|
52
|
+
export class ClipperUserError extends Error {
|
|
53
|
+
constructor(message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "ClipperUserError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
45
58
|
export const RUN_MODES = ["full-interactive", "quick-interactive", "auto-batch", "auto-submit"];
|
|
46
59
|
/** Nobody has chosen yet. Safe, and one of the two common modes. */
|
|
47
60
|
export const DEFAULT_RUN_MODE = "quick-interactive";
|
|
@@ -77,7 +90,29 @@ const DEFAULT_ASK = {
|
|
|
77
90
|
shortlist: "Which of these templates should I adapt? Reply with one template_id (or say 'you pick').",
|
|
78
91
|
plan: "Does this plan match what the buyer asked for? Approve it, or tell me what to change.",
|
|
79
92
|
raws: "Want to help collect footage? Drop clips into the folder above, then approve. Say 'go' to let me source it all myself.",
|
|
80
|
-
cut: "Watch the cut
|
|
93
|
+
cut: "Watch the cut, then tick what passes."
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The checklist behind a default gate, as a LIST — never as prose to be split
|
|
97
|
+
* apart later. The control panel used to derive checkboxes by splitting the ask
|
|
98
|
+
* on commas, which mangled any custom `--ask` ("…the music licence, and, most of
|
|
99
|
+
* all, the timing" became a criterion called "most of all") and could write an
|
|
100
|
+
* invented criterion into a task's approval history. A gate that carries its own
|
|
101
|
+
* criteria cannot be misread, and a custom ask simply carries none.
|
|
102
|
+
*/
|
|
103
|
+
export const DEFAULT_CRITERIA = {
|
|
104
|
+
cut: [
|
|
105
|
+
"Does it satisfy the brief",
|
|
106
|
+
"Is the hook alive in the first 3 seconds",
|
|
107
|
+
"Are the captions readable and inside the safe zone",
|
|
108
|
+
"Is the audio clean",
|
|
109
|
+
"Is the buyer's brand right"
|
|
110
|
+
],
|
|
111
|
+
plan: [
|
|
112
|
+
"Does it deliver what the buyer asked for",
|
|
113
|
+
"Is every beat something the agent can actually source",
|
|
114
|
+
"Is the hook in the first 3 seconds"
|
|
115
|
+
]
|
|
81
116
|
};
|
|
82
117
|
export function normalizeRunMode(raw) {
|
|
83
118
|
const v = (raw ?? "").trim().toLowerCase().replace(/[\s_]+/g, "-");
|
|
@@ -98,27 +133,45 @@ export function normalizeRunMode(raw) {
|
|
|
98
133
|
return null;
|
|
99
134
|
}
|
|
100
135
|
// ── the mission folder ───────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Where the mission folder lives: an explicit path, else VIDFARM_CLIPPER_DIR,
|
|
138
|
+
* else ./CLIPPER. Exported because the control panel resolves the same folder
|
|
139
|
+
* from its own flag rather than re-implementing the precedence.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveMissionRoot(dir) {
|
|
142
|
+
const explicit = dir?.trim();
|
|
103
143
|
if (explicit)
|
|
104
144
|
return path.resolve(explicit);
|
|
105
145
|
if (process.env.VIDFARM_CLIPPER_DIR)
|
|
106
146
|
return path.resolve(process.env.VIDFARM_CLIPPER_DIR);
|
|
107
147
|
return path.resolve("CLIPPER");
|
|
108
148
|
}
|
|
149
|
+
function missionRoot(values) {
|
|
150
|
+
return resolveMissionRoot(values.dir);
|
|
151
|
+
}
|
|
109
152
|
function readRunMode(root) {
|
|
110
153
|
// A per-run flag beats the file: `--mode auto-batch` on one command must not
|
|
111
154
|
// silently rewrite the mission's own choice.
|
|
155
|
+
const file = path.join(root, "run.json");
|
|
156
|
+
if (!existsSync(file))
|
|
157
|
+
return { mode: DEFAULT_RUN_MODE, isSet: false };
|
|
112
158
|
try {
|
|
113
|
-
const parsed = JSON.parse(readFileSync(
|
|
159
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
114
160
|
const mode = normalizeRunMode(parsed?.mode);
|
|
115
161
|
if (mode)
|
|
116
162
|
return { mode, isSet: true, note: parsed.note ?? null };
|
|
163
|
+
// The file exists and parses but names no mode we know. Falling back
|
|
164
|
+
// silently is how a mission drops from four gates to two without anybody
|
|
165
|
+
// being told.
|
|
166
|
+
return { mode: DEFAULT_RUN_MODE, isSet: false, unreadable: `run.json has no usable "mode" (found ${JSON.stringify(parsed?.mode)})` };
|
|
117
167
|
}
|
|
118
|
-
catch {
|
|
119
|
-
|
|
168
|
+
catch (error) {
|
|
169
|
+
return {
|
|
170
|
+
mode: DEFAULT_RUN_MODE,
|
|
171
|
+
isSet: false,
|
|
172
|
+
unreadable: `run.json could not be read (${error instanceof Error ? error.message : String(error)})`
|
|
173
|
+
};
|
|
120
174
|
}
|
|
121
|
-
return { mode: DEFAULT_RUN_MODE, isSet: false };
|
|
122
175
|
}
|
|
123
176
|
function resolveMode(root, values) {
|
|
124
177
|
const flag = normalizeRunMode(values.mode);
|
|
@@ -129,15 +182,52 @@ function resolveMode(root, values) {
|
|
|
129
182
|
return { mode: env, isSet: true };
|
|
130
183
|
return readRunMode(root);
|
|
131
184
|
}
|
|
185
|
+
/** Build a gate, attaching the standard criteria only when the ask is standard. */
|
|
186
|
+
function makeGate(stage, ask) {
|
|
187
|
+
const custom = (ask ?? "").trim();
|
|
188
|
+
const text = custom || DEFAULT_ASK[stage] || "Review this and approve it.";
|
|
189
|
+
const criteria = custom ? undefined : DEFAULT_CRITERIA[stage];
|
|
190
|
+
return {
|
|
191
|
+
stage,
|
|
192
|
+
ask: text,
|
|
193
|
+
since: new Date().toISOString(),
|
|
194
|
+
...(criteria ? { criteria: [...criteria] } : {})
|
|
195
|
+
};
|
|
196
|
+
}
|
|
132
197
|
function tasksDir(root) {
|
|
133
198
|
return path.join(root, "tasks");
|
|
134
199
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Task ids come off the wire, so they are validated, never "cleaned". Rewriting
|
|
202
|
+
* unsafe characters mapped distinct ids onto one folder ("T 1" and "T_1"), and
|
|
203
|
+
* `start` on the second silently overwrote the first — a live task, its history
|
|
204
|
+
* and its "a human watched this" record, gone with no warning. A raw id also
|
|
205
|
+
* reaches the page, so anything exotic here is a problem there too.
|
|
206
|
+
*/
|
|
207
|
+
function assertUsableTaskId(taskId) {
|
|
208
|
+
const id = (taskId ?? "").trim();
|
|
209
|
+
if (!id || id === "." || id === "..")
|
|
139
210
|
throw new Error(`"${taskId}" is not a usable task id.`);
|
|
140
|
-
|
|
211
|
+
if (!/^[A-Za-z0-9._-]+$/.test(id)) {
|
|
212
|
+
throw new Error(`"${taskId}" is not a usable task id — letters, digits, dot, dash and underscore only. ` +
|
|
213
|
+
`Ids with spaces or quotes cannot be told apart once they are folder names.`);
|
|
214
|
+
}
|
|
215
|
+
if (id.length > 128)
|
|
216
|
+
throw new Error(`"${taskId.slice(0, 32)}…" is too long for a task id (max 128).`);
|
|
217
|
+
return id;
|
|
218
|
+
}
|
|
219
|
+
/** Non-throwing form of the check above, for readers that must not crash. */
|
|
220
|
+
export function isUsableTaskId(taskId) {
|
|
221
|
+
try {
|
|
222
|
+
assertUsableTaskId(taskId);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function taskDir(root, taskId) {
|
|
230
|
+
return path.join(tasksDir(root), assertUsableTaskId(taskId));
|
|
141
231
|
}
|
|
142
232
|
function statePath(root, taskId) {
|
|
143
233
|
return path.join(taskDir(root, taskId), "state.json");
|
|
@@ -149,10 +239,49 @@ function readState(root, taskId) {
|
|
|
149
239
|
}
|
|
150
240
|
return JSON.parse(readFileSync(file, "utf8"));
|
|
151
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Task folders whose state.json could not be parsed on the last scan, by folder
|
|
244
|
+
* name. Exported so both front ends can SAY so instead of quietly showing a
|
|
245
|
+
* shorter list.
|
|
246
|
+
*/
|
|
247
|
+
export const unreadableTasks = new Map();
|
|
248
|
+
/**
|
|
249
|
+
* Why this parsed object cannot be treated as a task, or null when it can.
|
|
250
|
+
* Only the fields something downstream will dereference are checked — this is a
|
|
251
|
+
* guard against bricking the mission, not a schema validator.
|
|
252
|
+
*/
|
|
253
|
+
function describeUnusableState(state) {
|
|
254
|
+
if (!state || typeof state !== "object")
|
|
255
|
+
return "state.json is not an object";
|
|
256
|
+
if (typeof state.task_id !== "string" || !state.task_id)
|
|
257
|
+
return "state.json has no task_id";
|
|
258
|
+
if (!isUsableTaskId(state.task_id))
|
|
259
|
+
return `its recorded task_id ${JSON.stringify(state.task_id)} is not a usable id`;
|
|
260
|
+
if (typeof state.stage !== "string")
|
|
261
|
+
return "state.json has no stage";
|
|
262
|
+
if (typeof state.created_at !== "string")
|
|
263
|
+
return "state.json has no created_at";
|
|
264
|
+
if (state.price !== null && state.price !== undefined && typeof state.price !== "number") {
|
|
265
|
+
return "state.json has a non-numeric price";
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(state.history))
|
|
268
|
+
return "state.json has no history array";
|
|
269
|
+
if (!Array.isArray(state.shortlist))
|
|
270
|
+
return "state.json has no shortlist array";
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
/** Forget folders that no longer exist, so a deleted broken task stops warning. */
|
|
274
|
+
function forgetMissingUnreadable(root) {
|
|
275
|
+
for (const folder of Array.from(unreadableTasks.keys())) {
|
|
276
|
+
if (!existsSync(path.join(tasksDir(root), folder, "state.json")))
|
|
277
|
+
unreadableTasks.delete(folder);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
152
280
|
function listStates(root) {
|
|
153
281
|
const dir = tasksDir(root);
|
|
154
282
|
if (!existsSync(dir))
|
|
155
283
|
return [];
|
|
284
|
+
forgetMissingUnreadable(root);
|
|
156
285
|
const rows = [];
|
|
157
286
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
158
287
|
if (!entry.isDirectory())
|
|
@@ -161,20 +290,97 @@ function listStates(root) {
|
|
|
161
290
|
if (!existsSync(file))
|
|
162
291
|
continue;
|
|
163
292
|
try {
|
|
164
|
-
|
|
293
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
294
|
+
// Parsing is not enough. Anything downstream — the sort below, the review
|
|
295
|
+
// queue's price formatting, the panel's view model — assumes a shape this
|
|
296
|
+
// module wrote. A hand edit, an older schema or a half-recovered file has
|
|
297
|
+
// no such guarantee, and ONE of them used to brick the whole mission.
|
|
298
|
+
const missing = describeUnusableState(parsed);
|
|
299
|
+
if (missing) {
|
|
300
|
+
unreadableTasks.set(entry.name, missing);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
rows.push(parsed);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// Do not crash the whole loop over one bad file — but do NOT let it
|
|
307
|
+
// disappear either. A task silently missing from the review queue is
|
|
308
|
+
// indistinguishable from a task nobody has to look at.
|
|
309
|
+
unreadableTasks.set(entry.name, error instanceof Error ? error.message : String(error));
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
unreadableTasks.delete(entry.name);
|
|
313
|
+
}
|
|
314
|
+
// Defensive even after validation: a comparator must never be the thing that
|
|
315
|
+
// throws, because it runs inside every write.
|
|
316
|
+
return rows.sort((a, b) => String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")));
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* One task's own folder (state.json, brief.md, raws/). Exported so the control
|
|
320
|
+
* panel reads the brief from the same sanitized path the CLI writes it to.
|
|
321
|
+
*/
|
|
322
|
+
export function taskFolder(root, taskId) {
|
|
323
|
+
return taskDir(root, taskId);
|
|
324
|
+
}
|
|
325
|
+
/** One task's state. Throws when the task was never started. */
|
|
326
|
+
export function readTaskState(root, taskId) {
|
|
327
|
+
return readState(root, taskId);
|
|
328
|
+
}
|
|
329
|
+
/** Every task in the mission folder, oldest first. */
|
|
330
|
+
export function listTaskStates(root) {
|
|
331
|
+
return listStates(root);
|
|
332
|
+
}
|
|
333
|
+
/** The mission's saved run mode (ignores the per-call --mode flag). */
|
|
334
|
+
export function readMissionMode(root) {
|
|
335
|
+
return readRunMode(root);
|
|
336
|
+
}
|
|
337
|
+
/** LEDGER.jsonl, newest last. Unparseable lines are skipped, never fatal. */
|
|
338
|
+
export function readLedger(root) {
|
|
339
|
+
const file = path.join(root, "LEDGER.jsonl");
|
|
340
|
+
if (!existsSync(file))
|
|
341
|
+
return [];
|
|
342
|
+
const rows = [];
|
|
343
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
344
|
+
const text = line.trim();
|
|
345
|
+
if (!text)
|
|
346
|
+
continue;
|
|
347
|
+
try {
|
|
348
|
+
rows.push(JSON.parse(text));
|
|
165
349
|
}
|
|
166
350
|
catch {
|
|
167
|
-
/* a
|
|
351
|
+
/* a torn append is not worth failing the whole view over */
|
|
168
352
|
}
|
|
169
353
|
}
|
|
170
|
-
return rows
|
|
354
|
+
return rows;
|
|
171
355
|
}
|
|
172
356
|
function writeState(root, state) {
|
|
173
357
|
state.updated_at = new Date().toISOString();
|
|
174
358
|
const dir = taskDir(root, state.task_id);
|
|
175
359
|
mkdirSync(dir, { recursive: true });
|
|
176
360
|
const file = statePath(root, state.task_id);
|
|
177
|
-
|
|
361
|
+
// Write a sibling temp file and rename over the target. rename(2) is atomic
|
|
362
|
+
// within a directory, so a reader sees either the whole old file or the whole
|
|
363
|
+
// new one — never the half-written state that made a gated task vanish from
|
|
364
|
+
// the queue (listStates skips what it cannot parse).
|
|
365
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
366
|
+
writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
367
|
+
try {
|
|
368
|
+
renameSync(temp, file);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
// Windows returns EPERM/EBUSY when an indexer or antivirus has the target
|
|
372
|
+
// open. Retry once, then clean up rather than leaving a stray .tmp behind.
|
|
373
|
+
try {
|
|
374
|
+
renameSync(temp, file);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
try {
|
|
378
|
+
rmSync(temp, { force: true });
|
|
379
|
+
}
|
|
380
|
+
catch { /* nothing more to do */ }
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
178
384
|
// The human's list is a VIEW of the states, so regenerate it every time. A
|
|
179
385
|
// hand-maintained review queue goes stale the first time an agent forgets.
|
|
180
386
|
writeReviewQueue(root);
|
|
@@ -202,7 +408,7 @@ export function nextStage(stage) {
|
|
|
202
408
|
function completeStage(state, stage, ask) {
|
|
203
409
|
state.stage = stage;
|
|
204
410
|
if (isGated(state.mode, stage)) {
|
|
205
|
-
state.gate =
|
|
411
|
+
state.gate = makeGate(stage, ask);
|
|
206
412
|
note(state, `gate_opened:${stage}`);
|
|
207
413
|
return { gated: true };
|
|
208
414
|
}
|
|
@@ -214,13 +420,23 @@ function completeStage(state, stage, ask) {
|
|
|
214
420
|
// ── the human's list ─────────────────────────────────────────────────────────
|
|
215
421
|
function writeReviewQueue(root) {
|
|
216
422
|
const rows = listStates(root).filter((s) => s.gate);
|
|
423
|
+
const broken = Array.from(unreadableTasks.entries());
|
|
217
424
|
const lines = [
|
|
218
425
|
"# Review queue",
|
|
219
426
|
"",
|
|
220
427
|
"Regenerated by `vidfarm clipper-run`. One row per task that is WAITING FOR YOU.",
|
|
221
|
-
"
|
|
428
|
+
"**Easiest way to clear this list: `vidfarm dashboard`** — the clipper dashboard plays each cut,",
|
|
429
|
+
"shows the buyer's brief beside it, and answers the gate in one click.",
|
|
430
|
+
"",
|
|
431
|
+
"By hand: `vidfarm clipper-run approve <task-id>` · `… changes <task-id> --note \"…\"` · `… drop <task-id> --reason \"…\"`",
|
|
222
432
|
""
|
|
223
433
|
];
|
|
434
|
+
if (broken.length) {
|
|
435
|
+
lines.push(`> **${broken.length} task folder(s) could not be read and are NOT in this list.**`);
|
|
436
|
+
for (const [folder, why] of broken)
|
|
437
|
+
lines.push(`> - \`${folder}\` — ${why}`);
|
|
438
|
+
lines.push("");
|
|
439
|
+
}
|
|
224
440
|
if (!rows.length) {
|
|
225
441
|
lines.push("_Nothing waiting for you right now._");
|
|
226
442
|
}
|
|
@@ -228,7 +444,7 @@ function writeReviewQueue(root) {
|
|
|
228
444
|
lines.push(`## ${state.task_id} — waiting at \`${state.gate.stage}\``);
|
|
229
445
|
lines.push("");
|
|
230
446
|
lines.push(`- buyer machine: ${state.gig_id ?? "?"}${state.machine ? ` (${state.machine})` : ""}`);
|
|
231
|
-
lines.push(`- price: ${state.price ===
|
|
447
|
+
lines.push(`- price: ${typeof state.price === "number" ? `$${state.price.toFixed(2)}` : "TBD"} · mode: ${state.mode} · waiting since ${state.gate.since}`);
|
|
232
448
|
if (state.title)
|
|
233
449
|
lines.push(`- brief: ${state.title}`);
|
|
234
450
|
if (state.harness)
|
|
@@ -305,7 +521,295 @@ export function nextAction(state) {
|
|
|
305
521
|
return { who: "agent", what: "Nothing left on this task." };
|
|
306
522
|
}
|
|
307
523
|
}
|
|
524
|
+
// ── the transitions ──────────────────────────────────────────────────────────
|
|
525
|
+
// Every state change lives here, and BOTH front ends call these: the CLI verbs
|
|
526
|
+
// below, and the local control panel (`vidfarm panel`). Nothing else may write
|
|
527
|
+
// state.json. Two writers would let the terminal and the panel disagree about
|
|
528
|
+
// which gate is open, and the gate is the rail that stops an unreviewed cut.
|
|
529
|
+
/** Size in bytes, or null when the path is missing / not a file. */
|
|
530
|
+
function fileSizeOrNull(candidate) {
|
|
531
|
+
if (!candidate)
|
|
532
|
+
return null;
|
|
533
|
+
try {
|
|
534
|
+
const stat = statSync(candidate);
|
|
535
|
+
return stat.isFile() ? stat.size : null;
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** Answer an open gate with "yes". Throws when nothing is waiting. */
|
|
542
|
+
export function approveGate(root, taskId, noteText) {
|
|
543
|
+
const state = readState(root, taskId);
|
|
544
|
+
if (state.gate?.stage === "cut") {
|
|
545
|
+
// `reviewed_by_gigworker` is the record a buyer's trust score rests on. It
|
|
546
|
+
// may only be granted for something that can actually be watched.
|
|
547
|
+
const size = fileSizeOrNull(state.cut_path);
|
|
548
|
+
if (size === null) {
|
|
549
|
+
throw new ClipperUserError(`${state.task_id} has no cut on disk at ${state.cut_path ?? "(no path recorded)"} — there is nothing ` +
|
|
550
|
+
`to watch, so it cannot be approved. Request changes, or drop it.`);
|
|
551
|
+
}
|
|
552
|
+
if (size === 0) {
|
|
553
|
+
throw new ClipperUserError(`${state.task_id}'s cut is 0 bytes (${state.cut_path}) — the render produced nothing. ` +
|
|
554
|
+
`Request changes, or drop it.`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (!state.gate) {
|
|
558
|
+
throw new Error(`${state.task_id} is not waiting for anybody — it is at stage "${state.stage}". Run: vidfarm clipper-run status ${state.task_id}`);
|
|
559
|
+
}
|
|
560
|
+
const stage = state.gate.stage;
|
|
561
|
+
const at = new Date().toISOString();
|
|
562
|
+
note(state, `approved:${stage}`, noteText ?? null);
|
|
563
|
+
// Only the CUT gate is the "a human watched the video" record — that is the
|
|
564
|
+
// one the ledger and the buyer's trust score care about.
|
|
565
|
+
if (stage === "cut")
|
|
566
|
+
state.reviewed_by_gigworker = at;
|
|
567
|
+
state.gate = null;
|
|
568
|
+
state.stage = nextStage(stage);
|
|
569
|
+
writeState(root, state);
|
|
570
|
+
return { state, approved: stage };
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Undo the last decision: reopen the gate this task most recently stopped at.
|
|
574
|
+
*
|
|
575
|
+
* A reviewer working at speed WILL mis-click, and the first thing they reach
|
|
576
|
+
* for is undo. Without it the only escape is `changes` with a note the agent
|
|
577
|
+
* then tries to act on — which is how "undo" ends up in a task's history as an
|
|
578
|
+
* instruction to the model. A sent proof is the one thing this cannot take
|
|
579
|
+
* back; that is a withdrawal on the gig, not a local edit.
|
|
580
|
+
*/
|
|
581
|
+
export function reopenGate(root, taskId) {
|
|
582
|
+
const state = readState(root, taskId);
|
|
583
|
+
// The stage is the real signal: `done` means a proof went out, whether or not
|
|
584
|
+
// a proof id was recorded. Guarding on proof_id alone let a proofless
|
|
585
|
+
// `submitted` be reopened, re-approved and re-submitted — two ledger rows and
|
|
586
|
+
// a doubled "awaiting the buyer" figure for one task.
|
|
587
|
+
if (state.stage === "done" || state.proof_id) {
|
|
588
|
+
throw new Error(`${state.task_id} already went out${state.proof_id ? ` as proof ${state.proof_id}` : ""}. Undo is local — ` +
|
|
589
|
+
`it cannot unsend a proof. Withdraw it on the gig instead: vidfarm gigs withdraw ` +
|
|
590
|
+
`${state.gig_id ?? "<gig-id>"} ${state.proof_id ?? "<proof-id>"}`);
|
|
591
|
+
}
|
|
592
|
+
if (state.gate) {
|
|
593
|
+
throw new Error(`${state.task_id} is already waiting on you at "${state.gate.stage}" — there is nothing to undo.`);
|
|
594
|
+
}
|
|
595
|
+
let stage = null;
|
|
596
|
+
for (let i = state.history.length - 1; i >= 0; i -= 1) {
|
|
597
|
+
const match = /^gate_opened:(.+)$/.exec(state.history[i].event);
|
|
598
|
+
if (match) {
|
|
599
|
+
stage = match[1];
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (!stage) {
|
|
604
|
+
throw new Error(`${state.task_id} has never stopped for you, so there is no decision of yours to undo.`);
|
|
605
|
+
}
|
|
606
|
+
const wasDiscarded = state.stage === "discarded";
|
|
607
|
+
note(state, `undo:${stage}`);
|
|
608
|
+
// Unwind what answering that gate had recorded.
|
|
609
|
+
if (stage === "cut")
|
|
610
|
+
state.reviewed_by_gigworker = null;
|
|
611
|
+
if (stage === "shortlist") {
|
|
612
|
+
state.template_id = null;
|
|
613
|
+
state.template_picked_by = null;
|
|
614
|
+
}
|
|
615
|
+
state.stage = stage;
|
|
616
|
+
// The original wording is not kept in history, so the gate comes back with
|
|
617
|
+
// its standard question. The task's own history still shows what happened.
|
|
618
|
+
state.gate = makeGate(stage, null);
|
|
619
|
+
writeState(root, state);
|
|
620
|
+
// A drop wrote a ledger row, so the reversal writes one too — the ledger is
|
|
621
|
+
// append-only, and a rewritten past is worse than a longer one.
|
|
622
|
+
if (wasDiscarded) {
|
|
623
|
+
appendLedger(root, {
|
|
624
|
+
ts: new Date().toISOString(),
|
|
625
|
+
gig_id: state.gig_id,
|
|
626
|
+
task_id: state.task_id,
|
|
627
|
+
template_id: state.template_id,
|
|
628
|
+
run_mode: state.mode,
|
|
629
|
+
status: "reopened",
|
|
630
|
+
reason: `undo — back to the ${stage} gate`
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return { state, stage };
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* A task that has already gone to the buyer is closed to local edits. The rail
|
|
637
|
+
* lives in these transitions — NOT in the page — so every one of them has to
|
|
638
|
+
* say so, or the guard is only as good as the client that happens to call it.
|
|
639
|
+
*/
|
|
640
|
+
function assertNotAlreadySent(state, verb) {
|
|
641
|
+
if (state.stage !== "done" && !state.proof_id)
|
|
642
|
+
return;
|
|
643
|
+
throw new ClipperUserError(`${verb} ${state.task_id} is not possible — it already went out` +
|
|
644
|
+
`${state.proof_id ? ` as proof ${state.proof_id}` : ""}. Withdraw it on the gig instead: ` +
|
|
645
|
+
`vidfarm gigs withdraw ${state.gig_id ?? "<gig-id>"} ${state.proof_id ?? "<proof-id>"}`);
|
|
646
|
+
}
|
|
647
|
+
/** Send the work back with a note. The note is the whole point. */
|
|
648
|
+
export function requestChanges(root, taskId, noteText) {
|
|
649
|
+
const state = readState(root, taskId);
|
|
650
|
+
assertNotAlreadySent(state, "Requesting changes on");
|
|
651
|
+
const text = noteText.trim();
|
|
652
|
+
if (!text)
|
|
653
|
+
throw new Error('changes needs a note — say what to fix, or the agent redoes the same thing.');
|
|
654
|
+
const stage = state.gate?.stage ?? state.stage;
|
|
655
|
+
note(state, `changes_requested:${stage}`, text);
|
|
656
|
+
state.gate = null;
|
|
657
|
+
// Changing your mind after approving, but before the proof is sent, must
|
|
658
|
+
// WITHDRAW the approval. It used to leave the "a human watched and accepted
|
|
659
|
+
// this" stamp in place and the rail cleared, so the agent submitted anyway.
|
|
660
|
+
if (stage === "submit" || state.stage === "submit") {
|
|
661
|
+
state.reviewed_by_gigworker = null;
|
|
662
|
+
note(state, "approval_withdrawn");
|
|
663
|
+
}
|
|
664
|
+
// "None of these" means exactly that: clear the candidates so the next round
|
|
665
|
+
// cannot re-offer the ones just refused.
|
|
666
|
+
if (stage === "shortlist")
|
|
667
|
+
state.shortlist = [];
|
|
668
|
+
// Send it back to the stage that produced the thing being rejected, so the
|
|
669
|
+
// agent redoes THAT step rather than starting the task over. "not these
|
|
670
|
+
// templates" means shortlist again, which is the stage BEFORE the list.
|
|
671
|
+
state.stage = (stage === "cut" || stage === "submit")
|
|
672
|
+
? "build"
|
|
673
|
+
: stage === "shortlist" ? "claimed" : stage;
|
|
674
|
+
writeState(root, state);
|
|
675
|
+
return { state, from: stage };
|
|
676
|
+
}
|
|
677
|
+
/** Discard the task and log it. Never submit a cut the gigworker rejected. */
|
|
678
|
+
export function dropTask(root, taskId, reasonText) {
|
|
679
|
+
const state = readState(root, taskId);
|
|
680
|
+
// Dropping a task whose proof is with the buyer writes a "discarded" ledger
|
|
681
|
+
// row that overrides the "submitted" one, silently removing its price from
|
|
682
|
+
// the money the gigworker is owed.
|
|
683
|
+
assertNotAlreadySent(state, "Dropping");
|
|
684
|
+
const reason = reasonText?.trim() || "no reason given";
|
|
685
|
+
note(state, "discarded", reason);
|
|
686
|
+
state.gate = null;
|
|
687
|
+
state.stage = "discarded";
|
|
688
|
+
writeState(root, state);
|
|
689
|
+
appendLedger(root, {
|
|
690
|
+
ts: new Date().toISOString(),
|
|
691
|
+
gig_id: state.gig_id,
|
|
692
|
+
task_id: state.task_id,
|
|
693
|
+
template_id: state.template_id,
|
|
694
|
+
run_mode: state.mode,
|
|
695
|
+
status: "discarded",
|
|
696
|
+
reason
|
|
697
|
+
});
|
|
698
|
+
return { state, reason };
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Choose the template to adapt. A pick IS the answer to the shortlist gate, so
|
|
702
|
+
* it closes it — the gigworker never has to both pick and approve.
|
|
703
|
+
*/
|
|
704
|
+
export function pickTemplate(root, taskId, templateId, by) {
|
|
705
|
+
const state = readState(root, taskId);
|
|
706
|
+
// With no id at all, take the top of the shortlist. That is the auto-mode
|
|
707
|
+
// shape — the agent shortlisted best-first, so "pick" means "take your own
|
|
708
|
+
// first choice" — and it saves a weak model from re-typing an id it just wrote.
|
|
709
|
+
const chosen = templateId?.trim() || state.shortlist[0]?.template_id;
|
|
710
|
+
if (!chosen) {
|
|
711
|
+
throw new Error("pick needs a template id (or a shortlist to take the top of): vidfarm clipper-run pick <task-id> template_019…");
|
|
712
|
+
}
|
|
713
|
+
assertNotAlreadySent(state, "Picking a template for");
|
|
714
|
+
const wasGated = state.gate?.stage === "shortlist";
|
|
715
|
+
if (state.gate && !wasGated) {
|
|
716
|
+
throw new Error(`${state.task_id} is waiting on the gigworker at "${state.gate.stage}", not at the shortlist. ` +
|
|
717
|
+
`Picking a template here would delete that review request and rewind the task.`);
|
|
718
|
+
}
|
|
719
|
+
state.template_id = String(chosen);
|
|
720
|
+
state.template_picked_by = normalizeWho(by) ?? (wasGated ? "human" : "agent");
|
|
721
|
+
note(state, "template_picked", `${state.template_id} by ${state.template_picked_by}`);
|
|
722
|
+
state.gate = null;
|
|
723
|
+
state.stage = "plan";
|
|
724
|
+
writeState(root, state);
|
|
725
|
+
return { state };
|
|
726
|
+
}
|
|
727
|
+
/** Close the row once the proof is on the wire, and append the ledger. */
|
|
728
|
+
export function markSubmitted(root, taskId, proofId) {
|
|
729
|
+
const state = readState(root, taskId);
|
|
730
|
+
// Closing the row is a RECORD that the proof went out — it is not a decision,
|
|
731
|
+
// and it must never be able to answer a question the human was asked. This
|
|
732
|
+
// used to clear `gate` unconditionally, which turned a HOLD into "cleared to
|
|
733
|
+
// submit" and wrote a ledger row with reviewed_by_gigworker: null.
|
|
734
|
+
if (state.gate) {
|
|
735
|
+
throw new Error(`${state.task_id} is still waiting on the gigworker at "${state.gate.stage}" — answer that first ` +
|
|
736
|
+
`(vidfarm clipper-run approve ${state.task_id}). "submitted" records a proof; it cannot close a gate.`);
|
|
737
|
+
}
|
|
738
|
+
if (state.stage !== "submit" && state.stage !== "done") {
|
|
739
|
+
throw new Error(`${state.task_id} is at stage "${state.stage}", not "submit" — nothing has been cleared to send yet.`);
|
|
740
|
+
}
|
|
741
|
+
state.proof_id = proofId ?? null;
|
|
742
|
+
note(state, "submitted", state.proof_id);
|
|
743
|
+
state.gate = null;
|
|
744
|
+
state.stage = "done";
|
|
745
|
+
writeState(root, state);
|
|
746
|
+
appendLedger(root, {
|
|
747
|
+
ts: new Date().toISOString(),
|
|
748
|
+
gig_id: state.gig_id,
|
|
749
|
+
task_id: state.task_id,
|
|
750
|
+
proof_id: state.proof_id,
|
|
751
|
+
template_id: state.template_id,
|
|
752
|
+
harness: state.harness,
|
|
753
|
+
run_mode: state.mode,
|
|
754
|
+
locked_price: state.price,
|
|
755
|
+
status: "submitted",
|
|
756
|
+
reviewed_by_gigworker: state.reviewed_by_gigworker ?? (state.mode === "auto-submit" ? "auto_submit" : null),
|
|
757
|
+
paid_out_at: null
|
|
758
|
+
});
|
|
759
|
+
return { state };
|
|
760
|
+
}
|
|
761
|
+
/** Add a free-text note to a task's history. */
|
|
762
|
+
export function addTaskNote(root, taskId, text) {
|
|
763
|
+
const state = readState(root, taskId);
|
|
764
|
+
const trimmed = text.trim();
|
|
765
|
+
if (!trimmed)
|
|
766
|
+
throw new Error('note needs some text: vidfarm clipper-run note <task-id> "buyer wants the logo bigger"');
|
|
767
|
+
note(state, "note", trimmed);
|
|
768
|
+
writeState(root, state);
|
|
769
|
+
return { state };
|
|
770
|
+
}
|
|
771
|
+
/** Save the mission's run mode (run.json + the two MISSION.md lines). */
|
|
772
|
+
export function setRunMode(root, mode, noteText) {
|
|
773
|
+
const savedAt = new Date().toISOString();
|
|
774
|
+
mkdirSync(root, { recursive: true });
|
|
775
|
+
const payload = { mode, note: noteText ?? null, savedAt };
|
|
776
|
+
const file = path.join(root, "run.json");
|
|
777
|
+
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
778
|
+
// MISSION.md is the file a human reads, so keep its two mode lines honest —
|
|
779
|
+
// but only rewrite lines that already exist. This never invents a mission.
|
|
780
|
+
const missionFile = path.join(root, "MISSION.md");
|
|
781
|
+
if (existsSync(missionFile)) {
|
|
782
|
+
const before = readFileSync(missionFile, "utf8");
|
|
783
|
+
const after = before
|
|
784
|
+
.replace(/^run_mode:.*$/m, `run_mode: ${mode}`)
|
|
785
|
+
.replace(/^auto_submit:.*$/m, `auto_submit: ${mode === "auto-submit"}`);
|
|
786
|
+
if (after !== before)
|
|
787
|
+
writeFileSync(missionFile, after, "utf8");
|
|
788
|
+
}
|
|
789
|
+
return { mode, file, savedAt };
|
|
790
|
+
}
|
|
308
791
|
// ── output helpers ───────────────────────────────────────────────────────────
|
|
792
|
+
/**
|
|
793
|
+
* Print anything the mission could not read. The panel already surfaced this;
|
|
794
|
+
* the CLI did not — and the CLI is what the agent loop reads, so a gated task
|
|
795
|
+
* could disappear from `next`, `status`, `review` and REVIEW_QUEUE.md at once.
|
|
796
|
+
*/
|
|
797
|
+
function warnUnreadable(root) {
|
|
798
|
+
if (unreadableTasks.size) {
|
|
799
|
+
console.log("");
|
|
800
|
+
console.log(`${YELLOW}${unreadableTasks.size} task folder(s) could not be read — they are NOT in the list above${RESET}`);
|
|
801
|
+
for (const [folder, why] of unreadableTasks) {
|
|
802
|
+
console.log(` ${BOLD}${folder}${RESET} ${DIM}${why}${RESET}`);
|
|
803
|
+
}
|
|
804
|
+
console.log(` ${DIM}Fix or delete ${path.join(root, "tasks", "<folder>", "state.json")} — a task nobody can read is a task nobody reviews.${RESET}`);
|
|
805
|
+
}
|
|
806
|
+
const mode = readRunMode(root);
|
|
807
|
+
if (mode.unreadable) {
|
|
808
|
+
console.log("");
|
|
809
|
+
console.log(`${YELLOW}${mode.unreadable}${RESET}`);
|
|
810
|
+
console.log(` ${DIM}Falling back to ${mode.mode}. Set it deliberately: vidfarm clipper-run mode <${RUN_MODES.join("|")}>${RESET}`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
309
813
|
function out(json, value, human) {
|
|
310
814
|
if (json) {
|
|
311
815
|
console.log(JSON.stringify(value, null, 2));
|
|
@@ -315,7 +819,7 @@ function out(json, value, human) {
|
|
|
315
819
|
}
|
|
316
820
|
function stageLine(state) {
|
|
317
821
|
const gate = state.gate ? `${YELLOW}WAITING ON THE GIGWORKER${RESET} at ${BOLD}${state.gate.stage}${RESET}` : `stage ${BOLD}${state.stage}${RESET}`;
|
|
318
|
-
const price = state.price ===
|
|
822
|
+
const price = typeof state.price === "number" ? `$${state.price.toFixed(2)}` : "price TBD";
|
|
319
823
|
return ` ${BOLD}${state.task_id}${RESET} ${gate} ${DIM}${state.mode} · ${price}${state.gig_id ? " · " + state.gig_id : ""}${RESET}`;
|
|
320
824
|
}
|
|
321
825
|
// ── commands ─────────────────────────────────────────────────────────────────
|
|
@@ -324,6 +828,7 @@ const MISSION_TEMPLATE = (mode) => `# Clipper mission
|
|
|
324
828
|
payout_wallet: 0xYOUR_BASE_L2_ADDRESS # Base L2, receives USDC
|
|
325
829
|
cash_out: GCash via https://officex.short.gy/solana-usdc-gcash
|
|
326
830
|
cost_mode: minimize # profit = price - spend
|
|
831
|
+
dashboard: ask # ask | yes | no — open the clipper dashboard for reviews?
|
|
327
832
|
run_mode: ${mode} # full-interactive | quick-interactive | auto-batch | auto-submit
|
|
328
833
|
auto_submit: ${mode === "auto-submit"} # only the gigworker may set this true, in words, on purpose
|
|
329
834
|
specialities: # sticker explainers, meme recaptions, greenscreen reaction
|
|
@@ -334,7 +839,8 @@ cycle_every: 30 minutes
|
|
|
334
839
|
stop_if: 2 rejections in a row · any gig warning about funds · 3 failed renders · human says stop
|
|
335
840
|
`;
|
|
336
841
|
function cmdInit(root, values) {
|
|
337
|
-
const
|
|
842
|
+
const resolved = resolveMode(root, values);
|
|
843
|
+
const mode = resolved.mode;
|
|
338
844
|
mkdirSync(tasksDir(root), { recursive: true });
|
|
339
845
|
const created = [];
|
|
340
846
|
const seed = (name, contents) => {
|
|
@@ -350,6 +856,17 @@ function cmdInit(root, values) {
|
|
|
350
856
|
seed("NOTES.md", "# Notes\n\nWhat each buyer approved, what they rejected and with which tag, prices that moved.\n");
|
|
351
857
|
seed("EARNINGS.md", "# Earnings\n\nsubmitted / approved / PAID / owed, with dates. `approved` is not `paid`.\n");
|
|
352
858
|
seed("LEDGER.jsonl", "");
|
|
859
|
+
// The anti-repeat record. LEDGER.jsonl knows the money; this knows the IDEA —
|
|
860
|
+
// which clip, which hook, which pain went to which buyer. Read it before you
|
|
861
|
+
// shortlist, or you sell one buyer the same video twice.
|
|
862
|
+
seed("DELIVERED.tsv", "# gig_id\tproof_id\tharness\ttemplate_id\tsource_clip\thook\tpain\tverdict\n");
|
|
863
|
+
// `init --mode auto-batch` used to write the mode into MISSION.md and nowhere
|
|
864
|
+
// else, so run.json stayed unset and `clipper-run mode` reported the default
|
|
865
|
+
// — the human's file and the machine's file disagreed. Persist a mode that
|
|
866
|
+
// was actually ASKED for; never persist the assumed default (that stays an
|
|
867
|
+
// open question until the gigworker answers it).
|
|
868
|
+
if (resolved.isSet)
|
|
869
|
+
setRunMode(root, mode, values.note);
|
|
353
870
|
writeReviewQueue(root);
|
|
354
871
|
out(Boolean(values.json), { ok: true, root, mode, created }, () => {
|
|
355
872
|
console.log(`${GREEN}✓${RESET} Mission folder ready at ${BOLD}${root}${RESET}`);
|
|
@@ -357,6 +874,9 @@ function cmdInit(root, values) {
|
|
|
357
874
|
console.log(` ${DIM}${file}${RESET}`);
|
|
358
875
|
console.log("");
|
|
359
876
|
console.log(`${DIM}Run mode: ${BOLD}${mode}${RESET}${DIM} — change it with: vidfarm clipper-run mode <${RUN_MODES.join("|")}>${RESET}`);
|
|
877
|
+
console.log("");
|
|
878
|
+
console.log(`${BOLD}Ask the gigworker whether to open the clipper dashboard${RESET}${DIM} — recommended, and the answer goes in MISSION.md \`dashboard:\`.${RESET}`);
|
|
879
|
+
console.log(` ${BOLD}vidfarm dashboard${RESET}${DIM} the queue as a page: the cut plays, the brief sits beside it, one click answers the gate.${RESET}`);
|
|
360
880
|
});
|
|
361
881
|
}
|
|
362
882
|
function cmdMode(root, values, positionals) {
|
|
@@ -366,22 +886,7 @@ function cmdMode(root, values, positionals) {
|
|
|
366
886
|
const mode = normalizeRunMode(requested);
|
|
367
887
|
if (!mode)
|
|
368
888
|
throw new Error(`Unknown run mode "${requested}". Choose one of: ${RUN_MODES.join(", ")}.`);
|
|
369
|
-
const savedAt =
|
|
370
|
-
mkdirSync(root, { recursive: true });
|
|
371
|
-
const payload = { mode, note: values.note ?? null, savedAt };
|
|
372
|
-
const file = path.join(root, "run.json");
|
|
373
|
-
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
374
|
-
// MISSION.md is the file a human reads, so keep its two mode lines honest —
|
|
375
|
-
// but only rewrite lines that already exist. This never invents a mission.
|
|
376
|
-
const missionFile = path.join(root, "MISSION.md");
|
|
377
|
-
if (existsSync(missionFile)) {
|
|
378
|
-
const before = readFileSync(missionFile, "utf8");
|
|
379
|
-
const after = before
|
|
380
|
-
.replace(/^run_mode:.*$/m, `run_mode: ${mode}`)
|
|
381
|
-
.replace(/^auto_submit:.*$/m, `auto_submit: ${mode === "auto-submit"}`);
|
|
382
|
-
if (after !== before)
|
|
383
|
-
writeFileSync(missionFile, after, "utf8");
|
|
384
|
-
}
|
|
889
|
+
const { file, savedAt } = setRunMode(root, mode, values.note);
|
|
385
890
|
out(json, { ok: true, run_mode: mode, saved_at: savedAt, file, gates: MODE_GATES[mode] }, () => {
|
|
386
891
|
console.log(`${GREEN}${BOLD}Run mode: ${mode}${RESET}`);
|
|
387
892
|
console.log(`${DIM}${MODE_BLURB[mode]}${RESET}`);
|
|
@@ -404,11 +909,26 @@ function cmdMode(root, values, positionals) {
|
|
|
404
909
|
}
|
|
405
910
|
console.log("");
|
|
406
911
|
console.log(`${DIM}Set it: ${BOLD}vidfarm clipper-run mode <${RUN_MODES.join("|")}>${RESET}`);
|
|
407
|
-
|
|
912
|
+
const stored = readRunMode(root);
|
|
913
|
+
if (stored.unreadable) {
|
|
914
|
+
console.log(`${YELLOW}${stored.unreadable}${RESET}`);
|
|
915
|
+
console.log(`${DIM}A mode WAS saved but cannot be read — this is a fallback, not a fresh mission.${RESET}`);
|
|
916
|
+
}
|
|
917
|
+
else if (!resolved.isSet) {
|
|
408
918
|
console.log(`${DIM}Nothing saved yet — ASK the gigworker which one they want before the first task.${RESET}`);
|
|
409
919
|
}
|
|
410
920
|
});
|
|
411
921
|
}
|
|
922
|
+
/** A price the caller typed wrong is a silent $0 in the ledger otherwise. */
|
|
923
|
+
function parsePrice(value) {
|
|
924
|
+
if (value === undefined || value === null || value === "")
|
|
925
|
+
return null;
|
|
926
|
+
const parsed = numberOrNull(value);
|
|
927
|
+
if (parsed === null) {
|
|
928
|
+
throw new ClipperUserError(`--price "${String(value)}" is not a number. Give it as e.g. --price 4.50, or leave it out.`);
|
|
929
|
+
}
|
|
930
|
+
return parsed;
|
|
931
|
+
}
|
|
412
932
|
function numberOrNull(value) {
|
|
413
933
|
const parsed = typeof value === "string" ? Number(value) : typeof value === "number" ? value : NaN;
|
|
414
934
|
return Number.isFinite(parsed) ? parsed : null;
|
|
@@ -420,12 +940,23 @@ function cmdStart(root, values, positionals) {
|
|
|
420
940
|
const mode = resolveMode(root, values).mode;
|
|
421
941
|
const now = new Date().toISOString();
|
|
422
942
|
const dir = taskDir(root, taskId);
|
|
943
|
+
// Starting an id that already exists used to overwrite a live task back to
|
|
944
|
+
// `claimed`, taking its history and its review record with it.
|
|
945
|
+
if (existsSync(path.join(dir, "state.json")) && values.force) {
|
|
946
|
+
const doomed = readState(root, taskId);
|
|
947
|
+
console.log(`${YELLOW}--force: overwriting ${taskId}${RESET}${DIM} (was at "${doomed.stage}"${doomed.gate ? `, waiting on you at "${doomed.gate.stage}"` : ""}, ${doomed.history.length} history entr(ies)) — that record is gone.${RESET}`);
|
|
948
|
+
}
|
|
949
|
+
if (existsSync(path.join(dir, "state.json")) && !values.force) {
|
|
950
|
+
const existing = readState(root, taskId);
|
|
951
|
+
throw new Error(`${taskId} already exists (stage "${existing.stage}"${existing.gate ? `, waiting at "${existing.gate.stage}"` : ""}). ` +
|
|
952
|
+
`Starting it again would erase its history. Use a different id, or --force to overwrite on purpose.`);
|
|
953
|
+
}
|
|
423
954
|
mkdirSync(path.join(dir, "raws"), { recursive: true });
|
|
424
955
|
const state = {
|
|
425
|
-
task_id: taskId,
|
|
956
|
+
task_id: assertUsableTaskId(taskId),
|
|
426
957
|
gig_id: values.gig ?? null,
|
|
427
958
|
machine: values.machine ?? null,
|
|
428
|
-
price:
|
|
959
|
+
price: parsePrice(values.price),
|
|
429
960
|
title: values.title ?? null,
|
|
430
961
|
harness: values.harness ?? null,
|
|
431
962
|
mode,
|
|
@@ -473,9 +1004,7 @@ function cmdShortlist(root, values, positionals) {
|
|
|
473
1004
|
// task waits at `shortlist` for a `pick` either way — only the gate differs.
|
|
474
1005
|
state.stage = "shortlist";
|
|
475
1006
|
const gated = isGated(state.mode, "shortlist");
|
|
476
|
-
state.gate = gated
|
|
477
|
-
? { stage: "shortlist", ask: (values.ask ?? "").trim() || DEFAULT_ASK.shortlist, since: new Date().toISOString() }
|
|
478
|
-
: null;
|
|
1007
|
+
state.gate = gated ? makeGate("shortlist", values.ask) : null;
|
|
479
1008
|
if (gated)
|
|
480
1009
|
note(state, "gate_opened:shortlist");
|
|
481
1010
|
writeState(root, state);
|
|
@@ -495,24 +1024,8 @@ function cmdShortlist(root, values, positionals) {
|
|
|
495
1024
|
});
|
|
496
1025
|
}
|
|
497
1026
|
function cmdPick(root, values, positionals) {
|
|
498
|
-
const state = readState(root, requireTask(positionals[0], "pick"));
|
|
499
1027
|
const flagged = [].concat(values.template ?? [])[0];
|
|
500
|
-
|
|
501
|
-
// shape — the agent shortlisted best-first, so "pick" means "take your own
|
|
502
|
-
// first choice" — and it saves a weak model from re-typing an id it just wrote.
|
|
503
|
-
const templateId = positionals[1] ?? flagged ?? state.shortlist[0]?.template_id;
|
|
504
|
-
if (!templateId) {
|
|
505
|
-
throw new Error("clipper-run pick needs a template id (or a shortlist to take the top of): vidfarm clipper-run pick <task-id> template_019…");
|
|
506
|
-
}
|
|
507
|
-
// A pick IS the answer to the shortlist gate, so it closes it. That saves the
|
|
508
|
-
// gigworker from having to both pick and approve.
|
|
509
|
-
const wasGated = state.gate?.stage === "shortlist";
|
|
510
|
-
state.template_id = String(templateId);
|
|
511
|
-
state.template_picked_by = (normalizeWho(values.by) ?? (wasGated ? "human" : "agent"));
|
|
512
|
-
note(state, "template_picked", `${state.template_id} by ${state.template_picked_by}`);
|
|
513
|
-
state.gate = null;
|
|
514
|
-
state.stage = "plan";
|
|
515
|
-
writeState(root, state);
|
|
1028
|
+
const { state } = pickTemplate(root, requireTask(positionals[0], "pick"), positionals[1] ?? flagged, values.by);
|
|
516
1029
|
out(Boolean(values.json), { ok: true, state, next: nextAction(state) }, () => {
|
|
517
1030
|
console.log(`${GREEN}✓${RESET} ${BOLD}${state.task_id}${RESET} will adapt ${BOLD}${state.template_id}${RESET} ${DIM}(chosen by the ${state.template_picked_by})${RESET}`);
|
|
518
1031
|
const next = nextAction(state);
|
|
@@ -581,6 +1094,14 @@ function cmdCut(root, values, positionals) {
|
|
|
581
1094
|
state.clean_master = path.resolve(String(values["clean-master"]));
|
|
582
1095
|
if (!state.cut_path)
|
|
583
1096
|
throw new Error("clipper-run cut needs --file <path to the WATERMARKED cut>.");
|
|
1097
|
+
const recordedSize = fileSizeOrNull(state.cut_path);
|
|
1098
|
+
if (recordedSize === null) {
|
|
1099
|
+
throw new ClipperUserError(`No file at ${state.cut_path}. Record the cut only once the render has finished — a gate opened on a ` +
|
|
1100
|
+
`path that does not exist wastes the gigworker's trip to the queue.`);
|
|
1101
|
+
}
|
|
1102
|
+
if (recordedSize === 0) {
|
|
1103
|
+
throw new ClipperUserError(`${state.cut_path} is 0 bytes — the render produced nothing. Re-render before recording it.`);
|
|
1104
|
+
}
|
|
584
1105
|
note(state, "cut_ready", state.cut_path);
|
|
585
1106
|
const { gated } = completeStage(state, "cut", values.ask);
|
|
586
1107
|
writeState(root, state);
|
|
@@ -593,7 +1114,7 @@ function cmdCut(root, values, positionals) {
|
|
|
593
1114
|
console.log("");
|
|
594
1115
|
console.log(`${YELLOW}STOP — the gigworker watches this before it is submitted.${RESET}`);
|
|
595
1116
|
console.log(`${DIM}${state.gate.ask}${RESET}`);
|
|
596
|
-
console.log(`${DIM}Play it: vidfarm
|
|
1117
|
+
console.log(`${DIM}Play it in the clipper dashboard: ${BOLD}vidfarm dashboard${RESET}${DIM} · fast look: vidfarm stills ${state.cut_path}${RESET}`);
|
|
597
1118
|
console.log(`${DIM}They answer: vidfarm clipper-run approve ${state.task_id} · changes … --note "…" · drop … --reason "…"${RESET}`);
|
|
598
1119
|
}
|
|
599
1120
|
else {
|
|
@@ -604,19 +1125,7 @@ function cmdCut(root, values, positionals) {
|
|
|
604
1125
|
});
|
|
605
1126
|
}
|
|
606
1127
|
function cmdApprove(root, values, positionals) {
|
|
607
|
-
const state =
|
|
608
|
-
if (!state.gate)
|
|
609
|
-
throw new Error(`${state.task_id} is not waiting for anybody — it is at stage "${state.stage}". Run: vidfarm clipper-run status ${state.task_id}`);
|
|
610
|
-
const stage = state.gate.stage;
|
|
611
|
-
const at = new Date().toISOString();
|
|
612
|
-
note(state, `approved:${stage}`, values.note ?? null);
|
|
613
|
-
// Only the CUT gate is the "a human watched the video" record — that is the
|
|
614
|
-
// one the ledger and the buyer's trust score care about.
|
|
615
|
-
if (stage === "cut")
|
|
616
|
-
state.reviewed_by_gigworker = at;
|
|
617
|
-
state.gate = null;
|
|
618
|
-
state.stage = nextStage(stage);
|
|
619
|
-
writeState(root, state);
|
|
1128
|
+
const { state, approved: stage } = approveGate(root, requireTask(positionals[0], "approve"), values.note);
|
|
620
1129
|
out(Boolean(values.json), { ok: true, approved: stage, state, next: nextAction(state) }, () => {
|
|
621
1130
|
console.log(`${GREEN}✓${RESET} ${BOLD}${state.task_id}${RESET} approved at ${BOLD}${stage}${RESET} → stage ${BOLD}${state.stage}${RESET}`);
|
|
622
1131
|
const next = nextAction(state);
|
|
@@ -624,18 +1133,10 @@ function cmdApprove(root, values, positionals) {
|
|
|
624
1133
|
});
|
|
625
1134
|
}
|
|
626
1135
|
function cmdChanges(root, values, positionals) {
|
|
627
|
-
const state = readState(root, requireTask(positionals[0], "changes"));
|
|
628
1136
|
const text = values.note?.trim();
|
|
629
1137
|
if (!text)
|
|
630
1138
|
throw new Error('clipper-run changes needs --note "<what to fix>" — that note is the whole point.');
|
|
631
|
-
const stage =
|
|
632
|
-
note(state, `changes_requested:${stage}`, text);
|
|
633
|
-
state.gate = null;
|
|
634
|
-
// Send it back to the stage that produced the thing being rejected, so the
|
|
635
|
-
// agent redoes THAT step rather than starting the task over. "not these
|
|
636
|
-
// templates" means shortlist again, which is the stage BEFORE the list.
|
|
637
|
-
state.stage = stage === "cut" ? "build" : stage === "shortlist" ? "claimed" : stage;
|
|
638
|
-
writeState(root, state);
|
|
1139
|
+
const { state, from: stage } = requestChanges(root, requireTask(positionals[0], "changes"), text);
|
|
639
1140
|
out(Boolean(values.json), { ok: true, state, next: nextAction(state) }, () => {
|
|
640
1141
|
console.log(`${GREEN}✓${RESET} Changes recorded on ${BOLD}${state.task_id}${RESET} at ${BOLD}${stage}${RESET} → back to ${BOLD}${state.stage}${RESET}`);
|
|
641
1142
|
console.log(` ${DIM}${text}${RESET}`);
|
|
@@ -643,46 +1144,14 @@ function cmdChanges(root, values, positionals) {
|
|
|
643
1144
|
});
|
|
644
1145
|
}
|
|
645
1146
|
function cmdDrop(root, values, positionals) {
|
|
646
|
-
const state =
|
|
647
|
-
const reason = values.reason?.trim() || "no reason given";
|
|
648
|
-
note(state, "discarded", reason);
|
|
649
|
-
state.gate = null;
|
|
650
|
-
state.stage = "discarded";
|
|
651
|
-
writeState(root, state);
|
|
652
|
-
appendLedger(root, {
|
|
653
|
-
ts: new Date().toISOString(),
|
|
654
|
-
gig_id: state.gig_id,
|
|
655
|
-
task_id: state.task_id,
|
|
656
|
-
template_id: state.template_id,
|
|
657
|
-
run_mode: state.mode,
|
|
658
|
-
status: "discarded",
|
|
659
|
-
reason
|
|
660
|
-
});
|
|
1147
|
+
const { state, reason } = dropTask(root, requireTask(positionals[0], "drop"), values.reason);
|
|
661
1148
|
out(Boolean(values.json), { ok: true, state }, () => {
|
|
662
1149
|
console.log(`${GREEN}✓${RESET} Dropped ${BOLD}${state.task_id}${RESET} — ${reason}`);
|
|
663
1150
|
console.log(` ${DIM}Logged to LEDGER.jsonl. On Custom Requests, release it or let it expire — never submit a cut the gigworker rejected.${RESET}`);
|
|
664
1151
|
});
|
|
665
1152
|
}
|
|
666
1153
|
function cmdSubmitted(root, values, positionals) {
|
|
667
|
-
const state =
|
|
668
|
-
state.proof_id = values.proof ?? null;
|
|
669
|
-
note(state, "submitted", state.proof_id);
|
|
670
|
-
state.gate = null;
|
|
671
|
-
state.stage = "done";
|
|
672
|
-
writeState(root, state);
|
|
673
|
-
appendLedger(root, {
|
|
674
|
-
ts: new Date().toISOString(),
|
|
675
|
-
gig_id: state.gig_id,
|
|
676
|
-
task_id: state.task_id,
|
|
677
|
-
proof_id: state.proof_id,
|
|
678
|
-
template_id: state.template_id,
|
|
679
|
-
harness: state.harness,
|
|
680
|
-
run_mode: state.mode,
|
|
681
|
-
locked_price: state.price,
|
|
682
|
-
status: "submitted",
|
|
683
|
-
reviewed_by_gigworker: state.reviewed_by_gigworker ?? (state.mode === "auto-submit" ? "auto_submit" : null),
|
|
684
|
-
paid_out_at: null
|
|
685
|
-
});
|
|
1154
|
+
const { state } = markSubmitted(root, requireTask(positionals[0], "submitted"), values.proof);
|
|
686
1155
|
out(Boolean(values.json), { ok: true, state }, () => {
|
|
687
1156
|
console.log(`${GREEN}✓${RESET} ${BOLD}${state.task_id}${RESET} closed as submitted${state.proof_id ? ` ${DIM}(${state.proof_id})${RESET}` : ""}`);
|
|
688
1157
|
console.log(` ${DIM}Ledger row appended. approved is not paid — reconcile with: vidfarm gigs proof ${state.gig_id ?? "<gig>"} ${state.proof_id ?? "<proof-id>"}${RESET}`);
|
|
@@ -722,8 +1191,9 @@ function cmdStatus(root, values, positionals) {
|
|
|
722
1191
|
const waiting = open.filter((s) => s.gate).length;
|
|
723
1192
|
console.log("");
|
|
724
1193
|
console.log(waiting
|
|
725
|
-
? `${YELLOW}${waiting} task(s) waiting on the gigworker${RESET}${DIM} — the
|
|
1194
|
+
? `${YELLOW}${waiting} task(s) waiting on the gigworker${RESET}${DIM} — answer them in the clipper dashboard: ${BOLD}vidfarm dashboard${RESET}${DIM} (or ${path.join(root, "REVIEW_QUEUE.md")})${RESET}`
|
|
726
1195
|
: `${DIM}Nothing waiting on the gigworker.${RESET}`);
|
|
1196
|
+
warnUnreadable(root);
|
|
727
1197
|
});
|
|
728
1198
|
}
|
|
729
1199
|
function cmdNext(root, values) {
|
|
@@ -753,12 +1223,13 @@ function cmdNext(root, values) {
|
|
|
753
1223
|
}
|
|
754
1224
|
if (waiting.length) {
|
|
755
1225
|
console.log("");
|
|
756
|
-
console.log(`${YELLOW}Waiting on the gigworker — do NOT work around these${RESET}`);
|
|
1226
|
+
console.log(`${YELLOW}Waiting on the gigworker — do NOT work around these${RESET}${DIM} (they answer fastest in the clipper dashboard: ${BOLD}vidfarm dashboard${RESET}${DIM})${RESET}`);
|
|
757
1227
|
for (const state of waiting) {
|
|
758
1228
|
console.log(` ${BOLD}${state.task_id}${RESET} ${DIM}at ${state.gate.stage} since ${state.gate.since}${RESET}`);
|
|
759
1229
|
console.log(` ${DIM}${state.gate.ask}${RESET}`);
|
|
760
1230
|
}
|
|
761
1231
|
}
|
|
1232
|
+
warnUnreadable(root);
|
|
762
1233
|
});
|
|
763
1234
|
}
|
|
764
1235
|
function cmdReview(root, values) {
|
|
@@ -767,12 +1238,13 @@ function cmdReview(root, values) {
|
|
|
767
1238
|
out(Boolean(values.json), { ok: true, waiting: rows }, () => {
|
|
768
1239
|
if (!rows.length) {
|
|
769
1240
|
console.log(`${DIM}Nothing waiting for the gigworker.${RESET}`);
|
|
1241
|
+
warnUnreadable(root);
|
|
770
1242
|
return;
|
|
771
1243
|
}
|
|
772
|
-
console.log(`${BOLD}${rows.length} task(s) waiting for you${RESET} ${DIM}
|
|
1244
|
+
console.log(`${BOLD}${rows.length} task(s) waiting for you${RESET} ${DIM}— the clipper dashboard plays each cut and answers the gate in one click: ${BOLD}vidfarm dashboard${RESET}`);
|
|
773
1245
|
for (const state of rows) {
|
|
774
1246
|
console.log("");
|
|
775
|
-
console.log(` ${BOLD}${state.task_id}${RESET} ${DIM}${state.gate.stage} · ${state.mode}${state.price ===
|
|
1247
|
+
console.log(` ${BOLD}${state.task_id}${RESET} ${DIM}${state.gate.stage} · ${state.mode}${typeof state.price === "number" ? ` · $${state.price.toFixed(2)}` : ""}${RESET}`);
|
|
776
1248
|
if (state.gate.stage === "shortlist") {
|
|
777
1249
|
for (const row of state.shortlist)
|
|
778
1250
|
console.log(` ${BOLD}${row.template_id}${RESET}${row.why ? ` ${DIM}— ${row.why}${RESET}` : ""}`);
|
|
@@ -786,6 +1258,7 @@ function cmdReview(root, values) {
|
|
|
786
1258
|
console.log(` ${DIM}${state.gate.ask}${RESET}`);
|
|
787
1259
|
console.log(` ${DIM}vidfarm clipper-run approve ${state.task_id}${state.gate.stage === "shortlist" ? ` (or: pick ${state.task_id} <template_id>)` : ""}${RESET}`);
|
|
788
1260
|
}
|
|
1261
|
+
warnUnreadable(root);
|
|
789
1262
|
});
|
|
790
1263
|
}
|
|
791
1264
|
/**
|
|
@@ -805,7 +1278,16 @@ function cmdGate(root, values, positionals) {
|
|
|
805
1278
|
reviewed_by_gigworker: state.reviewed_by_gigworker
|
|
806
1279
|
}, () => {
|
|
807
1280
|
if (!blocked) {
|
|
808
|
-
|
|
1281
|
+
const provenance = state.reviewed_by_gigworker
|
|
1282
|
+
? `${DIM}(gigworker approved the cut at ${state.reviewed_by_gigworker})${RESET}`
|
|
1283
|
+
: state.mode === "auto-submit"
|
|
1284
|
+
? `${DIM}(auto-submit — no human watched this)${RESET}`
|
|
1285
|
+
: `${YELLOW}(NO review record, and this task is ${state.mode} — it should have stopped at the cut gate)${RESET}`;
|
|
1286
|
+
console.log(`${GREEN}✓ CLEARED TO SUBMIT${RESET} ${BOLD}${state.task_id}${RESET} ${provenance}`);
|
|
1287
|
+
// This gate answers "did a human watch it", never "can the buyer open it".
|
|
1288
|
+
// The second question is the other pre-submit check, and it is the one that
|
|
1289
|
+
// catches a 403 bucket or an expiring link.
|
|
1290
|
+
console.log(` ${DIM}Now check the buyer can actually play it: vidfarm gigs verify-proof --proof <url>${state.clean_master ? ` --clean-master ${state.clean_master}` : ""}${RESET}`);
|
|
809
1291
|
return;
|
|
810
1292
|
}
|
|
811
1293
|
console.log(`${YELLOW}HOLD${RESET} ${BOLD}${state.task_id}${RESET} ${DIM}is at ${state.stage}${state.gate ? ` and waiting on the gigworker (${state.gate.stage})` : ""}${RESET}`);
|
|
@@ -815,13 +1297,18 @@ function cmdGate(root, values, positionals) {
|
|
|
815
1297
|
if (blocked)
|
|
816
1298
|
process.exitCode = 2;
|
|
817
1299
|
}
|
|
1300
|
+
function cmdUndo(root, values, positionals) {
|
|
1301
|
+
const { state, stage } = reopenGate(root, requireTask(positionals[0], "undo"));
|
|
1302
|
+
out(Boolean(values.json), { ok: true, reopened: stage, state }, () => {
|
|
1303
|
+
console.log(`${GREEN}✓${RESET} Undone — ${BOLD}${state.task_id}${RESET} is waiting on you again at ${BOLD}${stage}${RESET}`);
|
|
1304
|
+
console.log(` ${DIM}${state.gate.ask}${RESET}`);
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
818
1307
|
function cmdNote(root, values, positionals) {
|
|
819
|
-
const state = readState(root, requireTask(positionals[0], "note"));
|
|
820
1308
|
const text = positionals.slice(1).join(" ").trim() || String(values.note ?? "").trim();
|
|
821
1309
|
if (!text)
|
|
822
1310
|
throw new Error('clipper-run note needs some text: vidfarm clipper-run note <task-id> "buyer wants the logo bigger"');
|
|
823
|
-
|
|
824
|
-
writeState(root, state);
|
|
1311
|
+
const { state } = addTaskNote(root, requireTask(positionals[0], "note"), text);
|
|
825
1312
|
out(Boolean(values.json), { ok: true, state }, () => {
|
|
826
1313
|
console.log(`${GREEN}✓${RESET} Noted on ${BOLD}${state.task_id}${RESET}.`);
|
|
827
1314
|
});
|
|
@@ -858,6 +1345,14 @@ THE GIGWORKER ANSWERS
|
|
|
858
1345
|
clipper-run approve <task-id> [--note "<text>"]
|
|
859
1346
|
clipper-run changes <task-id> --note "<what to fix>"
|
|
860
1347
|
clipper-run drop <task-id> --reason "<why>"
|
|
1348
|
+
clipper-run undo <task-id> Mis-clicked? Reopen the gate you just answered.
|
|
1349
|
+
Refuses once a proof is sent — that is a gig withdrawal.
|
|
1350
|
+
|
|
1351
|
+
THE CLIPPER DASHBOARD — the visual half of this loop
|
|
1352
|
+
vidfarm dashboard One localhost page: the queue with the cut PLAYING, the buyer's
|
|
1353
|
+
brief beside it, and Approve / Request changes / Drop. Offer it
|
|
1354
|
+
to the gigworker before the first task — most want it, and a
|
|
1355
|
+
gate that means "watch this" cannot be answered in a terminal.
|
|
861
1356
|
|
|
862
1357
|
WHERE AM I
|
|
863
1358
|
clipper-run next THE ONE COMMAND — what to do right now, per open task
|
|
@@ -879,6 +1374,7 @@ function options() {
|
|
|
879
1374
|
title: { type: "string" },
|
|
880
1375
|
harness: { type: "string" },
|
|
881
1376
|
brief: { type: "string" },
|
|
1377
|
+
force: { type: "boolean" },
|
|
882
1378
|
template: { type: "string", multiple: true },
|
|
883
1379
|
why: { type: "string", multiple: true },
|
|
884
1380
|
file: { type: "string" },
|
|
@@ -891,6 +1387,25 @@ function options() {
|
|
|
891
1387
|
};
|
|
892
1388
|
}
|
|
893
1389
|
export async function runClipperRunCommand(argv) {
|
|
1390
|
+
try {
|
|
1391
|
+
await dispatchClipperRun(argv);
|
|
1392
|
+
}
|
|
1393
|
+
catch (error) {
|
|
1394
|
+
if (error instanceof ClipperUserError)
|
|
1395
|
+
throw error;
|
|
1396
|
+
// Only DELIBERATE conditions become friendly one-liners. A TypeError or a
|
|
1397
|
+
// ReferenceError is a bug in this file, and dressing it up as advice hides
|
|
1398
|
+
// it from the user AND from crash reporting — which is how a null-deref
|
|
1399
|
+
// shipped looking like an ordinary error message.
|
|
1400
|
+
const deliberate = error instanceof Error
|
|
1401
|
+
&& error.constructor === Error
|
|
1402
|
+
&& !/undefined|is not a function|Cannot read propert/i.test(error.message);
|
|
1403
|
+
if (deliberate)
|
|
1404
|
+
throw new ClipperUserError(error.message);
|
|
1405
|
+
throw error;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
async function dispatchClipperRun(argv) {
|
|
894
1409
|
const sub = (argv[0] ?? "help").toLowerCase();
|
|
895
1410
|
if (["help", "--help", "-h", ""].includes(sub)) {
|
|
896
1411
|
console.log(CLIPPER_RUN_HELP);
|
|
@@ -921,6 +1436,8 @@ export async function runClipperRunCommand(argv) {
|
|
|
921
1436
|
case "revise": return cmdChanges(root, values, rest);
|
|
922
1437
|
case "drop":
|
|
923
1438
|
case "discard": return cmdDrop(root, values, rest);
|
|
1439
|
+
case "undo":
|
|
1440
|
+
case "reopen": return cmdUndo(root, values, rest);
|
|
924
1441
|
case "submitted":
|
|
925
1442
|
case "sent": return cmdSubmitted(root, values, rest);
|
|
926
1443
|
case "status":
|