@gobing-ai/spur 0.3.92 → 0.3.93

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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/pipeline-budgets.json +0 -7
  3. package/config/plugin-scripts.json +10 -0
  4. package/config/templates/AGENTS.md +4 -0
  5. package/config/templates/docs/02_ROADMAP.md +2 -0
  6. package/config/templates/docs/03_ARCHITECTURE.md +3 -1
  7. package/config/templates/docs/04_DESIGN.md +2 -0
  8. package/config/templates/docs/99_PROJECT_CONSTITUTION.md +10 -2
  9. package/config/workflow-candidates.json +72 -1
  10. package/config/workflows/feature-verification.yaml +1 -0
  11. package/config/workflows/history-anatomy.yaml +2 -0
  12. package/config/workflows/idea-pipeline.yaml +67 -18
  13. package/config/workflows/pr-review.yaml +8 -0
  14. package/config/workflows/task-pipeline.yaml +180 -39
  15. package/config/workflows/wayfinder-resolution.yaml +5 -0
  16. package/config/workflows/wrapup-pipeline.yaml +55 -14
  17. package/package.json +9 -9
  18. package/plugins/sp/README.md +7 -2
  19. package/plugins/sp/commands/dev-run.md +2 -2
  20. package/plugins/sp/commands/dev-runall.md +2 -2
  21. package/plugins/sp/lib/idea-handoff.generated.mjs +8 -4
  22. package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
  23. package/plugins/sp/lib/inline-run.generated.mjs +24 -16
  24. package/plugins/sp/plugin.json +1 -1
  25. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +1 -1
  26. package/plugins/sp/scripts/inline-run-setup.mjs +75 -3
  27. package/plugins/sp/scripts/inline-run-setup.ts +133 -3
  28. package/plugins/sp/scripts/quality-gate.mjs +248 -6
  29. package/plugins/sp/scripts/quality-gate.ts +410 -9
  30. package/plugins/sp/scripts/residual-scan.mjs +12 -4
  31. package/plugins/sp/scripts/residual-scan.ts +32 -6
  32. package/plugins/sp/scripts/task-diffstat.mjs +156 -0
  33. package/plugins/sp/scripts/task-diffstat.ts +229 -0
  34. package/plugins/sp/scripts/wrapup-drift-probe.mjs +181 -0
  35. package/plugins/sp/scripts/wrapup-drift-probe.ts +258 -0
  36. package/plugins/sp/scripts/wrapup-steps.mjs +60 -1
  37. package/plugins/sp/scripts/wrapup-steps.ts +89 -4
  38. package/plugins/sp/skills/brainstorm/SKILL.md +2 -0
  39. package/plugins/sp/skills/brainstorm/references/workflows.md +17 -2
  40. package/plugins/sp/skills/code-verification/SKILL.md +2 -2
  41. package/plugins/sp/skills/code-verification/references/secu-review.md +3 -2
  42. package/plugins/sp/skills/spur-check/SKILL.md +112 -0
  43. package/plugins/sp/skills/spur-dev/SKILL.md +2 -1
  44. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +14 -2
  45. package/plugins/sp/skills/spur-dev/references/document-authoring.md +85 -0
  46. package/plugins/sp/skills/spur-dev/references/execution-batch.md +1 -1
  47. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +8 -0
  48. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +3 -2
  49. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +19 -1
  50. package/plugins/sp/skills/spur-dev/references/planning-workflow.md +5 -5
  51. package/plugins/sp/skills/spur-dev/templates/design.md +31 -0
  52. package/plugins/sp/skills/spur-dev/templates/plan.md +32 -0
  53. package/plugins/sp/skills/spur-doctor/SKILL.md +60 -14
  54. package/schemas/state-machine-workflow.schema.json +4 -0
  55. package/spur.js +1117 -472
  56. package/config/workflows/decision-routing-example.yaml +0 -134
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.92",
3
+ "version": "0.3.93",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -45,11 +45,11 @@ const DOCUMENTED = {
45
45
  'file.read.into-var',
46
46
  'hitl.confirm',
47
47
  'hitl.input',
48
- 'hitl.select',
49
48
  'agent.run',
50
49
  'proof.fingerprint',
51
50
  'run.artifact',
52
51
  'command.gate',
52
+ 'decide',
53
53
  ]),
