@officexapp/vidfarm-devcli 0.21.59 → 0.21.61

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.
@@ -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: does it satisfy the brief, is the hook alive in the first 3 seconds, are the captions readable and inside the safe zone, is the audio clean, is the buyer's brand right?"
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
- function missionRoot(values) {
102
- const explicit = values.dir?.trim();
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(path.join(root, "run.json"), "utf8"));
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
- /* not set yet */
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
- function taskDir(root, taskId) {
136
- // Task ids come off the wire; keep them from escaping the mission folder.
137
- const safe = taskId.replace(/[^A-Za-z0-9._-]/g, "_");
138
- if (!safe || safe === "." || safe === "..")
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
- return path.join(tasksDir(root), safe);
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
- rows.push(JSON.parse(readFileSync(file, "utf8")));
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 half-written state file is not worth crashing the whole loop over */
351
+ /* a torn append is not worth failing the whole view over */
168
352
  }
169
353
  }
170
- return rows.sort((a, b) => a.created_at.localeCompare(b.created_at));
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
- writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
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 = { stage, ask: (ask ?? "").trim() || DEFAULT_ASK[stage] || "Review this and approve it.", since: new Date().toISOString() };
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
- "Approve: `vidfarm clipper-run approve <task-id>` · changes: `… changes <task-id> --note \"…\"` · drop: `… drop <task-id> --reason \"…\"`",
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 === null ? "TBD" : `$${state.price.toFixed(2)}`} · mode: ${state.mode} · waiting since ${state.gate.since}`);
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 === null ? "price TBD" : `$${state.price.toFixed(2)}`;
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 mode = resolveMode(root, values).mode;
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 = new Date().toISOString();
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
- if (!resolved.isSet) {
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: numberOrNull(values.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
- // With no id at all, take the top of the shortlist. That is the auto-mode
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 serve · fast look: vidfarm stills ${state.cut_path}${RESET}`);
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 = readState(root, requireTask(positionals[0], "approve"));
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 = state.gate?.stage ?? state.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 = readState(root, requireTask(positionals[0], "drop"));
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 = readState(root, requireTask(positionals[0], "submitted"));
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 list is in ${path.join(root, "REVIEW_QUEUE.md")}${RESET}`
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}(also written to ${path.join(root, "REVIEW_QUEUE.md")})${RESET}`);
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 === null ? "" : ` · $${state.price.toFixed(2)}`}${RESET}`);
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
- console.log(`${GREEN}✓ CLEARED TO SUBMIT${RESET} ${BOLD}${state.task_id}${RESET}${state.reviewed_by_gigworker ? ` ${DIM}(gigworker approved the cut at ${state.reviewed_by_gigworker})${RESET}` : ` ${DIM}(auto-submit — no human watched this)${RESET}`}`);
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
- note(state, "note", text);
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":