@officexapp/vidfarm-devcli 0.21.59 → 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 +2 -2
- package/.agents/skills/vidfarm/references/content-ideas.md +97 -14
- package/SKILL.director.md +99 -16
- package/SKILL.md +3 -1
- package/clipper.md +7 -3
- package/dist/src/cli.js +194 -15
- package/dist/src/devcli/clipper-panel.js +1828 -0
- package/dist/src/devcli/clipper-run.js +635 -126
- package/dist/src/devcli/skill-docs.js +14 -0
- package/experimental/meme-recaption.md +355 -54
- package/marketplace.md +21 -1
- package/package.json +3 -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) => {
|
|
@@ -354,6 +860,13 @@ function cmdInit(root, values) {
|
|
|
354
860
|
// which clip, which hook, which pain went to which buyer. Read it before you
|
|
355
861
|
// shortlist, or you sell one buyer the same video twice.
|
|
356
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);
|
|
357
870
|
writeReviewQueue(root);
|
|
358
871
|
out(Boolean(values.json), { ok: true, root, mode, created }, () => {
|
|
359
872
|
console.log(`${GREEN}✓${RESET} Mission folder ready at ${BOLD}${root}${RESET}`);
|
|
@@ -361,6 +874,9 @@ function cmdInit(root, values) {
|
|
|
361
874
|
console.log(` ${DIM}${file}${RESET}`);
|
|
362
875
|
console.log("");
|
|
363
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}`);
|
|
364
880
|
});
|
|
365
881
|
}
|
|
366
882
|
function cmdMode(root, values, positionals) {
|
|
@@ -370,22 +886,7 @@ function cmdMode(root, values, positionals) {
|
|
|
370
886
|
const mode = normalizeRunMode(requested);
|
|
371
887
|
if (!mode)
|
|
372
888
|
throw new Error(`Unknown run mode "${requested}". Choose one of: ${RUN_MODES.join(", ")}.`);
|
|
373
|
-
const savedAt =
|
|
374
|
-
mkdirSync(root, { recursive: true });
|
|
375
|
-
const payload = { mode, note: values.note ?? null, savedAt };
|
|
376
|
-
const file = path.join(root, "run.json");
|
|
377
|
-
writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
378
|
-
// MISSION.md is the file a human reads, so keep its two mode lines honest —
|
|
379
|
-
// but only rewrite lines that already exist. This never invents a mission.
|
|
380
|
-
const missionFile = path.join(root, "MISSION.md");
|
|
381
|
-
if (existsSync(missionFile)) {
|
|
382
|
-
const before = readFileSync(missionFile, "utf8");
|
|
383
|
-
const after = before
|
|
384
|
-
.replace(/^run_mode:.*$/m, `run_mode: ${mode}`)
|
|
385
|
-
.replace(/^auto_submit:.*$/m, `auto_submit: ${mode === "auto-submit"}`);
|
|
386
|
-
if (after !== before)
|
|
387
|
-
writeFileSync(missionFile, after, "utf8");
|
|
388
|
-
}
|
|
889
|
+
const { file, savedAt } = setRunMode(root, mode, values.note);
|
|
389
890
|
out(json, { ok: true, run_mode: mode, saved_at: savedAt, file, gates: MODE_GATES[mode] }, () => {
|
|
390
891
|
console.log(`${GREEN}${BOLD}Run mode: ${mode}${RESET}`);
|
|
391
892
|
console.log(`${DIM}${MODE_BLURB[mode]}${RESET}`);
|
|
@@ -408,11 +909,26 @@ function cmdMode(root, values, positionals) {
|
|
|
408
909
|
}
|
|
409
910
|
console.log("");
|
|
410
911
|
console.log(`${DIM}Set it: ${BOLD}vidfarm clipper-run mode <${RUN_MODES.join("|")}>${RESET}`);
|
|
411
|
-
|
|
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) {
|
|
412
918
|
console.log(`${DIM}Nothing saved yet — ASK the gigworker which one they want before the first task.${RESET}`);
|
|
413
919
|
}
|
|
414
920
|
});
|
|
415
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
|
+
}
|
|
416
932
|
function numberOrNull(value) {
|
|
417
933
|
const parsed = typeof value === "string" ? Number(value) : typeof value === "number" ? value : NaN;
|
|
418
934
|
return Number.isFinite(parsed) ? parsed : null;
|
|
@@ -424,12 +940,23 @@ function cmdStart(root, values, positionals) {
|
|
|
424
940
|
const mode = resolveMode(root, values).mode;
|
|
425
941
|
const now = new Date().toISOString();
|
|
426
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
|
+
}
|
|
427
954
|
mkdirSync(path.join(dir, "raws"), { recursive: true });
|
|
428
955
|
const state = {
|
|
429
|
-
task_id: taskId,
|
|
956
|
+
task_id: assertUsableTaskId(taskId),
|
|
430
957
|
gig_id: values.gig ?? null,
|
|
431
958
|
machine: values.machine ?? null,
|
|
432
|
-
price:
|
|
959
|
+
price: parsePrice(values.price),
|
|
433
960
|
title: values.title ?? null,
|
|
434
961
|
harness: values.harness ?? null,
|
|
435
962
|
mode,
|
|
@@ -477,9 +1004,7 @@ function cmdShortlist(root, values, positionals) {
|
|
|
477
1004
|
// task waits at `shortlist` for a `pick` either way — only the gate differs.
|
|
478
1005
|
state.stage = "shortlist";
|
|
479
1006
|
const gated = isGated(state.mode, "shortlist");
|
|
480
|
-
state.gate = gated
|
|
481
|
-
? { stage: "shortlist", ask: (values.ask ?? "").trim() || DEFAULT_ASK.shortlist, since: new Date().toISOString() }
|
|
482
|
-
: null;
|
|
1007
|
+
state.gate = gated ? makeGate("shortlist", values.ask) : null;
|
|
483
1008
|
if (gated)
|
|
484
1009
|
note(state, "gate_opened:shortlist");
|
|
485
1010
|
writeState(root, state);
|
|
@@ -499,24 +1024,8 @@ function cmdShortlist(root, values, positionals) {
|
|
|
499
1024
|
});
|
|
500
1025
|
}
|
|
501
1026
|
function cmdPick(root, values, positionals) {
|
|
502
|
-
const state = readState(root, requireTask(positionals[0], "pick"));
|
|
503
1027
|
const flagged = [].concat(values.template ?? [])[0];
|
|
504
|
-
|
|
505
|
-
// shape — the agent shortlisted best-first, so "pick" means "take your own
|
|
506
|
-
// first choice" — and it saves a weak model from re-typing an id it just wrote.
|
|
507
|
-
const templateId = positionals[1] ?? flagged ?? state.shortlist[0]?.template_id;
|
|
508
|
-
if (!templateId) {
|
|
509
|
-
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…");
|
|
510
|
-
}
|
|
511
|
-
// A pick IS the answer to the shortlist gate, so it closes it. That saves the
|
|
512
|
-
// gigworker from having to both pick and approve.
|
|
513
|
-
const wasGated = state.gate?.stage === "shortlist";
|
|
514
|
-
state.template_id = String(templateId);
|
|
515
|
-
state.template_picked_by = (normalizeWho(values.by) ?? (wasGated ? "human" : "agent"));
|
|
516
|
-
note(state, "template_picked", `${state.template_id} by ${state.template_picked_by}`);
|
|
517
|
-
state.gate = null;
|
|
518
|
-
state.stage = "plan";
|
|
519
|
-
writeState(root, state);
|
|
1028
|
+
const { state } = pickTemplate(root, requireTask(positionals[0], "pick"), positionals[1] ?? flagged, values.by);
|
|
520
1029
|
out(Boolean(values.json), { ok: true, state, next: nextAction(state) }, () => {
|
|
521
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}`);
|
|
522
1031
|
const next = nextAction(state);
|
|
@@ -585,6 +1094,14 @@ function cmdCut(root, values, positionals) {
|
|
|
585
1094
|
state.clean_master = path.resolve(String(values["clean-master"]));
|
|
586
1095
|
if (!state.cut_path)
|
|
587
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
|
+
}
|
|
588
1105
|
note(state, "cut_ready", state.cut_path);
|
|
589
1106
|
const { gated } = completeStage(state, "cut", values.ask);
|
|
590
1107
|
writeState(root, state);
|
|
@@ -597,7 +1114,7 @@ function cmdCut(root, values, positionals) {
|
|
|
597
1114
|
console.log("");
|
|
598
1115
|
console.log(`${YELLOW}STOP — the gigworker watches this before it is submitted.${RESET}`);
|
|
599
1116
|
console.log(`${DIM}${state.gate.ask}${RESET}`);
|
|
600
|
-
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}`);
|
|
601
1118
|
console.log(`${DIM}They answer: vidfarm clipper-run approve ${state.task_id} · changes … --note "…" · drop … --reason "…"${RESET}`);
|
|
602
1119
|
}
|
|
603
1120
|
else {
|
|
@@ -608,19 +1125,7 @@ function cmdCut(root, values, positionals) {
|
|
|
608
1125
|
});
|
|
609
1126
|
}
|
|
610
1127
|
function cmdApprove(root, values, positionals) {
|
|
611
|
-
const state =
|
|
612
|
-
if (!state.gate)
|
|
613
|
-
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}`);
|
|
614
|
-
const stage = state.gate.stage;
|
|
615
|
-
const at = new Date().toISOString();
|
|
616
|
-
note(state, `approved:${stage}`, values.note ?? null);
|
|
617
|
-
// Only the CUT gate is the "a human watched the video" record — that is the
|
|
618
|
-
// one the ledger and the buyer's trust score care about.
|
|
619
|
-
if (stage === "cut")
|
|
620
|
-
state.reviewed_by_gigworker = at;
|
|
621
|
-
state.gate = null;
|
|
622
|
-
state.stage = nextStage(stage);
|
|
623
|
-
writeState(root, state);
|
|
1128
|
+
const { state, approved: stage } = approveGate(root, requireTask(positionals[0], "approve"), values.note);
|
|
624
1129
|
out(Boolean(values.json), { ok: true, approved: stage, state, next: nextAction(state) }, () => {
|
|
625
1130
|
console.log(`${GREEN}✓${RESET} ${BOLD}${state.task_id}${RESET} approved at ${BOLD}${stage}${RESET} → stage ${BOLD}${state.stage}${RESET}`);
|
|
626
1131
|
const next = nextAction(state);
|
|
@@ -628,18 +1133,10 @@ function cmdApprove(root, values, positionals) {
|
|
|
628
1133
|
});
|
|
629
1134
|
}
|
|
630
1135
|
function cmdChanges(root, values, positionals) {
|
|
631
|
-
const state = readState(root, requireTask(positionals[0], "changes"));
|
|
632
1136
|
const text = values.note?.trim();
|
|
633
1137
|
if (!text)
|
|
634
1138
|
throw new Error('clipper-run changes needs --note "<what to fix>" — that note is the whole point.');
|
|
635
|
-
const stage =
|
|
636
|
-
note(state, `changes_requested:${stage}`, text);
|
|
637
|
-
state.gate = null;
|
|
638
|
-
// Send it back to the stage that produced the thing being rejected, so the
|
|
639
|
-
// agent redoes THAT step rather than starting the task over. "not these
|
|
640
|
-
// templates" means shortlist again, which is the stage BEFORE the list.
|
|
641
|
-
state.stage = stage === "cut" ? "build" : stage === "shortlist" ? "claimed" : stage;
|
|
642
|
-
writeState(root, state);
|
|
1139
|
+
const { state, from: stage } = requestChanges(root, requireTask(positionals[0], "changes"), text);
|
|
643
1140
|
out(Boolean(values.json), { ok: true, state, next: nextAction(state) }, () => {
|
|
644
1141
|
console.log(`${GREEN}✓${RESET} Changes recorded on ${BOLD}${state.task_id}${RESET} at ${BOLD}${stage}${RESET} → back to ${BOLD}${state.stage}${RESET}`);
|
|
645
1142
|
console.log(` ${DIM}${text}${RESET}`);
|
|
@@ -647,46 +1144,14 @@ function cmdChanges(root, values, positionals) {
|
|
|
647
1144
|
});
|
|
648
1145
|
}
|
|
649
1146
|
function cmdDrop(root, values, positionals) {
|
|
650
|
-
const state =
|
|
651
|
-
const reason = values.reason?.trim() || "no reason given";
|
|
652
|
-
note(state, "discarded", reason);
|
|
653
|
-
state.gate = null;
|
|
654
|
-
state.stage = "discarded";
|
|
655
|
-
writeState(root, state);
|
|
656
|
-
appendLedger(root, {
|
|
657
|
-
ts: new Date().toISOString(),
|
|
658
|
-
gig_id: state.gig_id,
|
|
659
|
-
task_id: state.task_id,
|
|
660
|
-
template_id: state.template_id,
|
|
661
|
-
run_mode: state.mode,
|
|
662
|
-
status: "discarded",
|
|
663
|
-
reason
|
|
664
|
-
});
|
|
1147
|
+
const { state, reason } = dropTask(root, requireTask(positionals[0], "drop"), values.reason);
|
|
665
1148
|
out(Boolean(values.json), { ok: true, state }, () => {
|
|
666
1149
|
console.log(`${GREEN}✓${RESET} Dropped ${BOLD}${state.task_id}${RESET} — ${reason}`);
|
|
667
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}`);
|
|
668
1151
|
});
|
|
669
1152
|
}
|
|
670
1153
|
function cmdSubmitted(root, values, positionals) {
|
|
671
|
-
const state =
|
|
672
|
-
state.proof_id = values.proof ?? null;
|
|
673
|
-
note(state, "submitted", state.proof_id);
|
|
674
|
-
state.gate = null;
|
|
675
|
-
state.stage = "done";
|
|
676
|
-
writeState(root, state);
|
|
677
|
-
appendLedger(root, {
|
|
678
|
-
ts: new Date().toISOString(),
|
|
679
|
-
gig_id: state.gig_id,
|
|
680
|
-
task_id: state.task_id,
|
|
681
|
-
proof_id: state.proof_id,
|
|
682
|
-
template_id: state.template_id,
|
|
683
|
-
harness: state.harness,
|
|
684
|
-
run_mode: state.mode,
|
|
685
|
-
locked_price: state.price,
|
|
686
|
-
status: "submitted",
|
|
687
|
-
reviewed_by_gigworker: state.reviewed_by_gigworker ?? (state.mode === "auto-submit" ? "auto_submit" : null),
|
|
688
|
-
paid_out_at: null
|
|
689
|
-
});
|
|
1154
|
+
const { state } = markSubmitted(root, requireTask(positionals[0], "submitted"), values.proof);
|
|
690
1155
|
out(Boolean(values.json), { ok: true, state }, () => {
|
|
691
1156
|
console.log(`${GREEN}✓${RESET} ${BOLD}${state.task_id}${RESET} closed as submitted${state.proof_id ? ` ${DIM}(${state.proof_id})${RESET}` : ""}`);
|
|
692
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}`);
|
|
@@ -726,8 +1191,9 @@ function cmdStatus(root, values, positionals) {
|
|
|
726
1191
|
const waiting = open.filter((s) => s.gate).length;
|
|
727
1192
|
console.log("");
|
|
728
1193
|
console.log(waiting
|
|
729
|
-
? `${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}`
|
|
730
1195
|
: `${DIM}Nothing waiting on the gigworker.${RESET}`);
|
|
1196
|
+
warnUnreadable(root);
|
|
731
1197
|
});
|
|
732
1198
|
}
|
|
733
1199
|
function cmdNext(root, values) {
|
|
@@ -757,12 +1223,13 @@ function cmdNext(root, values) {
|
|
|
757
1223
|
}
|
|
758
1224
|
if (waiting.length) {
|
|
759
1225
|
console.log("");
|
|
760
|
-
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}`);
|
|
761
1227
|
for (const state of waiting) {
|
|
762
1228
|
console.log(` ${BOLD}${state.task_id}${RESET} ${DIM}at ${state.gate.stage} since ${state.gate.since}${RESET}`);
|
|
763
1229
|
console.log(` ${DIM}${state.gate.ask}${RESET}`);
|
|
764
1230
|
}
|
|
765
1231
|
}
|
|
1232
|
+
warnUnreadable(root);
|
|
766
1233
|
});
|
|
767
1234
|
}
|
|
768
1235
|
function cmdReview(root, values) {
|
|
@@ -771,12 +1238,13 @@ function cmdReview(root, values) {
|
|
|
771
1238
|
out(Boolean(values.json), { ok: true, waiting: rows }, () => {
|
|
772
1239
|
if (!rows.length) {
|
|
773
1240
|
console.log(`${DIM}Nothing waiting for the gigworker.${RESET}`);
|
|
1241
|
+
warnUnreadable(root);
|
|
774
1242
|
return;
|
|
775
1243
|
}
|
|
776
|
-
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}`);
|
|
777
1245
|
for (const state of rows) {
|
|
778
1246
|
console.log("");
|
|
779
|
-
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}`);
|
|
780
1248
|
if (state.gate.stage === "shortlist") {
|
|
781
1249
|
for (const row of state.shortlist)
|
|
782
1250
|
console.log(` ${BOLD}${row.template_id}${RESET}${row.why ? ` ${DIM}— ${row.why}${RESET}` : ""}`);
|
|
@@ -790,6 +1258,7 @@ function cmdReview(root, values) {
|
|
|
790
1258
|
console.log(` ${DIM}${state.gate.ask}${RESET}`);
|
|
791
1259
|
console.log(` ${DIM}vidfarm clipper-run approve ${state.task_id}${state.gate.stage === "shortlist" ? ` (or: pick ${state.task_id} <template_id>)` : ""}${RESET}`);
|
|
792
1260
|
}
|
|
1261
|
+
warnUnreadable(root);
|
|
793
1262
|
});
|
|
794
1263
|
}
|
|
795
1264
|
/**
|
|
@@ -809,7 +1278,12 @@ function cmdGate(root, values, positionals) {
|
|
|
809
1278
|
reviewed_by_gigworker: state.reviewed_by_gigworker
|
|
810
1279
|
}, () => {
|
|
811
1280
|
if (!blocked) {
|
|
812
|
-
|
|
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}`);
|
|
813
1287
|
// This gate answers "did a human watch it", never "can the buyer open it".
|
|
814
1288
|
// The second question is the other pre-submit check, and it is the one that
|
|
815
1289
|
// catches a 403 bucket or an expiring link.
|
|
@@ -823,13 +1297,18 @@ function cmdGate(root, values, positionals) {
|
|
|
823
1297
|
if (blocked)
|
|
824
1298
|
process.exitCode = 2;
|
|
825
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
|
+
}
|
|
826
1307
|
function cmdNote(root, values, positionals) {
|
|
827
|
-
const state = readState(root, requireTask(positionals[0], "note"));
|
|
828
1308
|
const text = positionals.slice(1).join(" ").trim() || String(values.note ?? "").trim();
|
|
829
1309
|
if (!text)
|
|
830
1310
|
throw new Error('clipper-run note needs some text: vidfarm clipper-run note <task-id> "buyer wants the logo bigger"');
|
|
831
|
-
|
|
832
|
-
writeState(root, state);
|
|
1311
|
+
const { state } = addTaskNote(root, requireTask(positionals[0], "note"), text);
|
|
833
1312
|
out(Boolean(values.json), { ok: true, state }, () => {
|
|
834
1313
|
console.log(`${GREEN}✓${RESET} Noted on ${BOLD}${state.task_id}${RESET}.`);
|
|
835
1314
|
});
|
|
@@ -866,6 +1345,14 @@ THE GIGWORKER ANSWERS
|
|
|
866
1345
|
clipper-run approve <task-id> [--note "<text>"]
|
|
867
1346
|
clipper-run changes <task-id> --note "<what to fix>"
|
|
868
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.
|
|
869
1356
|
|
|
870
1357
|
WHERE AM I
|
|
871
1358
|
clipper-run next THE ONE COMMAND — what to do right now, per open task
|
|
@@ -887,6 +1374,7 @@ function options() {
|
|
|
887
1374
|
title: { type: "string" },
|
|
888
1375
|
harness: { type: "string" },
|
|
889
1376
|
brief: { type: "string" },
|
|
1377
|
+
force: { type: "boolean" },
|
|
890
1378
|
template: { type: "string", multiple: true },
|
|
891
1379
|
why: { type: "string", multiple: true },
|
|
892
1380
|
file: { type: "string" },
|
|
@@ -899,6 +1387,25 @@ function options() {
|
|
|
899
1387
|
};
|
|
900
1388
|
}
|
|
901
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) {
|
|
902
1409
|
const sub = (argv[0] ?? "help").toLowerCase();
|
|
903
1410
|
if (["help", "--help", "-h", ""].includes(sub)) {
|
|
904
1411
|
console.log(CLIPPER_RUN_HELP);
|
|
@@ -929,6 +1436,8 @@ export async function runClipperRunCommand(argv) {
|
|
|
929
1436
|
case "revise": return cmdChanges(root, values, rest);
|
|
930
1437
|
case "drop":
|
|
931
1438
|
case "discard": return cmdDrop(root, values, rest);
|
|
1439
|
+
case "undo":
|
|
1440
|
+
case "reopen": return cmdUndo(root, values, rest);
|
|
932
1441
|
case "submitted":
|
|
933
1442
|
case "sent": return cmdSubmitted(root, values, rest);
|
|
934
1443
|
case "status":
|