54
54
  guards: new Set(['always', 'shell', 'action-ok', 'contract-violation']),
55
55
  } as const;
@@ -29,7 +29,8 @@ function usage() {
29
29
  console.error("Usage: bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]");
30
30
  console.error(" bun plugins/sp/scripts/inline-run-setup.ts --fingerprint --task-file <path> [--feature-file <path>] [--spur-bin <path>]");
31
31
  console.error(" bun plugins/sp/scripts/inline-run-setup.ts --action --run-id <id> --node <state> --kind <kind> " + "--status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]");
32
- console.error(" bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--spur-bin <path>]");
32
+ console.error(" bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]");
33
+ console.error(" bun plugins/sp/scripts/inline-run-setup.ts --decide --run-id <id> --node <state> --options-json <file> [--spur-bin <path>]");
33
34
  process.exit(2);
34
35
  }
35
36
  var SAFE_RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -151,6 +152,17 @@ async function printFingerprint(taskFile, featureFile, spurBin) {
151
152
  return 0;
152
153
  }
153
154
  var CLOSE_STATUSES = new Set(["done", "failed", "paused"]);
155
+ var TERMINAL_REASONS = new Set([
156
+ "done",
157
+ "paused-operator",
158
+ "failed-check",
159
+ "failed-agent",
160
+ "failed-timeout",
161
+ "failed-guard",
162
+ "cancelled",
163
+ "interrupted",
164
+ "retry-exhausted"
165
+ ]);
154
166
  var ACTION_STATUSES = new Set(["done", "failed"]);
155
167
  function runRecordLogPath(runDir, runId) {
156
168
  const markdownPath = join(runDir, `${runId}.md`);
@@ -187,7 +199,7 @@ async function runTraceMode(input) {
187
199
  const detail = failure;
188
200
  appendTraceFailureLine(input.runId, `trace-emission-failed operation=${detail.operation ?? operation} run=${input.runId}: ${detail.error ?? "unknown error"}`);
189
201
  });
