@agjs/tsforge 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.2.6",
4
+ "version": "0.2.7",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
package/src/cli.ts CHANGED
@@ -2,13 +2,7 @@
2
2
  import { join, isAbsolute } from "node:path";
3
3
  import { appendFileSync, mkdirSync } from "node:fs";
4
4
  import { createInterface } from "node:readline/promises";
5
- import {
6
- runTask,
7
- RUN_STATUS,
8
- Session,
9
- PLAN_APPROVED_NOTE,
10
- LOOP_LIMITS,
11
- } from "./loop";
5
+ import { runTask, RUN_STATUS, Session, PLAN_APPROVED_NOTE } from "./loop";
12
6
  import {
13
7
  PROVIDER_LIMITS,
14
8
  PROVIDER_DEFAULTS,
@@ -997,9 +991,10 @@ async function repl(args: ICliArgs): Promise<number> {
997
991
  session.setFix(buildWebFix(framework));
998
992
  session.setIncrementalCheck(buildWebTscCheck());
999
993
  session.guide(webGuidance(framework));
1000
- // A from-scratch web build needs the big turn budget the default cap was
1001
- // measured to cut a todo app off mid-write, before its gate ever ran.
1002
- session.setMaxTurns(LOOP_LIMITS.webMaxTurns);
994
+ // A from-scratch web build legitimately needs many turns. Don't pin a low
995
+ // ceiling here the interactive session already rides the high runaway
996
+ // backstop (interactiveBackstopTurns) and stops on the progress guards, so a
997
+ // long, converging build is never cut off mid-write.
1003
998
  };
1004
999
 
1005
1000
  // The `scaffold_web` tool invokes this when the AGENT decides to build a web app
@@ -31,17 +31,31 @@ export const LOOP_LIMITS = {
31
31
  */
32
32
  maxEditLines: 50,
33
33
  /**
34
- * Give up after the gate shows the EXACT same error set this many edits in a
35
- * row (genuine spinning). Generous; the turn cap is the real backstop.
34
+ * Give up after the gate shows the EXACT same error SET this many edits in a
35
+ * row (genuine spinning) the coarse net. The finer `samePersist` guard
36
+ * (below) usually trips first; this catches a stable-but-shuffling set.
36
37
  */
37
- gateStuckRepeats: 10,
38
+ gateStuckRepeats: 6,
39
+ /**
40
+ * The PRIMARY no-progress guard: give up when a SINGLE error — same (file,rule)
41
+ * key — survives this many consecutive gate cycles, i.e. the model keeps failing
42
+ * at the same thing N attempts running, even while OTHER errors churn around it.
43
+ * This (not a raw turn count) is how the loop decides it's genuinely stuck.
44
+ */
45
+ samePersist: 5,
38
46
  /**
39
47
  * Above this many chars of combined file content, the seed prompt sends a
40
48
  * navigable project MAP instead of full dumps. Below it, full dumps.
41
49
  */
42
50
  mapThresholdChars: 12000,
43
- /** Hard backstop on model turns per task. */
51
+ /** Hard backstop on model turns per HEADLESS task (eval/cron — no human to
52
+ * intervene). Interactive sessions use `interactiveBackstopTurns` instead. */
44
53
  maxTurns: 40,
54
+ /** Interactive runaway safety only — NOT the primary stop. A human is present
55
+ * and can interrupt, and the progress guards (`samePersist` / `gateStuckRepeats`)
56
+ * pull the agent out the moment it stops converging, so this is set high enough
57
+ * that normal long, productive back-and-forth never trips it. */
58
+ interactiveBackstopTurns: 250,
45
59
  /** Turn budget for a from-scratch WEB build (heavy gate, many files): used by
46
60
  * headless web builds AND applied when an interactive session scaffolds via
47
61
  * `scaffold_web` — measured: a todo app was still WRITING components when it
@@ -37,6 +37,8 @@ export interface ILoopEvent {
37
37
  * reads to tell a type error from a lint rule, not just a count. */
38
38
  rules?: readonly string[];
39
39
  passed?: boolean;
40
+ /** For `stuck` events: a human-readable blocker diagnosis. */
41
+ detail?: string;
40
42
  file?: string;
41
43
  /** For `create` events: the new file's content (rendered as a code block). */
42
44
  content?: string;
@@ -82,6 +84,9 @@ export interface IRunResult {
82
84
  /** Model turns used. */
83
85
  cycles: number;
84
86
  reason?: StuckReason;
87
+ /** When stuck: a human-readable blocker diagnosis (the persistent rule/file +
88
+ * last error) so an interactive session can hand back something actionable. */
89
+ detail?: string;
85
90
  /** Edits/creates applied to editable files (measure edit churn). */
86
91
  edits?: number;
87
92
  /** Times an edit RAISED the gate error count (regressions). */
@@ -109,6 +109,21 @@
109
109
  "bad": "[1, 2, 3].reduce((arr, num) => arr.concat(num * 2), [] as number[]);\n\n['a', 'b'].reduce(\n (accumulator, name) => ({\n ...accumulator,\n [name]: true,\n }),\n {} as Record<string, boolean>,",
110
110
  "good": "[1, 2, 3].reduce<number[]>((arr, num) => arr.concat(num * 2), []);\n\n['a', 'b'].reduce<Record<string, boolean>>(\n (accumulator, name) => ({\n ...accumulator,\n [name]: true,\n }),\n {},"
111
111
  },
112
+ "tsforge/id-param-requires-object-authz": {
113
+ "what": "Warn when a handler reads `params.id` and queries the database without an authorization check in the same function.",
114
+ "bad": "// Example that violates the rule",
115
+ "good": "// Corrected version"
116
+ },
117
+ "tsforge/mutating-route-requires-authz": {
118
+ "what": "POST/PUT/PATCH/DELETE route handlers must call an authorization helper before mutating state.",
119
+ "bad": "// Example that violates the rule",
120
+ "good": "// Corrected version"
121
+ },
122
+ "tsforge/server-action-requires-authz": {
123
+ "what": "Files with `\"use server\"` that perform database mutations must call an authorization helper in the same function.",
124
+ "bad": "// Example that violates the rule",
125
+ "good": "// Corrected version"
126
+ },
112
127
  "tsforge/job-name-must-be-constant": {
113
128
  "what": "Disallow string-literal job names in `<queue>.add(name, ...)` calls — use a constant identifier so all consumers share one source of truth.",
114
129
  "bad": "// Example that violates the rule",
@@ -219,6 +234,16 @@
219
234
  "bad": "// Example that violates the rule",
220
235
  "good": "// Corrected version"
221
236
  },
237
+ "tsforge/update-delete-account-scoped-must-filter-scope": {
238
+ "what": "Require Drizzle `.update()` / `.delete()` against account-scoped tables to filter by a scope column in `.where()`.",
239
+ "bad": "// Example that violates the rule",
240
+ "good": "// Corrected version"
241
+ },
242
+ "tsforge/update-delete-must-have-where": {
243
+ "what": "Require every Drizzle `.update()` and `.delete()` call to include a `.where()` clause — unscoped writes affect every row.",
244
+ "bad": "// Example that violates the rule",
245
+ "good": "// Corrected version"
246
+ },
222
247
  "tsforge/consistent-status-via-set": {
223
248
  "what": "Inside Elysia route handlers, set HTTP status via `set.status = N`, not by returning a `new Response(body, { status: N })`.",
224
249
  "bad": "// Example that violates the rule",
@@ -319,11 +344,26 @@
319
344
  "bad": "// Example that violates the rule",
320
345
  "good": "// Corrected version"
321
346
  },
347
+ "tsforge/auth-cookie-must-set-maxage-or-expires": {
348
+ "what": "Auth-cookie writes should set `maxAge` or `expires` so session cookies do not live forever by default.",
349
+ "bad": "// Example that violates the rule",
350
+ "good": "// Corrected version"
351
+ },
352
+ "tsforge/auth-cookie-must-set-samesite": {
353
+ "what": "Auth-cookie writes must set `sameSite` (`strict` or `lax`) — missing SameSite allows cross-site cookie delivery.",
354
+ "bad": "// Example that violates the rule",
355
+ "good": "// Corrected version"
356
+ },
322
357
  "tsforge/bcrypt-rounds-min": {
323
358
  "what": "Disallow `bcrypt.hash` / `bcrypt.hashSync` calls with a numeric-literal rounds value below the configured minimum (default 10).",
324
359
  "bad": "// Example that violates the rule",
325
360
  "good": "// Corrected version"
326
361
  },
362
+ "tsforge/jwt-must-verify-not-decode": {
363
+ "what": "Disallow `jwt.decode` / `decodeJwt` — decoding without verification accepts forged tokens. Use `jwt.verify` or `jwtVerify` instead.",
364
+ "bad": "// Example that violates the rule",
365
+ "good": "// Corrected version"
366
+ },
327
367
  "tsforge/no-import-build-output": {
328
368
  "what": "Disallow importing from build/output directories within the project. Source must import source, not compiled artifacts, to avoid stale-code drift and broken module boundaries.",
329
369
  "bad": "// Example that violates the rule",
@@ -354,6 +394,11 @@
354
394
  "bad": "// Example that violates the rule",
355
395
  "good": "// Corrected version"
356
396
  },
397
+ "tsforge/mutation-should-revalidate-cache": {
398
+ "what": "After database mutations in server actions or route handlers, call `revalidatePath` or `revalidateTag` so cached pages reflect the change.",
399
+ "bad": "// Example that violates the rule",
400
+ "good": "// Corrected version"
401
+ },
357
402
  "tsforge/no-html-img-element": {
358
403
  "what": "Prefer next/image over raw <img> elements for optimized responsive images and Core Web Vitals.",
359
404
  "bad": "// Example that violates the rule",
@@ -374,6 +419,11 @@
374
419
  "bad": "// Example that violates the rule",
375
420
  "good": "// Corrected version"
376
421
  },
422
+ "tsforge/no-secret-props-to-client": {
423
+ "what": "Warn when Server Components pass secret-looking props to JSX — values may cross the client boundary.",
424
+ "bad": "// Example that violates the rule",
425
+ "good": "// Corrected version"
426
+ },
377
427
  "tsforge/no-sensitive-next-public-env": {
378
428
  "what": "Disallow NEXT_PUBLIC_* env vars whose names suggest secrets — public build-time vars are visible in the client bundle.",
379
429
  "bad": "// Example that violates the rule",
@@ -384,6 +434,16 @@
384
434
  "bad": "// Example that violates the rule",
385
435
  "good": "// Corrected version"
386
436
  },
437
+ "tsforge/server-action-requires-authz-and-validation": {
438
+ "what": "Server actions (`\"use server\"`) that mutate the database must call authorization helpers and validate input with `.parse()` / `.safeParse()`.",
439
+ "bad": "// Example that violates the rule",
440
+ "good": "// Corrected version"
441
+ },
442
+ "tsforge/server-only-modules-import-server-only": {
443
+ "what": "App-router server modules must import `\"server-only\"` so accidental client bundling fails at build time.",
444
+ "bad": "// Example that violates the rule",
445
+ "good": "// Corrected version"
446
+ },
387
447
  "tsforge/pkce-required-for-oidc": {
388
448
  "what": "OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`.",
389
449
  "bad": "// Example that violates the rule",
@@ -399,8 +459,13 @@
399
459
  "bad": "// Example that violates the rule",
400
460
  "good": "// Corrected version"
401
461
  },
462
+ "tsforge/component-file-purity": {
463
+ "what": "A component .tsx contains only imports and the component itself — types go to <feature>.types.ts, constants to <feature>.constants.ts, helpers to src/lib",
464
+ "bad": "// Example that violates the rule",
465
+ "good": "// Corrected version"
466
+ },
402
467
  "tsforge/component-folder-structure": {
403
- "what": "Enforce required sibling files in component folders (hooks, types, stories, test, index)",
468
+ "what": "A component .tsx must live in src/views/<Feature>/components/ (feature component), src/components/ui/ (shared primitive), or be the view root src/views/<Feature>/index.tsx",
404
469
  "bad": "// Example that violates the rule",
405
470
  "good": "// Corrected version"
406
471
  },
@@ -469,6 +534,31 @@
469
534
  "bad": "// Example that violates the rule",
470
535
  "good": "// Corrected version"
471
536
  },
537
+ "tsforge/no-prototype-polluting-merge": {
538
+ "what": "Disallow merging request body/query/params into objects — enables prototype pollution.",
539
+ "bad": "// Example that violates the rule",
540
+ "good": "// Corrected version"
541
+ },
542
+ "tsforge/no-user-controlled-fetch-url": {
543
+ "what": "Disallow fetch/axios requests to non-literal URLs — dynamic URLs enable SSRF.",
544
+ "bad": "// Example that violates the rule",
545
+ "good": "// Corrected version"
546
+ },
547
+ "tsforge/no-user-controlled-redirect": {
548
+ "what": "Disallow redirects to non-literal URLs — user-controlled redirects enable open redirects.",
549
+ "bad": "// Example that violates the rule",
550
+ "good": "// Corrected version"
551
+ },
552
+ "tsforge/upload-must-set-limits": {
553
+ "what": "Multipart upload handlers should declare `limits` or `maxFileSize` to bound request size.",
554
+ "bad": "// Example that violates the rule",
555
+ "good": "// Corrected version"
556
+ },
557
+ "tsforge/webhook-must-verify-signature-before-parse": {
558
+ "what": "Webhook handlers must verify signatures before calling `.json()` on the request body.",
559
+ "bad": "// Example that violates the rule",
560
+ "good": "// Corrected version"
561
+ },
472
562
  "tsforge/catch-must-handle": {
473
563
  "what": "Catch blocks must log, rethrow, or propagate errors — not silently return empty defaults on failure.",
474
564
  "bad": "// Example that violates the rule",
@@ -499,6 +589,16 @@
499
589
  "bad": "// Example that violates the rule",
500
590
  "good": "// Corrected version"
501
591
  },
592
+ "tsforge/caught-error-log-requires-cause": {
593
+ "what": "When logging a caught error, include a `cause` field in the structured payload so downstream tools preserve the error chain.",
594
+ "bad": "// Example that violates the rule",
595
+ "good": "// Corrected version"
596
+ },
597
+ "tsforge/logger-not-console": {
598
+ "what": "Service modules should use the structured logger instead of `console.*` — console output is unstructured and hard to search.",
599
+ "bad": "// Example that violates the rule",
600
+ "good": "// Corrected version"
601
+ },
502
602
  "tsforge/mask-pii-fields": {
503
603
  "what": "Disallow unmasked PII (email, phone, password, token, ...) in structured-logger payloads — the #1 way data leaks quietly.",
504
604
  "bad": "// Example that violates the rule",
@@ -519,14 +619,49 @@
519
619
  "bad": "// Example that violates the rule",
520
620
  "good": "// Corrected version"
521
621
  },
622
+ "tsforge/fake-timers-must-be-restored": {
623
+ "what": "When a test file calls `useFakeTimers()`, it must also call `useRealTimers()` so later tests are not affected.",
624
+ "bad": "// Example that violates the rule",
625
+ "good": "// Corrected version"
626
+ },
627
+ "tsforge/no-conditional-expect": {
628
+ "what": "Disallow `expect()` inside conditionals — tests must fail when assertions are skipped.",
629
+ "bad": "// Example that violates the rule",
630
+ "good": "// Corrected version"
631
+ },
522
632
  "tsforge/no-focused-tests": {
523
633
  "what": "Disallow focused tests (`test.only`, `it.only`, `fdescribe`, ...) — the canonical 'I forgot to remove this before committing' leak.",
524
634
  "bad": "// Example that violates the rule",
525
635
  "good": "// Corrected version"
526
636
  },
637
+ "tsforge/no-real-network-in-unit-tests": {
638
+ "what": "Unit tests should not perform real network I/O — mock HTTP clients or move the test to an integration suite.",
639
+ "bad": "// Example that violates the rule",
640
+ "good": "// Corrected version"
641
+ },
527
642
  "tsforge/test-file-mirrors-source": {
528
643
  "what": "Every test file under `tests/` must mirror a source file under `src/`. Catches orphaned tests left behind after refactors and renames.",
529
644
  "bad": "// Example that violates the rule",
530
645
  "good": "// Corrected version"
646
+ },
647
+ "tsforge/exported-functions-require-return-type": {
648
+ "what": "Exported functions should declare an explicit return type at module boundaries.",
649
+ "bad": "// Example that violates the rule",
650
+ "good": "// Corrected version"
651
+ },
652
+ "tsforge/fetch-must-check-ok": {
653
+ "what": "HTTP fetch responses must check `.ok` or status before calling `.json()`.",
654
+ "bad": "// Example that violates the rule",
655
+ "good": "// Corrected version"
656
+ },
657
+ "tsforge/json-parse-must-validate": {
658
+ "what": "Disallow bare JSON.parse on untrusted input — validate through a schema library.",
659
+ "bad": "// Example that violates the rule",
660
+ "good": "// Corrected version"
661
+ },
662
+ "tsforge/no-unsafe-boundary-cast": {
663
+ "what": "Disallow type assertions immediately after parsing untrusted boundary input.",
664
+ "bad": "// Example that violates the rule",
665
+ "good": "// Corrected version"
531
666
  }
532
667
  }
