@officexapp/vidfarm-devcli 0.21.58 → 0.21.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) => {
@@ -350,6 +856,17 @@ function cmdInit(root, values) {
350
856
  seed("NOTES.md", "# Notes\n\nWhat each buyer approved, what they rejected and with which tag, prices that moved.\n");
351
857
  seed("EARNINGS.md", "# Earnings\n\nsubmitted / approved / PAID / owed, with dates. `approved` is not `paid`.\n");
352
858
  seed("LEDGER.jsonl", "");
859
+ // The anti-repeat record. LEDGER.jsonl knows the money; this knows the IDEA —
860
+ // which clip, which hook, which pain went to which buyer. Read it before you
861
+ // shortlist, or you sell one buyer the same video twice.
862
+ seed("DELIVERED.tsv", "# gig_id\tproof_id\tharness\ttemplate_id\tsource_clip\thook\tpain\tverdict\n");
863
+ // `init --mode auto-batch` used to write the mode into MISSION.md and nowhere
864
+ // else, so run.json stayed unset and `clipper-run mode` reported the default
865
+ // — the human's file and the machine's file disagreed. Persist a mode that
866
+ // was actually ASKED for; never persist the assumed default (that stays an
867
+ // open question until the gigworker answers it).
868
+ if (resolved.isSet)
869
+ setRunMode(root, mode, values.note);
353
870
  writeReviewQueue(root);
354
871
  out(Boolean(values.json), { ok: true, root, mode, created }, () => {
355
872
  console.log(`${GREEN}✓${RESET} Mission folder ready at ${BOLD}${root}${RESET}`);
@@ -357,6 +874,9 @@ function cmdInit(root, values) {
357
874
  console.log(` ${DIM}${file}${RESET}`);
358
875
  console.log("");
359
876
  console.log(`${DIM}Run mode: ${BOLD}${mode}${RESET}${DIM} — change it with: vidfarm clipper-run mode <${RUN_MODES.join("|")}>${RESET}`);
877
+ console.log("");
878
+ console.log(`${BOLD}Ask the gigworker whether to open the clipper dashboard${RESET}${DIM} — recommended, and the answer goes in MISSION.md \`dashboard:\`.${RESET}`);
879
+ console.log(` ${BOLD}vidfarm dashboard${RESET}${DIM} the queue as a page: the cut plays, the brief sits beside it, one click answers the gate.${RESET}`);
360
880
  });
361
881
  }
362
882
  function cmdMode(root, values, positionals) {
@@ -366,22 +886,7 @@ function cmdMode(root, values, positionals) {
366
886
  const mode = normalizeRunMode(requested);
367
887
  if (!mode)
368
888
  throw new Error(`Unknown run mode "${requested}". Choose one of: ${RUN_MODES.join(", ")}.`);
369
- const savedAt = new Date().toISOString();
370
- mkdirSync(root, { recursive: true });
371
- const payload = { mode, note: values.note ?? null, savedAt };
372
- const file = path.join(root, "run.json");
373
- writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
374
- // MISSION.md is the file a human reads, so keep its two mode lines honest —
375
- // but only rewrite lines that already exist. This never invents a mission.
376
- const missionFile = path.join(root, "MISSION.md");
377
- if (existsSync(missionFile)) {
378
- const before = readFileSync(missionFile, "utf8");
379
- const after = before
380
- .replace(/^run_mode:.*$/m, `run_mode: ${mode}`)
381
- .replace(/^auto_submit:.*$/m, `auto_submit: ${mode === "auto-submit"}`);
382
- if (after !== before)
383
- writeFileSync(missionFile, after, "utf8");
384
- }
889
+ const { file, savedAt } = setRunMode(root, mode, values.note);
385
890
  out(json, { ok: true, run_mode: mode, saved_at: savedAt, file, gates: MODE_GATES[mode] }, () => {
386
891
  console.log(`${GREEN}${BOLD}Run mode: ${mode}${RESET}`);
387
892
  console.log(`${DIM}${MODE_BLURB[mode]}${RESET}`);
@@ -404,11 +909,26 @@ function cmdMode(root, values, positionals) {
404
909
  }
405
910
  console.log("");
406
911
  console.log(`${DIM}Set it: ${BOLD}vidfarm clipper-run mode <${RUN_MODES.join("|")}>${RESET}`);
407
- 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) {
408
918
  console.log(`${DIM}Nothing saved yet — ASK the gigworker which one they want before the first task.${RESET}`);
409
919
  }
410
920
  });
411
921
  }
922
+ /** A price the caller typed wrong is a silent $0 in the ledger otherwise. */
923
+ function parsePrice(value) {
924
+ if (value === undefined || value === null || value === "")
925
+ return null;
926
+ const parsed = numberOrNull(value);
927
+ if (parsed === null) {
928
+ throw new ClipperUserError(`--price "${String(value)}" is not a number. Give it as e.g. --price 4.50, or leave it out.`);
929
+ }
930
+ return parsed;
931
+ }
412
932
  function numberOrNull(value) {
413
933
  const parsed = typeof value === "string" ? Number(value) : typeof value === "number" ? value : NaN;
414
934
  return Number.isFinite(parsed) ? parsed : null;
@@ -420,12 +940,23 @@ function cmdStart(root, values, positionals) {
420
940
  const mode = resolveMode(root, values).mode;
421
941
  const now = new Date().toISOString();
422
942
  const dir = taskDir(root, taskId);
943
+ // Starting an id that already exists used to overwrite a live task back to
944
+ // `claimed`, taking its history and its review record with it.
945
+ if (existsSync(path.join(dir, "state.json")) && values.force) {
946
+ const doomed = readState(root, taskId);
947
+ console.log(`${YELLOW}--force: overwriting ${taskId}${RESET}${DIM} (was at "${doomed.stage}"${doomed.gate ? `, waiting on you at "${doomed.gate.stage}"` : ""}, ${doomed.history.length} history entr(ies)) — that record is gone.${RESET}`);
948
+ }
949
+ if (existsSync(path.join(dir, "state.json")) && !values.force) {
950
+ const existing = readState(root, taskId);
951
+ throw new Error(`${taskId} already exists (stage "${existing.stage}"${existing.gate ? `, waiting at "${existing.gate.stage}"` : ""}). ` +
952
+ `Starting it again would erase its history. Use a different id, or --force to overwrite on purpose.`);
953
+ }
423
954
  mkdirSync(path.join(dir, "raws"), { recursive: true });
424
955
  const state = {
425
- task_id: taskId,
956
+ task_id: assertUsableTaskId(taskId),
426
957
  gig_id: values.gig ?? null,
427
958
  machine: values.machine ?? null,
428
- price: numberOrNull(values.price),
959
+ price: parsePrice(values.price),
429
960
  title: values.title ?? null,
430
961
  harness: values.harness ?? null,
431
962
  mode,
@@ -473,9 +1004,7 @@ function cmdShortlist(root, values, positionals) {
473
1004
  // task waits at `shortlist` for a `pick` either way — only the gate differs.
474
1005
  state.stage = "shortlist";
475
1006
  const gated = isGated(state.mode, "shortlist");
476
- state.gate = gated
477
- ? { stage: "shortlist", ask: (values.ask ?? "").trim() || DEFAULT_ASK.shortlist, since: new Date().toISOString() }
478
- : null;
1007
+ state.gate = gated ? makeGate("shortlist", values.ask) : null;
479
1008
  if (gated)
480
1009
  note(state, "gate_opened:shortlist");
481
1010
  writeState(root, state);
@@ -495,24 +1024,8 @@ function cmdShortlist(root, values, positionals) {
495
1024
  });
496
1025
  }
497
1026
  function cmdPick(root, values, positionals) {
498
- const state = readState(root, requireTask(positionals[0], "pick"));
499
1027
  const flagged = [].concat(values.template ?? [])[0];
500
- // With no id at all, take the top of the shortlist. That is the auto-mode
501
- // shape — the agent shortlisted best-first, so "pick" means "take your own
502
- // first choice" — and it saves a weak model from re-typing an id it just wrote.
503
- const templateId = positionals[1] ?? flagged ?? state.shortlist[0]?.template_id;
504
- if (!templateId) {
505
- throw new Error("clipper-run pick needs a template id (or a shortlist to take the top of): vidfarm clipper-run pick <task-id> template_019…");
506
- }
507
- // A pick IS the answer to the shortlist gate, so it closes it. That saves the
508
- // gigworker from having to both pick and approve.
509
- const wasGated = state.gate?.stage === "shortlist";
510
- state.template_id = String(templateId);
511
- state.template_picked_by = (normalizeWho(values.by) ?? (wasGated ? "human" : "agent"));
512
- note(state, "template_picked", `${state.template_id} by ${state.template_picked_by}`);
513
- state.gate = null;
514
- state.stage = "plan";
515
- writeState(root, state);
1028
+ const { state } = pickTemplate(root, requireTask(positionals[0], "pick"), positionals[1] ?? flagged, values.by);
516
1029
  out(Boolean(values.json), { ok: true, state, next: nextAction(state) }, () => {
517
1030
  console.log(`${GREEN}✓${RESET} ${BOLD}${state.task_id}${RESET} will adapt ${BOLD}${state.template_id}${RESET} ${DIM}(chosen by the ${state.template_picked_by})${RESET}`);
518
1031
  const next = nextAction(state);
@@ -581,6 +1094,14 @@ function cmdCut(root, values, positionals) {
581
1094
  state.clean_master = path.resolve(String(values["clean-master"]));
582
1095
  if (!state.cut_path)
583
1096
  throw new Error("clipper-run cut needs --file <path to the WATERMARKED cut>.");
1097
+ const recordedSize = fileSizeOrNull(state.cut_path);
1098
+ if (recordedSize === null) {
1099
+ throw new ClipperUserError(`No file at ${state.cut_path}. Record the cut only once the render has finished — a gate opened on a ` +
1100
+ `path that does not exist wastes the gigworker's trip to the queue.`);
1101
+ }
1102
+ if (recordedSize === 0) {
1103
+ throw new ClipperUserError(`${state.cut_path} is 0 bytes — the render produced nothing. Re-render before recording it.`);
1104
+ }
584
1105
  note(state, "cut_ready", state.cut_path);
585
1106
  const { gated } = completeStage(state, "cut", values.ask);
586
1107
  writeState(root, state);
@@ -593,7 +1114,7 @@ function cmdCut(root, values, positionals) {
593
1114
  console.log("");
594
1115
  console.log(`${YELLOW}STOP — the gigworker watches this before it is submitted.${RESET}`);
595
1116
  console.log(`${DIM}${state.gate.ask}${RESET}`);
596
- console.log(`${DIM}Play it: vidfarm 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}`);
597
1118
  console.log(`${DIM}They answer: vidfarm clipper-run approve ${state.task_id} · changes … --note "…" · drop … --reason "…"${RESET}`);
598
1119
  }
599
1120
  else {
@@ -604,19 +1125,7 @@ function cmdCut(root, values, positionals) {
604
1125
  });
605
1126
  }
606
1127
  function cmdApprove(root, values, positionals) {
607
- const state = readState(root, requireTask(positionals[0], "approve"));
608
- if (!state.gate)
609
- throw new Error(`${state.task_id} is not waiting for anybody — it is at stage "${state.stage}". Run: vidfarm clipper-run status ${state.task_id}`);
610
- const stage = state.gate.stage;
611
- const at = new Date().toISOString();
612
- note(state, `approved:${stage}`, values.note ?? null);
613
- // Only the CUT gate is the "a human watched the video" record — that is the
614
- // one the ledger and the buyer's trust score care about.
615
- if (stage === "cut")
616
- state.reviewed_by_gigworker = at;
617
- state.gate = null;
618
- state.stage = nextStage(stage);
619
- writeState(root, state);
1128
+ const { state, approved: stage } = approveGate(root, requireTask(positionals[0], "approve"), values.note);
620
1129
  out(Boolean(values.json), { ok: true, approved: stage, state, next: nextAction(state) }, () => {
621
1130
  console.log(`${GREEN}✓${RESET} ${BOLD}${state.task_id}${RESET} approved at ${BOLD}${stage}${RESET} → stage ${BOLD}${state.stage}${RESET}`);
622
1131
  const next = nextAction(state);
@@ -624,18 +1133,10 @@ function cmdApprove(root, values, positionals) {
624
1133
  });
625
1134
  }
626
1135
  function cmdChanges(root, values, positionals) {
627
- const state = readState(root, requireTask(positionals[0], "changes"));
628
1136
  const text = values.note?.trim();
629
1137
  if (!text)
630
1138
  throw new Error('clipper-run changes needs --note "<what to fix>" — that note is the whole point.');
631
- const stage = state.gate?.stage ?? state.stage;
632
- note(state, `changes_requested:${stage}`, text);
633
- state.gate = null;
634
- // Send it back to the stage that produced the thing being rejected, so the
635
- // agent redoes THAT step rather than starting the task over. "not these
636
- // templates" means shortlist again, which is the stage BEFORE the list.
637
- state.stage = stage === "cut" ? "build" : stage === "shortlist" ? "claimed" : stage;
638
- writeState(root, state);
1139
+ const { state, from: stage } = requestChanges(root, requireTask(positionals[0], "changes"), text);
639
1140
  out(Boolean(values.json), { ok: true, state, next: nextAction(state) }, () => {
640
1141
  console.log(`${GREEN}✓${RESET} Changes recorded on ${BOLD}${state.task_id}${RESET} at ${BOLD}${stage}${RESET} → back to ${BOLD}${state.stage}${RESET}`);
641
1142
  console.log(` ${DIM}${text}${RESET}`);
@@ -643,46 +1144,14 @@ function cmdChanges(root, values, positionals) {
643
1144
  });
644
1145
  }
645
1146
  function cmdDrop(root, values, positionals) {
646
- const state = readState(root, requireTask(positionals[0], "drop"));
647
- const reason = values.reason?.trim() || "no reason given";
648
- note(state, "discarded", reason);
649
- state.gate = null;
650
- state.stage = "discarded";
651
- writeState(root, state);
652
- appendLedger(root, {
653
- ts: new Date().toISOString(),
654
- gig_id: state.gig_id,
655
- task_id: state.task_id,
656
- template_id: state.template_id,
657
- run_mode: state.mode,
658
- status: "discarded",
659
- reason
660
- });
1147
+ const { state, reason } = dropTask(root, requireTask(positionals[0], "drop"), values.reason);
661
1148
  out(Boolean(values.json), { ok: true, state }, () => {
662
1149
  console.log(`${GREEN}✓${RESET} Dropped ${BOLD}${state.task_id}${RESET} — ${reason}`);
663
1150
  console.log(` ${DIM}Logged to LEDGER.jsonl. On Custom Requests, release it or let it expire — never submit a cut the gigworker rejected.${RESET}`);
664
1151
  });
665
1152
  }
666
1153
  function cmdSubmitted(root, values, positionals) {
667
- const state = readState(root, requireTask(positionals[0], "submitted"));
668
- state.proof_id = values.proof ?? null;
669
- note(state, "submitted", state.proof_id);
670
- state.gate = null;
671
- state.stage = "done";
672
- writeState(root, state);
673
- appendLedger(root, {
674
- ts: new Date().toISOString(),
675
- gig_id: state.gig_id,
676
- task_id: state.task_id,
677
- proof_id: state.proof_id,
678
- template_id: state.template_id,
679
- harness: state.harness,
680
- run_mode: state.mode,
681
- locked_price: state.price,
682
- status: "submitted",
683
- reviewed_by_gigworker: state.reviewed_by_gigworker ?? (state.mode === "auto-submit" ? "auto_submit" : null),
684
- paid_out_at: null
685
- });
1154
+ const { state } = markSubmitted(root, requireTask(positionals[0], "submitted"), values.proof);
686
1155
  out(Boolean(values.json), { ok: true, state }, () => {
687
1156
  console.log(`${GREEN}✓${RESET} ${BOLD}${state.task_id}${RESET} closed as submitted${state.proof_id ? ` ${DIM}(${state.proof_id})${RESET}` : ""}`);
688
1157
  console.log(` ${DIM}Ledger row appended. approved is not paid — reconcile with: vidfarm gigs proof ${state.gig_id ?? "<gig>"} ${state.proof_id ?? "<proof-id>"}${RESET}`);
@@ -722,8 +1191,9 @@ function cmdStatus(root, values, positionals) {
722
1191
  const waiting = open.filter((s) => s.gate).length;
723
1192
  console.log("");
724
1193
  console.log(waiting
725
- ? `${YELLOW}${waiting} task(s) waiting on the gigworker${RESET}${DIM} — the 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}`
726
1195
  : `${DIM}Nothing waiting on the gigworker.${RESET}`);
1196
+ warnUnreadable(root);
727
1197
  });
728
1198
  }
729
1199
  function cmdNext(root, values) {
@@ -753,12 +1223,13 @@ function cmdNext(root, values) {
753
1223
  }
754
1224
  if (waiting.length) {
755
1225
  console.log("");
756
- console.log(`${YELLOW}Waiting on the gigworker — do NOT work around these${RESET}`);
1226
+ console.log(`${YELLOW}Waiting on the gigworker — do NOT work around these${RESET}${DIM} (they answer fastest in the clipper dashboard: ${BOLD}vidfarm dashboard${RESET}${DIM})${RESET}`);
757
1227
  for (const state of waiting) {
758
1228
  console.log(` ${BOLD}${state.task_id}${RESET} ${DIM}at ${state.gate.stage} since ${state.gate.since}${RESET}`);
759
1229
  console.log(` ${DIM}${state.gate.ask}${RESET}`);
760
1230
  }
761
1231
  }
1232
+ warnUnreadable(root);
762
1233
  });
763
1234
  }
764
1235
  function cmdReview(root, values) {
@@ -767,12 +1238,13 @@ function cmdReview(root, values) {
767
1238
  out(Boolean(values.json), { ok: true, waiting: rows }, () => {
768
1239
  if (!rows.length) {
769
1240
  console.log(`${DIM}Nothing waiting for the gigworker.${RESET}`);
1241
+ warnUnreadable(root);
770
1242
  return;
771
1243
  }
772
- console.log(`${BOLD}${rows.length} task(s) waiting for you${RESET} ${DIM}(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}`);
773
1245
  for (const state of rows) {
774
1246
  console.log("");
775
- console.log(` ${BOLD}${state.task_id}${RESET} ${DIM}${state.gate.stage} · ${state.mode}${state.price === 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}`);
776
1248
  if (state.gate.stage === "shortlist") {
777
1249
  for (const row of state.shortlist)
778
1250
  console.log(` ${BOLD}${row.template_id}${RESET}${row.why ? ` ${DIM}— ${row.why}${RESET}` : ""}`);
@@ -786,6 +1258,7 @@ function cmdReview(root, values) {
786
1258
  console.log(` ${DIM}${state.gate.ask}${RESET}`);
787
1259
  console.log(` ${DIM}vidfarm clipper-run approve ${state.task_id}${state.gate.stage === "shortlist" ? ` (or: pick ${state.task_id} <template_id>)` : ""}${RESET}`);
788
1260
  }
1261
+ warnUnreadable(root);
789
1262
  });
790
1263
  }
791
1264
  /**
@@ -805,7 +1278,16 @@ function cmdGate(root, values, positionals) {
805
1278
  reviewed_by_gigworker: state.reviewed_by_gigworker
806
1279
  }, () => {
807
1280
  if (!blocked) {
808
- 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}`);
1287
+ // This gate answers "did a human watch it", never "can the buyer open it".
1288
+ // The second question is the other pre-submit check, and it is the one that
1289
+ // catches a 403 bucket or an expiring link.
1290
+ console.log(` ${DIM}Now check the buyer can actually play it: vidfarm gigs verify-proof --proof <url>${state.clean_master ? ` --clean-master ${state.clean_master}` : ""}${RESET}`);
809
1291
  return;
810
1292
  }
811
1293
  console.log(`${YELLOW}HOLD${RESET} ${BOLD}${state.task_id}${RESET} ${DIM}is at ${state.stage}${state.gate ? ` and waiting on the gigworker (${state.gate.stage})` : ""}${RESET}`);
@@ -815,13 +1297,18 @@ function cmdGate(root, values, positionals) {
815
1297
  if (blocked)
816
1298
  process.exitCode = 2;
817
1299
  }
1300
+ function cmdUndo(root, values, positionals) {
1301
+ const { state, stage } = reopenGate(root, requireTask(positionals[0], "undo"));
1302
+ out(Boolean(values.json), { ok: true, reopened: stage, state }, () => {
1303
+ console.log(`${GREEN}✓${RESET} Undone — ${BOLD}${state.task_id}${RESET} is waiting on you again at ${BOLD}${stage}${RESET}`);
1304
+ console.log(` ${DIM}${state.gate.ask}${RESET}`);
1305
+ });
1306
+ }
818
1307
  function cmdNote(root, values, positionals) {
819
- const state = readState(root, requireTask(positionals[0], "note"));
820
1308
  const text = positionals.slice(1).join(" ").trim() || String(values.note ?? "").trim();
821
1309
  if (!text)
822
1310
  throw new Error('clipper-run note needs some text: vidfarm clipper-run note <task-id> "buyer wants the logo bigger"');
823
- note(state, "note", text);
824
- writeState(root, state);
1311
+ const { state } = addTaskNote(root, requireTask(positionals[0], "note"), text);
825
1312
  out(Boolean(values.json), { ok: true, state }, () => {
826
1313
  console.log(`${GREEN}✓${RESET} Noted on ${BOLD}${state.task_id}${RESET}.`);
827
1314
  });
@@ -858,6 +1345,14 @@ THE GIGWORKER ANSWERS
858
1345
  clipper-run approve <task-id> [--note "<text>"]
859
1346
  clipper-run changes <task-id> --note "<what to fix>"
860
1347
  clipper-run drop <task-id> --reason "<why>"
1348
+ clipper-run undo <task-id> Mis-clicked? Reopen the gate you just answered.
1349
+ Refuses once a proof is sent — that is a gig withdrawal.
1350
+
1351
+ THE CLIPPER DASHBOARD — the visual half of this loop
1352
+ vidfarm dashboard One localhost page: the queue with the cut PLAYING, the buyer's
1353
+ brief beside it, and Approve / Request changes / Drop. Offer it
1354
+ to the gigworker before the first task — most want it, and a
1355
+ gate that means "watch this" cannot be answered in a terminal.
861
1356
 
862
1357
  WHERE AM I
863
1358
  clipper-run next THE ONE COMMAND — what to do right now, per open task
@@ -879,6 +1374,7 @@ function options() {
879
1374
  title: { type: "string" },
880
1375
  harness: { type: "string" },
881
1376
  brief: { type: "string" },
1377
+ force: { type: "boolean" },
882
1378
  template: { type: "string", multiple: true },
883
1379
  why: { type: "string", multiple: true },
884
1380
  file: { type: "string" },
@@ -891,6 +1387,25 @@ function options() {
891
1387
  };
892
1388
  }
893
1389
  export async function runClipperRunCommand(argv) {
1390
+ try {
1391
+ await dispatchClipperRun(argv);
1392
+ }
1393
+ catch (error) {
1394
+ if (error instanceof ClipperUserError)
1395
+ throw error;
1396
+ // Only DELIBERATE conditions become friendly one-liners. A TypeError or a
1397
+ // ReferenceError is a bug in this file, and dressing it up as advice hides
1398
+ // it from the user AND from crash reporting — which is how a null-deref
1399
+ // shipped looking like an ordinary error message.
1400
+ const deliberate = error instanceof Error
1401
+ && error.constructor === Error
1402
+ && !/undefined|is not a function|Cannot read propert/i.test(error.message);
1403
+ if (deliberate)
1404
+ throw new ClipperUserError(error.message);
1405
+ throw error;
1406
+ }
1407
+ }
1408
+ async function dispatchClipperRun(argv) {
894
1409
  const sub = (argv[0] ?? "help").toLowerCase();
895
1410
  if (["help", "--help", "-h", ""].includes(sub)) {
896
1411
  console.log(CLIPPER_RUN_HELP);
@@ -921,6 +1436,8 @@ export async function runClipperRunCommand(argv) {
921
1436
  case "revise": return cmdChanges(root, values, rest);
922
1437
  case "drop":
923
1438
  case "discard": return cmdDrop(root, values, rest);
1439
+ case "undo":
1440
+ case "reopen": return cmdUndo(root, values, rest);
924
1441
  case "submitted":
925
1442
  case "sent": return cmdSubmitted(root, values, rest);
926
1443
  case "status":