190
- const result = input.close ? await writer.closeRun(input.runId, input.status) : await writer.recordAction({
202
+ const result = input.close ? await writer.closeRun(input.runId, input.status, undefined, input.reason) : await writer.recordAction({
191
203
  runId: input.runId,
192
204
  node: input.node,
193
205
  kind: input.kind,
@@ -215,6 +227,41 @@ async function runTraceMode(input) {
215
227
  projectDb?.close();
216
228
  }
217
229
  }
230
+ async function runDecideMode(input) {
231
+ const decideFailed = (error) => {
232
+ process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error })}
233
+ `);
234
+ return 1;
235
+ };
236
+ let outcome;
237
+ try {
238
+ const { entry, portable } = resolveAppEntry(input.spurBin);
239
+ const app = await import(entry);
240
+ const lib = await import(fileURLToPath(new URL("../lib/inline-run.generated.mjs", import.meta.url)));
241
+ const enabled = await lib.resolveDecideDecisionMakerEnabled(process.cwd(), portable ? { embeddedSchemas: lib.EMBEDDED_SPUR_SCHEMAS } : undefined);
242
+ outcome = await app.runDecideForInlineRun({
243
+ workdir: process.cwd(),
244
+ optionsFile: input.optionsFile,
245
+ enabled
246
+ });
247
+ } catch (error) {
248
+ return decideFailed(error instanceof Error ? error.message : String(error));
249
+ }
250
+ if (!outcome.ok)
251
+ return decideFailed(outcome.error ?? "decide failed without an error message");
252
+ process.stdout.write(`${JSON.stringify({ ok: true, runId: input.runId, node: input.node, ...outcome })}
253
+ `);
254
+ return await runTraceMode({
255
+ runId: input.runId,
256
+ close: false,
257
+ node: input.node,
258
+ kind: "decide",
259
+ status: "done",
260
+ ok: true,
261
+ durationMs: outcome.durationMs ?? 0,
262
+ spurBin: input.spurBin
263
+ });
264
+ }
218
265
  async function main() {
219
266
  if (!process.versions.bun) {
220
267
  const child = spawnSync("bun", [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
@@ -231,9 +278,12 @@ async function main() {
231
278
  let featureFile = "";
232
279
  let action = false;
233
280
  let close = false;
281
+ let decide = false;
282
+ let optionsJson = "";
234
283
  let node = "";
235
284
  let kind = "";
236
285
  let status = "";
286
+ let reason = "";
237
287
  let okRaw = "";
238
288
  let durationRaw = "";
239
289
  let spurBin = getEnvVar("SPUR_BIN") ?? "";
@@ -253,12 +303,18 @@ async function main() {
253
303
  action = true;
254
304
  else if (argv[i] === "--close")
255
305
  close = true;
306
+ else if (argv[i] === "--decide")
307
+ decide = true;
308
+ else if (argv[i] === "--options-json")
309
+ optionsJson = argv[++i] ?? "";
256
310
  else if (argv[i] === "--node")
257
311
  node = argv[++i] ?? "";
258
312
  else if (argv[i] === "--kind")
259
313
  kind = argv[++i] ?? "";
260
314
  else if (argv[i] === "--status")
261
315
  status = argv[++i] ?? "";
316
+ else if (argv[i] === "--reason")
317
+ reason = argv[++i] ?? "";
262
318
  else if (argv[i] === "--ok")
263
319
  okRaw = argv[++i] ?? "";
264
320
  else if (argv[i] === "--duration-ms")
@@ -271,6 +327,15 @@ async function main() {
271
327
  usage();
272
328
  process.exit(await printFingerprint(taskFile, featureFile, spurBin));
273
329
  }
330
+ if (decide) {
331
+ if (action || close || fingerprint || file !== "" || taskFile !== "")
332
+ usage();
333
+ if (runId.trim() === "" || node.trim() === "" || optionsJson.trim() === "")
334
+ usage();
335
+ if (!SAFE_RUN_ID_RE.test(runId))
336
+ refuseUnsafeRunId(runId);
337
+ process.exit(await runDecideMode({ runId, node, optionsFile: optionsJson, spurBin }));
338
+ }
274
339
  if (action || close) {
275
340
  if (action && close)
276
341
  usage();
@@ -281,6 +346,12 @@ async function main() {
281
346
  if (close) {
282
347
  if (!CLOSE_STATUSES.has(status))
283
348
  usage();
349
+ if (reason.trim() === "") {
350
+ if (status === "failed")
351
+ usage();
352
+ } else if (!TERMINAL_REASONS.has(reason)) {
353
+ usage();
354
+ }
284
355
  process.exit(await runTraceMode({
285
356
  runId,
286
357
  close: true,
@@ -289,7 +360,8 @@ async function main() {
289
360
  status,
290
361
  ok: true,
291
362
  durationMs: 0,
292
- spurBin
363
+ spurBin,
364
+ ...reason.trim() === "" ? {} : { reason }
293
365
  }));
294
366
  }
295
367
  if (node.trim() === "" || kind.trim() === "")
@@ -31,8 +31,9 @@
31
31
  * bun plugins/sp/scripts/inline-run-setup.ts --run-id <id> --file <definition> [--spur-bin <path>]
32
32
  * bun plugins/sp/scripts/inline-run-setup.ts --fingerprint --task-file <path> [--feature-file <path>] [--spur-bin <path>]
33
33
  * bun plugins/sp/scripts/inline-run-setup.ts --action --run-id <id> --node <state> --kind <kind> \
34
+ * bun plugins/sp/scripts/inline-run-setup.ts --decide --run-id <id> --node <state> --options-json <file> [--spur-bin <path>]
34
35
  * --status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]
35
- * bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--spur-bin <path>]
36
+ * bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]
36
37
  *
37
38
  * The `--fingerprint` mode prints the engine's proof-input digest for the given spec files and
38
39
  * creates nothing (task 0862 R5).
@@ -44,6 +45,12 @@
44
45
  * `.spur/run/<run-id>.md` and the script still exits 0 with `{"ok":false}` on stdout, so
45
46
  * observation never wedges the run. `--close` is NOT best-effort: the run-row closure is
46
47
  * bookkeeping, so a missing run row or a persistence failure exits 1 with a named error.
48
+ * The `--decide` mode (task 0941 R5) executes the non-pausing decide action through the SAME
49
+ * app runner the engine registers (`runDecideForInlineRun`), writing the same resultFile row
50
+ * and recording the same `action_runs` trace row (`kind=decide`, best-effort). A degraded
51
+ * decision is still `ok: true` — the run continues — so exit 0 covers every model-level
52
+ * outcome; only an invalid options schema exits 1 (fail closed).
53
+ *
47
54
  * Exit 2 is reserved for usage errors.
48
55
  *
49
56
  * Env: SPUR_BIN
@@ -93,7 +100,10 @@ function usage(): never {
93
100
  '--status <done|failed> --ok <true|false> --duration-ms <n> [--spur-bin <path>]',
94
101
  );
95
102
  console.error(
96
- ' bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--spur-bin <path>]',
103
+ ' bun plugins/sp/scripts/inline-run-setup.ts --close --run-id <id> --status <done|failed|paused> [--reason <terminal-reason>] [--spur-bin <path>]',
104
+ );
105
+ console.error(
106
+ ' bun plugins/sp/scripts/inline-run-setup.ts --decide --run-id <id> --node <state> --options-json <file> [--spur-bin <path>]',
97
107
  );
98
108
  process.exit(2);
99
109
  }
@@ -305,6 +315,24 @@ async function printFingerprint(taskFile: string, featureFile: string, spurBin:
305
315
  /** Terminal statuses the inline driver may declare when closing its run row. */
306
316
  const CLOSE_STATUSES = new Set(['done', 'failed', 'paused']);
307
317
 
318
+ /**
319
+ * 0937 R2: closed terminal-reason vocabulary the driver may declare on `--close`.
320
+ * COPIED from packages/app/src/workflow/terminal-reason.ts — the plugin standalone
321
+ * contract forbids a value import of app code; the parity test asserts the copy
322
+ * equals the app export.
323
+ */
324
+ const TERMINAL_REASONS = new Set([
325
+ 'done',
326
+ 'paused-operator',
327
+ 'failed-check',
328
+ 'failed-agent',
329
+ 'failed-timeout',
330
+ 'failed-guard',
331
+ 'cancelled',
332
+ 'interrupted',
333
+ 'retry-exhausted',
334
+ ]);
335
+
308
336
  /** Finalize statuses — a finish emission is terminal, so only done|failed are valid (0868 #4). */
309
337
  const ACTION_STATUSES = new Set(['done', 'failed']);
310
338
 
@@ -316,6 +344,8 @@ interface TraceModeInput {
316
344
  readonly close: boolean;
317
345
  readonly node: string;
318
346
  readonly kind: string;
347
+ /** Declared terminal reason (0937 R2) — validated against TERMINAL_REASONS before this point. */
348
+ readonly reason?: string;
319
349
  readonly status: string;
320
350
  readonly ok: boolean;
321
351
  readonly durationMs: number;
@@ -397,7 +427,7 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
397
427
  });
398
428
  const result = (
399
429
  input.close
400
- ? await writer.closeRun(input.runId, input.status)
430
+ ? await writer.closeRun(input.runId, input.status, undefined, input.reason)
401
431
  : await writer.recordAction({
402
432
  runId: input.runId,
403
433
  node: input.node,
@@ -432,6 +462,82 @@ async function runTraceMode(input: TraceModeInput): Promise<number> {
432
462
  }
433
463
  }
434
464
 
465
+ /**
466
+ * `--decide` mode (task 0941 R5): execute the non-pausing decide action through the same app
467
+ * function the engine registers (`runDecideForInlineRun` → `DecideActionRunner`), then record
468
+ * the `action_runs` trace row through the shared writer. The decide-enabled switch
469
+ * (`workflow.decideDecisionMaker`) is resolved HERE at the driver boundary (ADR-082) through
470
+ * the bridged facade derivation and passed into the app runner as an explicit parameter —
471
+ * app services never load Spur config. Degraded outcomes (`disabled`,
472
+ * `no-backend`, `error`, `timeout`, `low-confidence`) are normal — the value is the declared
473
+ * default and the run continues — so they print `ok: true` and exit 0. Only an invalid options
474
+ * schema, an unreadable options file, or a failed config load fails closed with exit 1.
475
+ */
476
+ async function runDecideMode(input: {
477
+ runId: string;
478
+ node: string;
479
+ optionsFile: string;
480
+ spurBin: string;
481
+ }): Promise<number> {
482
+ type DecideOutcome = {
483
+ ok: boolean;
484
+ error?: string;
485
+ value?: string;
486
+ degraded?: boolean;
487
+ reason?: string;
488
+ backend?: string | null;
489
+ confidence?: number | null;
490
+ resultFile?: string;
491
+ durationMs?: number;
492
+ };
493
+ const decideFailed = (error: string): number => {
494
+ process.stdout.write(`${JSON.stringify({ ok: false, runId: input.runId, error })}\n`);
495
+ return 1;
496
+ };
497
+ let outcome: DecideOutcome;
498
+ try {
499
+ const { entry, portable } = resolveAppEntry(input.spurBin);
500
+ const app = (await import(entry)) as {
501
+ runDecideForInlineRun: (input2: {
502
+ workdir: string;
503
+ optionsFile: string;
504
+ enabled: boolean;
505
+ }) => Promise<DecideOutcome>;
506
+ };
507
+ // Config switch at the driver boundary (ADR-082 / task 0941 gate fix): the flag comes
508
+ // from the bridged facade derivation in the generated lib — the same committed bundle
509
+ // the inventory flow imports — never a @gobing-ai/* value import. Embedded schemas
510
+ // ride along only in the portable layout, mirroring the setup mode's load posture.
511
+ const lib = (await import(
512
+ fileURLToPath(new URL('../lib/inline-run.generated.mjs', import.meta.url))
513
+ )) as typeof import('../lib/inline-run.generated.mjs');
514
+ const enabled = await lib.resolveDecideDecisionMakerEnabled(
515
+ process.cwd(),
516
+ portable ? { embeddedSchemas: lib.EMBEDDED_SPUR_SCHEMAS } : undefined,
517
+ );
518
+ outcome = await app.runDecideForInlineRun({
519
+ workdir: process.cwd(),
520
+ optionsFile: input.optionsFile,
521
+ enabled,
522
+ });
523
+ } catch (error) {
524
+ return decideFailed(error instanceof Error ? error.message : String(error));
525
+ }
526
+ if (!outcome.ok) return decideFailed(outcome.error ?? 'decide failed without an error message');
527
+ process.stdout.write(`${JSON.stringify({ ok: true, runId: input.runId, node: input.node, ...outcome })}\n`);
528
+ // Trace row is best-effort, exactly like --action: an emission failure never wedges the run.
529
+ return await runTraceMode({
530
+ runId: input.runId,
531
+ close: false,
532
+ node: input.node,
533
+ kind: 'decide',
534
+ status: 'done',
535
+ ok: true,
536
+ durationMs: outcome.durationMs ?? 0,
537
+ spurBin: input.spurBin,
538
+ });
539
+ }
540
+
435
541
  async function main(): Promise<void> {
436
542
  // The portable Node twin has no workspace imports; SQLite still uses Spur's existing Bun runtime.
437
543
  if (!process.versions.bun) {
@@ -448,9 +554,12 @@ async function main(): Promise<void> {
448
554
  let featureFile = '';
449
555
  let action = false;
450
556
  let close = false;
557
+ let decide = false;
558
+ let optionsJson = '';
451
559
  let node = '';
452
560
  let kind = '';
453
561
  let status = '';
562
+ let reason = '';
454
563
  let okRaw = '';
455
564
  let durationRaw = '';
456
565
  let spurBin = getEnvVar('SPUR_BIN') ?? '';
@@ -463,9 +572,12 @@ async function main(): Promise<void> {
463
572
  else if (argv[i] === '--feature-file') featureFile = argv[++i] ?? '';
464
573
  else if (argv[i] === '--action') action = true;
465
574
  else if (argv[i] === '--close') close = true;
575
+ else if (argv[i] === '--decide') decide = true;
576
+ else if (argv[i] === '--options-json') optionsJson = argv[++i] ?? '';
466
577
  else if (argv[i] === '--node') node = argv[++i] ?? '';
467
578
  else if (argv[i] === '--kind') kind = argv[++i] ?? '';
468
579
  else if (argv[i] === '--status') status = argv[++i] ?? '';
580
+ else if (argv[i] === '--reason') reason = argv[++i] ?? '';
469
581
  else if (argv[i] === '--ok') okRaw = argv[++i] ?? '';
470
582
  else if (argv[i] === '--duration-ms') durationRaw = argv[++i] ?? '';
471
583
  else if (argv[i] === '--spur-bin') spurBin = argv[++i] ?? spurBin;
@@ -481,12 +593,29 @@ async function main(): Promise<void> {
481
593
  // ADR-117 emission modes (task 0868): the inline driver reports one completed action
482
594
  // boundary, or closes its run row at the declared terminal state. Both share the run-id
483
595
  // filename guard, the app-entry resolution chain and the best-effort failure contract.
596
+ // Non-pausing decide action (0941 R5): --decide is mutually exclusive with the run-setup,
597
+ // fingerprint, and trace modes; it requires the run id (filename-guarded), the state id
598
+ // for the trace row, and the options JSON file.
599
+ if (decide) {
600
+ if (action || close || fingerprint || file !== '' || taskFile !== '') usage();
601
+ if (runId.trim() === '' || node.trim() === '' || optionsJson.trim() === '') usage();
602
+ if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId);
603
+ process.exit(await runDecideMode({ runId, node, optionsFile: optionsJson, spurBin }));
604
+ }
605
+
484
606
  if (action || close) {
485
607
  if (action && close) usage();
486
608
  if (runId.trim() === '' || status.trim() === '') usage();
487
609
  if (!SAFE_RUN_ID_RE.test(runId)) refuseUnsafeRunId(runId);
488
610
  if (close) {
489
611
  if (!CLOSE_STATUSES.has(status)) usage();
612
+ // 0937 R2: a failed close requires a declared reason, and any declared reason
613
+ // must be a closed-enum value — both fail loudly BEFORE any write happens.
614
+ if (reason.trim() === '') {
615
+ if (status === 'failed') usage();
616
+ } else if (!TERMINAL_REASONS.has(reason)) {
617
+ usage();
618
+ }
490
619
  process.exit(
491
620
  await runTraceMode({
492
621
  runId,
@@ -497,6 +626,7 @@ async function main(): Promise<void> {
497
626
  ok: true,
498
627
  durationMs: 0,
499
628
  spurBin,
629
+ ...(reason.trim() === '' ? {} : { reason }),
500
630
  }),
501
631
  );
502
632
  }