package/src/loop/run.ts CHANGED
@@ -330,6 +330,7 @@ export async function runTask(
330
330
  const state: ILoopState = {
331
331
  prevGateErrors: red.errors,
332
332
  gateNoProgress: 0,
333
+ errorAge: new Map(),
333
334
  lastGateCount: -1,
334
335
  edits: 0,
335
336
  regressions: 0,
@@ -446,6 +446,7 @@ export class Session {
446
446
  this.state = {
447
447
  prevGateErrors: [],
448
448
  gateNoProgress: 0,
449
+ errorAge: new Map(),
449
450
  lastGateCount: -1,
450
451
  edits: 0,
451
452
  regressions: 0,
@@ -698,8 +699,13 @@ export class Session {
698
699
  */
699
700
  async send(text: string, opts: ISendOptions = {}): Promise<ISendResult> {
700
701
  const { ctx, report } = this;
702
+ // Interactive ceiling is a RUNAWAY backstop, not the primary stop — the
703
+ // progress guards (samePersist / gateNoProgress) pull the agent out the moment
704
+ // it stops converging. Set high so normal long back-and-forth never trips it.
701
705
  const maxTurns =
702
- this.maxTurnsOverride ?? this.cfg.maxTurns ?? LOOP_LIMITS.maxTurns;
706
+ this.maxTurnsOverride ??
707
+ this.cfg.maxTurns ??
708
+ LOOP_LIMITS.interactiveBackstopTurns;
703
709
  const sendStart = performance.now();
704
710
 
705
711
  // Thread cancellation to the tool `run` commands and the gate (not just the
@@ -1505,7 +1511,7 @@ export class Session {
1505
1511
  kind: "stuck",
1506
1512
  task: SESSION_ID,
1507
1513
  cycles: maxTurns,
1508
- message: `stuck (hit ${maxTurns}-turn cap)`,
1514
+ message: `stuck (hit the ${maxTurns}-turn runaway backstop — progress guards never tripped, which is unusual; re-steer or narrow the task)`,
1509
1515
  });
1510
1516
 
1511
1517
  return { status: "stuck", turns: maxTurns };
package/src/loop/turn.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  sameErrorSet,
9
9
  type ErrorParser,
10
10
  type ErrorSet,
11
+ type IErrorItem,
11
12
  } from "../validate";
12
13
  import { isInScope } from "../lib/scope";
13
14
  import { fileExists, resolveScopeFiles } from "../lib/fs";
@@ -126,6 +127,9 @@ export interface ILoopCtx {
126
127
  export interface ILoopState {
127
128
  prevGateErrors: ErrorSet;
128
129
  gateNoProgress: number;
130
+ /** Per-error-key (file:rule) survival count: how many consecutive gate cycles
131
+ * each error has persisted. Drives the primary `samePersist` no-progress stop. */
132
+ errorAge: Map<string, number>;
129
133
  lastGateCount: number;
130
134
  edits: number;
131
135
  regressions: number;
@@ -688,6 +692,46 @@ function autoFixNotice(files: string[]): string {
688
692
  );
689
693
  }
690
694
 
695
+ /**
696
+ * Advance each error's per-(file:rule) survival count and return the first error
697
+ * that has now persisted for `samePersist` consecutive gate cycles — the model
698
+ * keeps failing at the SAME thing — or null. Rebuilds the map from the CURRENT
699
+ * keys, so a fixed error's age drops out (no stale growth) and an error that
700
+ * comes back later starts fresh. Catches "stuck on X" even while OTHER errors
701
+ * churn around it (which the whole-set `gateNoProgress` guard misses).
702
+ */
703
+ export function trackErrorAges(
704
+ state: ILoopState,
705
+ gateErrors: ErrorSet
706
+ ): IErrorItem | null {
707
+ const next = new Map<string, number>();
708
+ let stuck: IErrorItem | null = null;
709
+
710
+ for (const e of gateErrors) {
711
+ const age = (state.errorAge.get(e.key) ?? 0) + 1;
712
+
713
+ next.set(e.key, age);
714
+
715
+ if (age >= LOOP_LIMITS.samePersist && stuck === null) {
716
+ stuck = e;
717
+ }
718
+ }
719
+
720
+ state.errorAge = next;
721
+
722
+ return stuck;
723
+ }
724
+
725
+ /** The blocker diagnosis surfaced when a single error persists too long — names
726
+ * the rule + file + attempt count + the last message, so an interactive session
727
+ * hands back something the user can act on. */
728
+ export function persistDetail(e: IErrorItem): string {
729
+ const where = e.file !== undefined ? ` in ${e.file}` : "";
730
+ const rule = e.rule ?? "the same error";
731
+
732
+ return `stuck on ${rule}${where} after ${String(LOOP_LIMITS.samePersist)} attempts (last: ${e.message.slice(0, 140)})`;
733
+ }
734
+
691
735
  /**
692
736
  * The deterministic gate — the only authority on "done". Auto-fix, run the
693
737
  * optional fix command, validate, and return a terminal result (done/stuck) or
@@ -817,17 +861,47 @@ export async function settleGate(
817
861
  };
818
862
  }
819
863
 
864
+ // PRIMARY no-progress stop: the model keeps failing at the SAME (file,rule)
865
+ // for `samePersist` cycles running — even if other errors churn. Hand back a
866
+ // concrete blocker rather than spinning to a raw turn cap.
867
+ const persisted = trackErrorAges(state, gateErrors);
868
+
869
+ if (persisted !== null) {
870
+ const detail = persistDetail(persisted);
871
+
872
+ report({
873
+ kind: "stuck",
874
+ task: task.id,
875
+ cycles: turn,
876
+ detail,
877
+ message: `task ${task.id}: ${detail}`,
878
+ });
879
+
880
+ return {
881
+ task: task.id,
882
+ redConfirmed: true,
883
+ status: RUN_STATUS.stuck,
884
+ cycles: turn,
885
+ reason: STUCK_REASON.stalled,
886
+ detail,
887
+ };
888
+ }
889
+
890
+ // Coarser secondary net: the WHOLE error set unchanged this many cycles.
820
891
  state.gateNoProgress = sameErrorSet(state.prevGateErrors, gateErrors)
821
892
  ? state.gateNoProgress + 1
822
893
  : 0;
823
894
  state.prevGateErrors = gateErrors;
824
895
 
825
896
  if (state.gateNoProgress >= LOOP_LIMITS.gateStuckRepeats) {
897
+ const detail = `gate unchanged ${String(LOOP_LIMITS.gateStuckRepeats)} cycles (${String(gateErrors.length)} error(s) not converging)`;
898
+
826
899
  report({
827
900
  kind: "stuck",
828
901
  task: task.id,
829
902
  cycles: turn,
830
- message: `task ${task.id}: stuck (gate unchanged ${LOOP_LIMITS.gateStuckRepeats}x)`,
903
+ detail,
904
+ message: `task ${task.id}: stuck — ${detail}`,
831
905
  });
832
906
 
833
907
  return {
@@ -836,6 +910,7 @@ export async function settleGate(
836
910
  status: RUN_STATUS.stuck,
837
911
  cycles: turn,
838
912
  reason: STUCK_REASON.stalled,
913
+ detail,
839
914
  };
840
915
  }
841
916