@gajae-code/coding-agent 0.12.5 → 0.12.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +36 -1
  4. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  6. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  7. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  8. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  9. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  10. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  11. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  12. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  13. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  14. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  15. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
  16. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  17. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/sdk/broker/broker.d.ts +18 -0
  20. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  21. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  22. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -3
  23. package/dist/types/sdk/bus/index.d.ts +4 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  27. package/dist/types/session/agent-session.d.ts +5 -4
  28. package/dist/types/session/attempt-record-store.d.ts +19 -0
  29. package/dist/types/session/session-manager.d.ts +1 -0
  30. package/dist/types/session/session-storage.d.ts +8 -0
  31. package/dist/types/task/receipt.d.ts +1 -0
  32. package/dist/types/task/types.d.ts +43 -0
  33. package/dist/types/tools/subagent.d.ts +15 -0
  34. package/dist/types/tools/todo-write.d.ts +3 -0
  35. package/package.json +8 -8
  36. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  37. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  38. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  39. package/src/async/job-manager.ts +430 -46
  40. package/src/config/model-profiles.ts +8 -0
  41. package/src/config/settings-schema.ts +17 -3
  42. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  43. package/src/extensibility/extensions/runner.ts +80 -6
  44. package/src/extensibility/extensions/wrapper.ts +22 -15
  45. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  46. package/src/gjc-runtime/launch-tmux.ts +309 -293
  47. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  48. package/src/gjc-runtime/psmux-detect.ts +52 -6
  49. package/src/gjc-runtime/team-launch.ts +50 -2
  50. package/src/gjc-runtime/team-runtime.ts +716 -251
  51. package/src/gjc-runtime/team-store.ts +25 -7
  52. package/src/gjc-runtime/team-workers.ts +161 -1
  53. package/src/gjc-runtime/tmux-common.ts +16 -4
  54. package/src/gjc-runtime/tmux-gc.ts +37 -5
  55. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  56. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  57. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  58. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  59. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  60. package/src/gjc-runtime/ultragoal-runtime.ts +360 -156
  61. package/src/internal-urls/docs-index.generated.ts +4 -4
  62. package/src/internal-urls/local-protocol.ts +39 -18
  63. package/src/modes/acp/acp-agent.ts +27 -13
  64. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  65. package/src/modes/components/plugin-settings.ts +5 -0
  66. package/src/modes/components/settings-selector.ts +4 -0
  67. package/src/modes/controllers/selector-controller.ts +1 -0
  68. package/src/prompts/tools/monitor.md +4 -4
  69. package/src/sdk/broker/broker.ts +93 -10
  70. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  71. package/src/sdk/broker/lifecycle.ts +691 -92
  72. package/src/sdk/bus/chat-daemon-control.ts +30 -6
  73. package/src/sdk/bus/index.ts +23 -6
  74. package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
  75. package/src/sdk/bus/telegram-daemon.ts +283 -44
  76. package/src/sdk/bus/topic-registry.ts +133 -10
  77. package/src/sdk/session.ts +7 -6
  78. package/src/session/agent-session.ts +752 -339
  79. package/src/session/attempt-record-store.ts +106 -0
  80. package/src/session/internal/managed-session-scope.ts +252 -67
  81. package/src/session/internal/managed-session-storage.ts +1 -0
  82. package/src/session/session-manager.ts +7 -0
  83. package/src/session/session-storage.ts +146 -24
  84. package/src/task/index.ts +230 -19
  85. package/src/task/provider-retry-status.ts +4 -1
  86. package/src/task/receipt.ts +2 -0
  87. package/src/task/types.ts +10 -0
  88. package/src/tools/monitor.ts +7 -1
  89. package/src/tools/output-meta.ts +10 -2
  90. package/src/tools/subagent.ts +97 -75
  91. package/src/tools/todo-write.ts +47 -0
@@ -1,6 +1,21 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { ansiToHtml, xterm256Color } from "./capture-sticky-viewport-showcase";
3
+ import {
4
+ SEMANTIC_ANCHOR_DOMAIN,
5
+ STICKY_VIEWPORT_FRAME_TEXT_WITNESS,
6
+ type StickyViewportShowcaseKey,
7
+ semanticAnchorDigest,
8
+ semanticAnchorNamespace,
9
+ } from "../test/fixtures/tui/sticky-viewport-showcase";
10
+ import {
11
+ ansiToHtml,
12
+ captureProvenance,
13
+ committedBlobSha256,
14
+ gitObjectType,
15
+ PROVENANCE_DIFF_SCOPE,
16
+ resolveRepositoryPath,
17
+ xterm256Color,
18
+ } from "./capture-sticky-viewport-showcase";
4
19
 
5
20
  const KEYS = [
6
21
  "live-overflow/80x24/unicode-color",
@@ -64,6 +79,48 @@ const INDEPENDENT_REVIEW_KEYS = [
64
79
  const INDEPENDENT_REVIEW_RESULT_KEYS = ["key", "result", "notes", "artifact_checks"] as const;
65
80
  const INDEPENDENT_REVIEW_DEFECT_KEYS = ["description", "accepted"] as const;
66
81
  const ARTIFACT_CHECK_KEYS = ["terminal_txt", "terminal_ansi_txt", "terminal_html", "metadata_json"] as const;
82
+ const SEMANTIC_ANCHOR_KEYS = [
83
+ "domain",
84
+ "id",
85
+ "namespace",
86
+ "grapheme_start",
87
+ "grapheme_end",
88
+ "cell_start",
89
+ "cell_end",
90
+ "frame_start_row",
91
+ "row_text_sha256",
92
+ "frame_sha256",
93
+ "frame_text_sha256",
94
+ ] as const;
95
+ const SEMANTIC_ROOT_IDS = [
96
+ "irc-split",
97
+ "pending-messages",
98
+ "status-container",
99
+ "todos",
100
+ "btw",
101
+ "status-line",
102
+ "hooks-above",
103
+ "editor-container",
104
+ "pet-floor",
105
+ "hooks-below",
106
+ ] as const;
107
+ const STATE_KEYS = [
108
+ "manual",
109
+ "notice",
110
+ "observed_output_revision",
111
+ "transcript_capacity",
112
+ "composer_visible",
113
+ "resize_probes",
114
+ "visible_empty_irc_frame",
115
+ "root_order",
116
+ "pin_boundary",
117
+ "focused_component",
118
+ "cursor",
119
+ "selection",
120
+ "semantic_anchor",
121
+ "cjk_contiguous_semantics",
122
+ "coverage",
123
+ ] as const;
67
124
  type Style = {
68
125
  foreground: string;
69
126
  background: string;
@@ -78,7 +135,52 @@ type Style = {
78
135
  overline: boolean;
79
136
  };
80
137
  type Run = { text: string; style: Style };
81
- const hash = (value: string) => new Bun.CryptoHasher("sha256").update(value).digest("hex");
138
+ const hash = (value: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(value).digest("hex");
139
+ const cellWidth = (grapheme: string) => {
140
+ const scalar = grapheme.codePointAt(0)!;
141
+ if (scalar === 0x200d || (scalar >= 0x300 && scalar <= 0x36f) || (scalar >= 0xfe00 && scalar <= 0xfe0f)) return 0;
142
+ return (scalar >= 0x1100 && scalar <= 0x115f) ||
143
+ (scalar >= 0x2e80 && scalar <= 0xa4cf) ||
144
+ (scalar >= 0xac00 && scalar <= 0xd7a3) ||
145
+ (scalar >= 0xf900 && scalar <= 0xfaff) ||
146
+ (scalar >= 0xff01 && scalar <= 0xff60) ||
147
+ (scalar >= 0xffe0 && scalar <= 0xffe6)
148
+ ? 2
149
+ : 1;
150
+ };
151
+ const terminalRows = (text: string, columns: number) =>
152
+ text
153
+ .slice(0, -1)
154
+ .split("\n")
155
+ .map(text => {
156
+ const cells = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)].map(item => ({
157
+ grapheme: item.segment,
158
+ width: cellWidth(item.segment),
159
+ }));
160
+ const width = cells.reduce((total, cell) => total + cell.width, 0);
161
+ if (width > columns) fail(`terminal row exceeds ${columns} cells`);
162
+ return { text, cells, width };
163
+ });
164
+ const verifyCjkCellOracle = (text: string, columns: number, pinRow: unknown, cursorRow: unknown) => {
165
+ if (!Number.isInteger(pinRow) || !Number.isInteger(cursorRow)) fail("narrow CJK lane geometry missing");
166
+ const pinnedRow = pinRow as number;
167
+ const editorRow = cursorRow as number;
168
+ const rows = terminalRows(text, columns);
169
+ const phrase = CJK[1];
170
+ const row = rows.findIndex(candidate => candidate.text.includes(phrase));
171
+ if (row < 0) fail("narrow CJK cell oracle missing canonical phrase");
172
+ const candidate = rows[row]!;
173
+ const phraseIndex = candidate.text.indexOf(phrase);
174
+ const prefix = candidate.text.slice(0, phraseIndex);
175
+ const phraseCells = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(phrase)];
176
+ const phraseStart = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(prefix)].reduce(
177
+ (total, item) => total + cellWidth(item.segment),
178
+ 0,
179
+ );
180
+ const phraseWidth = phraseCells.reduce((total, item) => total + cellWidth(item.segment), 0);
181
+ if (phraseStart + phraseWidth > columns || row >= pinnedRow || row === editorRow)
182
+ fail("narrow CJK cell oracle lane overlap");
183
+ };
82
184
  const fail = (message: string): never => {
83
185
  throw new Error(`Sticky viewport evidence invalid: ${message}`);
84
186
  };
@@ -281,79 +383,328 @@ const normalized = (runs: Run[]) => {
281
383
  return merged;
282
384
  };
283
385
  const equalRuns = (left: Run[], right: Run[]) => JSON.stringify(normalized(left)) === JSON.stringify(normalized(right));
284
- const transcriptCapacity = (text: string) => {
285
- const row = text.split("\n").findIndex(line => line.includes("status:"));
286
- return row < 0 ? fail("frame omits pinned status row") : row;
386
+ const hasColorSgr = (ansi: string) =>
387
+ [...ansi.matchAll(/\x1b\[([0-9;]*)m/g)].some(match => {
388
+ const codes = (match[1] || "0").split(";").map(Number);
389
+ return codes.some(
390
+ code =>
391
+ (code >= 30 && code <= 37) ||
392
+ (code >= 40 && code <= 47) ||
393
+ (code >= 90 && code <= 107) ||
394
+ code === 38 ||
395
+ code === 48,
396
+ );
397
+ });
398
+ // Independent, frame-derived transcript geometry. The renderer assigns
399
+ // `transcriptCapacity` and `pinBoundary.row` from one local (tui.ts), so
400
+ // comparing those two to each other can never fail. These derivations read the
401
+ // committed paint instead, so a renderer reporting stale geometry is rejected.
402
+ const STATUS_ROW_MARKER = "⬢";
403
+ const NOTICE_MARKER = "New output — type to follow";
404
+ const frameGeometry = (key: string, text: string) => {
405
+ const rows = text.split("\n").slice(0, -1);
406
+ const statusRows = rows.filter(row => row.includes(STATUS_ROW_MARKER));
407
+ if (statusRows.length !== 1) fail(`entry ${key} status row cardinality is not exactly one`);
408
+ const statusRow = rows.findIndex(row => row.includes(STATUS_ROW_MARKER));
409
+ const noticeRows = text.split(NOTICE_MARKER).length - 1;
410
+ if (noticeRows > 1) fail(`entry ${key} notice row cardinality exceeds one`);
411
+ // The notice, when present, occupies the first row below the transcript, so
412
+ // the painted status row sits one lower than the transcript capacity.
413
+ return { capacity: statusRow - noticeRows, statusRow, noticeRows };
414
+ };
415
+ // Immutable per-entry anchor expectation. A recomputed digest only proves internal
416
+ // consistency: the producer chooses `frame_start_row` and the geometry, so it can
417
+ // mint a cryptographically valid id for a RELOCATED or TRANSPLANTED anchor. This
418
+ // table is the independent witness. It lives in verifier source, not in the bundle,
419
+ // and this file's own sha256 is inside `source_sha256`, so a bundle author cannot
420
+ // restate it. Geometry was measured identical across indexed-color and truecolor
421
+ // hosts, so pinning it costs no host portability.
422
+ // Files that OWN the verification contract itself: the expectation table, the
423
+ // anchor digest function, and every guard below. `source_sha256` cannot protect
424
+ // these, because it is produced by `captureProvenance()` from the worktree -- an
425
+ // author who edits an oracle and restamps provenance gets a self-consistent
426
+ // bundle. So these two are pinned to their committed blobs at the bundle's own
427
+ // `git_head` instead. Product-source diffs stay permitted for staged current-dev
428
+ // integration; oracle diffs do not.
429
+ const ORACLE_SOURCES = [
430
+ "packages/coding-agent/scripts/verify-sticky-viewport-showcase.ts",
431
+ "packages/coding-agent/test/fixtures/tui/sticky-viewport-showcase.ts",
432
+ ] as const;
433
+ // The reviewed PR commit, supplied out-of-band by the verifying operator.
434
+ // `provenance.git_head` is `git rev-parse HEAD`, which is NOT the reviewed commit
435
+ // during an uncommitted synthetic merge: there HEAD is the integration base while
436
+ // the running oracle is the staged PR version. Comparing against HEAD alone
437
+ // therefore rejects an honest bundle before any evidence guard runs. This value is
438
+ // environment-supplied rather than bundle-supplied, so a bundle author cannot
439
+ // choose which commit vouches for the oracle.
440
+ const ORACLE_COMMIT_ENV = "GJC_STICKY_VIEWPORT_ORACLE_COMMIT";
441
+ const verifyOracleIntegrity = async (gitHead: string, declaredProvenanceCommit: unknown) => {
442
+ // Authority is exactly one commit. Reachability is NOT authority: bytes
443
+ // committed on an unrelated local or remote-tracking ref are reachable via
444
+ // `--all` yet were never reviewed, so enumerating refs would let any pushed
445
+ // or fetched branch authorize the oracle. The reviewed commit is therefore
446
+ // either declared out-of-band by the review harness or it is `git_head`.
447
+ const declared = process.env[ORACLE_COMMIT_ENV]?.trim();
448
+ if (declared !== undefined && !/^[0-9a-f]{40}$/.test(declared))
449
+ fail(`oracle integrity: ${ORACLE_COMMIT_ENV} must be a full 40-hex commit id`);
450
+ const authority = declared ?? gitHead;
451
+ // Fail closed when the authority is not a readable commit object.
452
+ if ((await gitObjectType(authority)) !== "commit")
453
+ fail(`oracle integrity: authority ${authority} is not a readable commit object`);
454
+ // The bundle records which commit it claimed; a mismatch means the bundle was
455
+ // captured against a different authority than the one being verified.
456
+ if (declaredProvenanceCommit !== authority)
457
+ fail(
458
+ `oracle integrity: provenance oracle_commit ${String(declaredProvenanceCommit)} is not the authority ${authority}`,
459
+ );
460
+ // Both oracle files must resolve at that same commit; per-file provenance
461
+ // from different refs is not accepted.
462
+ for (const source of ORACLE_SOURCES) {
463
+ const committed = await committedBlobSha256(authority, source);
464
+ if (committed === null) fail(`oracle integrity: ${source} has no committed blob at ${authority}`);
465
+ const running = hash(new Uint8Array(await fs.readFile(resolveRepositoryPath(source))));
466
+ if (running !== committed) fail(`oracle integrity: ${source} differs from its committed blob at ${authority}`);
467
+ }
287
468
  };
288
- const expectedState = (state: string) => ({
289
- manual: state !== "live-overflow",
290
- notice: state === "manual-new-output",
291
- status: state === "live-overflow" ? "status: live follow" : "status: manual history · composer pinned",
292
- multiline: state === "multiline-editor-hooks-pet",
469
+ const SEMANTIC_ANCHOR_EXPECTATION: Readonly<
470
+ Record<string, { frameRow: number; graphemeStart: number; graphemeEnd: number; cellStart: number; cellEnd: number }>
471
+ > = Object.freeze({
472
+ "live-overflow/80x24/unicode-color": {
473
+ frameRow: 2,
474
+ graphemeStart: 0,
475
+ graphemeEnd: 1638400,
476
+ cellStart: 0,
477
+ cellEnd: 1638400,
478
+ },
479
+ "live-overflow/120x36/unicode-color": {
480
+ frameRow: 2,
481
+ graphemeStart: 0,
482
+ graphemeEnd: 3276800,
483
+ cellStart: 0,
484
+ cellEnd: 3276800,
485
+ },
486
+ "manual-history/80x24/unicode-color": {
487
+ frameRow: 0,
488
+ graphemeStart: 1638400,
489
+ graphemeEnd: 3276800,
490
+ cellStart: 1638400,
491
+ cellEnd: 3276800,
492
+ },
493
+ "manual-history/120x36/unicode-color": {
494
+ frameRow: 0,
495
+ graphemeStart: 0,
496
+ graphemeEnd: 3276800,
497
+ cellStart: 0,
498
+ cellEnd: 3276800,
499
+ },
500
+ "manual-new-output/80x24/unicode-color": {
501
+ frameRow: 0,
502
+ graphemeStart: 1638400,
503
+ graphemeEnd: 3276800,
504
+ cellStart: 1638400,
505
+ cellEnd: 3276800,
506
+ },
507
+ "manual-new-output/120x36/unicode-color": {
508
+ frameRow: 0,
509
+ graphemeStart: 0,
510
+ graphemeEnd: 3276800,
511
+ cellStart: 0,
512
+ cellEnd: 3276800,
513
+ },
514
+ "multiline-editor-hooks-pet/80x24/unicode-color": {
515
+ frameRow: 3,
516
+ graphemeStart: 0,
517
+ graphemeEnd: 1638400,
518
+ cellStart: 0,
519
+ cellEnd: 1638400,
520
+ },
521
+ "multiline-editor-hooks-pet/120x36/unicode-color": {
522
+ frameRow: 3,
523
+ graphemeStart: 0,
524
+ graphemeEnd: 3276800,
525
+ cellStart: 0,
526
+ cellEnd: 3276800,
527
+ },
528
+ "capacity-many/80x24/unicode-color": {
529
+ frameRow: 0,
530
+ graphemeStart: 1638400,
531
+ graphemeEnd: 3276800,
532
+ cellStart: 1638400,
533
+ cellEnd: 3276800,
534
+ },
535
+ "capacity-many/120x36/unicode-color": {
536
+ frameRow: 0,
537
+ graphemeStart: 0,
538
+ graphemeEnd: 3276800,
539
+ cellStart: 0,
540
+ cellEnd: 3276800,
541
+ },
542
+ "capacity-one/80x24/unicode-color": {
543
+ frameRow: 0,
544
+ graphemeStart: 0,
545
+ graphemeEnd: 1605632,
546
+ cellStart: 0,
547
+ cellEnd: 1605632,
548
+ },
549
+ "capacity-one/120x36/unicode-color": {
550
+ frameRow: 0,
551
+ graphemeStart: 0,
552
+ graphemeEnd: 3211264,
553
+ cellStart: 0,
554
+ cellEnd: 3211264,
555
+ },
556
+ "selection-boundary/80x24/unicode-color": {
557
+ frameRow: 0,
558
+ graphemeStart: 1638400,
559
+ graphemeEnd: 3276800,
560
+ cellStart: 1638400,
561
+ cellEnd: 3276800,
562
+ },
563
+ "selection-boundary/120x36/unicode-color": {
564
+ frameRow: 0,
565
+ graphemeStart: 0,
566
+ graphemeEnd: 3276800,
567
+ cellStart: 0,
568
+ cellEnd: 3276800,
569
+ },
570
+ "manual-new-output/80x24/ascii-no-color": {
571
+ frameRow: 0,
572
+ graphemeStart: 1638400,
573
+ graphemeEnd: 3276800,
574
+ cellStart: 1638400,
575
+ cellEnd: 3276800,
576
+ },
577
+ "multiline-editor-hooks-pet/48x10/unicode-color": {
578
+ frameRow: 0,
579
+ graphemeStart: 1638400,
580
+ graphemeEnd: 3276800,
581
+ cellStart: 1638400,
582
+ cellEnd: 3276800,
583
+ },
584
+ "narrow-cjk/48x10/unicode-color": {
585
+ frameRow: 3,
586
+ graphemeStart: 0,
587
+ graphemeEnd: 589824,
588
+ cellStart: 0,
589
+ cellEnd: 589824,
590
+ },
293
591
  });
294
- function validateOracle(
592
+ // Semantic anchor identity guard. The persisted `semantic_anchor.id` is the only
593
+ // durable handle on WHICH painted row the evidence anchors, so it must be
594
+ // unforgeable rather than merely well-formed. A geometry-only digest failed both
595
+ // ways: distinct entries painting different content at equal offsets collapsed
596
+ // onto one id, and an 8-hex truncation is brute-forceable. This recomputes the
597
+ // full domain-separated digest from the persisted inputs plus the committed paint
598
+ // and rejects arbitrary, transplanted, aliased, malformed, and truncated ids. It
599
+ // runs BEFORE every downstream metadata check so a forged id can never ride
600
+ // through on an earlier-passing path.
601
+ const verifySemanticAnchor = (
295
602
  key: string,
603
+ state: string,
604
+ value: unknown,
296
605
  text: string,
297
- metadata: Record<string, unknown>,
298
- stateEvidence: Record<string, unknown>,
299
- ) {
300
- const state = key.split("/")[0]!;
301
- const expected = expectedState(state);
302
- const lines = text.split("\n");
303
- if (stateEvidence.manual !== expected.manual || stateEvidence.notice !== expected.notice)
304
- fail(`immutable state oracle mismatch for ${key}`);
305
- if (lines.filter(line => line.includes(expected.status)).length !== 1)
306
- fail(`exact status oracle mismatch for ${key}`);
307
- const markers = [
308
- expected.status,
309
- ...(expected.multiline
310
- ? ["hook: ready", "completed: visual proof", "pet: ◕‿◕", "> ", "first composer line", "second composer line"]
311
- : ["> "]),
312
- ];
313
- let position = -1;
314
- for (const marker of markers) {
315
- const next = text.indexOf(marker, position + 1);
316
- if (next < 0) fail(`ordered suffix oracle missing ${marker} for ${key}`);
317
- position = next;
606
+ ansiSha256: string,
607
+ seen: Map<string, string>,
608
+ ) => {
609
+ if (state === "capacity-zero") {
610
+ if (value !== null) fail(`semantic anchor guard: ${key} must not carry an anchor at zero capacity`);
611
+ return;
318
612
  }
319
- if (text.split("New output — type to follow").length - 1 !== (expected.notice ? 1 : 0))
320
- fail(`notice cardinality oracle mismatch for ${key}`);
613
+ const anchor = object(value, `semantic anchor ${key}`);
614
+ exactKeys(anchor, SEMANTIC_ANCHOR_KEYS, `semantic anchor ${key}`);
615
+ if (anchor.domain !== SEMANTIC_ANCHOR_DOMAIN)
616
+ fail(`semantic anchor guard: ${key} domain separation literal mismatch`);
617
+ const id = anchor.id;
618
+ const namespace = anchor.namespace;
619
+ if (typeof id !== "string" || typeof namespace !== "string" || !namespace)
620
+ fail(`semantic anchor guard: ${key} id or namespace is not a nonempty string`);
621
+ const geometry = ["grapheme_start", "grapheme_end", "cell_start", "cell_end", "frame_start_row"] as const;
622
+ for (const field of geometry)
623
+ if (!Number.isInteger(anchor[field]) || (anchor[field] as number) < 0)
624
+ fail(`semantic anchor guard: ${key} ${field} is not a nonnegative integer`);
625
+ const graphemeStart = anchor.grapheme_start as number;
626
+ const graphemeEnd = anchor.grapheme_end as number;
627
+ const cellStart = anchor.cell_start as number;
628
+ const cellEnd = anchor.cell_end as number;
629
+ const frameRow = anchor.frame_start_row as number;
630
+ if (graphemeEnd <= graphemeStart || cellEnd <= cellStart)
631
+ fail(`semantic anchor guard: ${key} geometry span is empty or inverted`);
632
+ // Independent witness: reject relocation/transplant even when every digest and
633
+ // provenance field is internally consistent.
634
+ const expectation = SEMANTIC_ANCHOR_EXPECTATION[key];
635
+ if (!expectation) fail(`semantic anchor guard: ${key} has no immutable anchor expectation`);
321
636
  if (
322
- expected.multiline !==
323
- (text.includes("hook: ready") &&
324
- text.includes("pet: ◕‿◕") &&
325
- text.includes("first composer line") &&
326
- text.includes("second composer line"))
637
+ frameRow !== expectation!.frameRow ||
638
+ graphemeStart !== expectation!.graphemeStart ||
639
+ graphemeEnd !== expectation!.graphemeEnd ||
640
+ cellStart !== expectation!.cellStart ||
641
+ cellEnd !== expectation!.cellEnd
327
642
  )
328
- fail(`multiline editor/hooks/pet oracle mismatch for ${key}`);
329
- if (metadata.output_revision !== (expected.notice ? "1" : "0")) fail(`manual notice invariant mismatch for ${key}`);
330
- if (state === "selection-boundary") {
331
- const copied = stateEvidence.selection_copied_text;
332
- if (
333
- stateEvidence.selection_scope !== "transcript" ||
334
- typeof copied !== "string" ||
335
- !copied.trim() ||
336
- copied.includes("status:") ||
337
- copied.includes("> ") ||
338
- !lines.some(line => line.includes(copied.trim()))
339
- )
340
- fail(`selection oracle mismatch for ${key}`);
341
- } else if (stateEvidence.selection_scope !== "none" || stateEvidence.selection_copied_text !== "")
342
- fail(`selection oracle mismatch for ${key}`);
343
- const historicalRows = stateEvidence.manual_historical_rows;
344
- const preOutputCapacity = stateEvidence.manual_pre_output_capacity;
345
- if (expected.manual) {
346
- if (
347
- !Number.isInteger(preOutputCapacity) ||
348
- (preOutputCapacity as number) < 0 ||
349
- !Array.isArray(historicalRows) ||
350
- historicalRows.length !== ((preOutputCapacity as number) > 0 ? 1 : 0) ||
351
- historicalRows.some(row => typeof row !== "string" || !row.trim() || !lines.includes(row))
352
- )
353
- fail(`manual historical transcript evidence mismatch for ${key}`);
354
- } else if (preOutputCapacity !== 0 || !Array.isArray(historicalRows) || historicalRows.length !== 0)
355
- fail(`manual historical transcript evidence mismatch for ${key}`);
356
- }
643
+ fail(`semantic anchor guard: ${key} anchor row or geometry does not match its immutable expectation`);
644
+ // The row text is read from the committed paint, never from metadata, so the id
645
+ // stays bound to what the artifact actually renders at the recorded row.
646
+ const rows = text.split("\n");
647
+ const rowText = rows[frameRow];
648
+ if (rowText === undefined) fail(`semantic anchor guard: ${key} frame_start_row is outside the committed paint`);
649
+ if (anchor.row_text_sha256 !== hash(rowText as string))
650
+ fail(`semantic anchor guard: ${key} row content digest does not match the painted anchor row`);
651
+ if (anchor.frame_sha256 !== ansiSha256)
652
+ fail(`semantic anchor guard: ${key} frame digest does not match the committed frame`);
653
+ const frameTextSha256 = hash(Bun.stripANSI(text));
654
+ if (anchor.frame_text_sha256 !== frameTextSha256)
655
+ fail(`semantic anchor guard: ${key} frame text digest does not match the committed paint`);
656
+ if (semanticAnchorNamespace(id as string) !== namespace)
657
+ fail(`semantic anchor guard: ${key} id namespace does not match the persisted namespace`);
658
+ const expected = `${namespace}:${semanticAnchorDigest({
659
+ entryKey: key,
660
+ namespace: namespace as string,
661
+ rowText: rowText as string,
662
+ graphemeStart,
663
+ graphemeEnd,
664
+ cellStart,
665
+ cellEnd,
666
+ frameRow,
667
+ frameTextSha256,
668
+ })}`;
669
+ if (id !== expected) fail(`semantic anchor guard: ${key} id is not the digest of its own persisted inputs`);
670
+ // No silent aliasing: distinct evidence entries are distinct anchors by
671
+ // construction, because the entry key is inside the preimage. A repeat here can
672
+ // only mean a transplanted id, so it is a hard failure rather than an
673
+ // equivalence to be tolerated.
674
+ const owner = seen.get(id as string);
675
+ if (owner !== undefined) fail(`semantic anchor guard: ${key} id aliases the anchor already claimed by ${owner}`);
676
+ seen.set(id as string, key);
677
+ };
678
+ // Whole-frame content witness. The semantic anchor guard above pins exactly ONE
679
+ // painted row per entry, and `anchor.frame_sha256` is recomputed from the
680
+ // bundle's OWN artifact, so it only proves internal consistency: a producer who
681
+ // rewrites a non-anchor row and coordinately rehashes every dependent digest --
682
+ // artifacts, metadata, manifest entry, provenance blocks, review-input binding --
683
+ // stayed self-consistent and was ACCEPTED. In an 80x24 frame 13 rows carry
684
+ // content while one is anchored, so the transcript rows carrying the bundle's own
685
+ // evidence text were rewritable at will.
686
+ //
687
+ // This compares the painted frame against a value committed in oracle source,
688
+ // which no bundle can reach. It is invoked LAST in the per-entry loop, after the
689
+ // anchor guard and after every geometry, marker, and CJK oracle, so a mutation
690
+ // those already attribute keeps its narrower message and only the rows no other
691
+ // oracle inspects surface here.
692
+ //
693
+ // The digest is over the ANSI-STRIPPED paint. `frame_sha256` stays untouched as
694
+ // the artifact digest and legitimately diverges between an indexed-color and a
695
+ // truecolor host; the stripped paint does not, so it is the only frame-wide
696
+ // surface that a single committed value can pin on both hosts.
697
+ export const stickyViewportFrameTextDigest = (frame: string): string => hash(Bun.stripANSI(frame));
698
+ const verifyFrameTextWitness = (key: string, text: string) => {
699
+ // Fail closed: an unwitnessed key is an unpinned frame, not a pass.
700
+ const expected: string | undefined = STICKY_VIEWPORT_FRAME_TEXT_WITNESS[key as StickyViewportShowcaseKey];
701
+ if (expected === undefined) fail(`frame content guard: ${key} has no committed frame text witness`);
702
+ const observed = stickyViewportFrameTextDigest(text);
703
+ if (observed !== expected)
704
+ fail(
705
+ `frame content guard: ${key} painted frame digest is ${observed} but the committed witness pins ${expected}`,
706
+ );
707
+ };
357
708
  export async function verifyStickyViewportShowcase(rootInput: string, requireIndependentReview = false): Promise<void> {
358
709
  const root = path.resolve(rootInput);
359
710
  const manifestText = await fs.readFile(path.join(root, "manifest.json"), "utf8");
@@ -370,6 +721,7 @@ export async function verifyStickyViewportShowcase(rootInput: string, requireInd
370
721
  fail("manifest schema or provenance literals mismatch");
371
722
  strings(manifest.ordered_keys, KEYS, "manifest ordered_keys");
372
723
  const provenance = object(manifest.provenance, "manifest provenance");
724
+ const expectedProvenance = await captureProvenance();
373
725
  if (
374
726
  provenance.capture_mode !== "production-tui-virtual-terminal" ||
375
727
  provenance.live_pty !== false ||
@@ -381,8 +733,24 @@ export async function verifyStickyViewportShowcase(rootInput: string, requireInd
381
733
  !provenance.executor_identity.trim()
382
734
  )
383
735
  fail("manifest provenance mismatch");
736
+ // `git_diff_scope` is compared against the verifier's own constant, so a bundle
737
+ // cannot narrow the covered surface to dodge the digest: claiming a smaller
738
+ // scope than the verifier requires is itself a staleness rejection.
739
+ if (
740
+ provenance.git_head !== expectedProvenance.git_head ||
741
+ JSON.stringify(provenance.git_diff_scope) !== JSON.stringify(PROVENANCE_DIFF_SCOPE) ||
742
+ provenance.git_diff_binary_sha256 !== expectedProvenance.git_diff_binary_sha256 ||
743
+ JSON.stringify(provenance.source_sha256) !== JSON.stringify(expectedProvenance.source_sha256)
744
+ )
745
+ fail("manifest capture provenance is stale");
746
+ // Runs before every entry check: a rewritten oracle would otherwise decide
747
+ // what "valid" means for all of them.
748
+ await verifyOracleIntegrity(provenance.git_head as string, provenance.oracle_commit);
384
749
  const entries = array(manifest.entries, "manifest entries");
385
750
  if (entries.length !== KEYS.length) fail("manifest entries must contain exactly 20 entries");
751
+ // Cross-entry anchor identity ledger. Uniqueness is a hard contract, not a
752
+ // tolerance: every distinct evidence entry must own a distinct anchor id.
753
+ const anchorIds = new Map<string, string>();
386
754
  for (let index = 0; index < KEYS.length; index += 1) {
387
755
  const key = KEYS[index]!;
388
756
  const entry = object(entries[index], `entry ${index}`);
@@ -422,21 +790,12 @@ export async function verifyStickyViewportShowcase(rootInput: string, requireInd
422
790
  !equalRuns(ansiStyleRuns, htmlStyleRuns)
423
791
  )
424
792
  fail(`entry ${key} ANSI/HTML style-run mismatch`);
425
- if (state === "multiline-editor-hooks-pet" && (!ansi.includes("\x1b[9m") || !html.includes("line-through")))
426
- fail(`entry ${key} strikethrough evidence missing`);
427
- if (state === "selection-boundary" && !ansi.includes("\x1b[7m"))
428
- fail(`entry ${key} inverse selection evidence missing`);
429
- if (state === "narrow-cjk" && CJK.some(boundary => !text.includes(boundary)))
430
- fail("narrow CJK visible terminal evidence missing");
431
- if (
432
- text
433
- .split("\n")
434
- .slice(0, -1)
435
- .some(row => Bun.stringWidth(row) !== columns) ||
436
- text.split("\n").length - 1 !== rows
437
- )
438
- fail(`entry ${key} terminal dimensions mismatch`);
439
- if (mode === "ascii-no-color" ? /\x1b\[/.test(ansi) : !/\x1b\[[0-9;]*(?:3[0-9]|38;)/.test(ansi))
793
+ if (text.split("\n").length - 1 !== rows) fail(`entry ${key} terminal row count mismatch`);
794
+ // `ascii-no-color` artifacts must contain no escape sequence at all, which is
795
+ // host-independent. The color branch keeps the widened check below because the
796
+ // prior `3[0-9]|38;` regex missed background (40-47/100-107) and bright (90-97)
797
+ // color, so a frame carrying only those would have passed as "no color".
798
+ if (mode === "ascii-no-color" ? /\x1b\[/.test(ansi) : !hasColorSgr(ansi))
440
799
  fail(`entry ${key} ANSI mode/color mismatch`);
441
800
  const metadata = await readJson(path.join(root, key, "metadata.json"), `metadata ${key}`);
442
801
  exactKeys(
@@ -476,7 +835,7 @@ export async function verifyStickyViewportShowcase(rootInput: string, requireInd
476
835
  metadata.fixture_source !== FIXTURE ||
477
836
  metadata.render_mode !== mode ||
478
837
  metadata.ansi_mode !== (mode === "unicode-color") ||
479
- metadata.source_revision !== "production-tui-virtual-terminal-v2" ||
838
+ metadata.source_revision !== "production-tui-virtual-terminal-v3" ||
480
839
  terminal.id !== id ||
481
840
  terminal.columns !== columns ||
482
841
  terminal.rows !== rows ||
@@ -488,22 +847,182 @@ export async function verifyStickyViewportShowcase(rootInput: string, requireInd
488
847
  metaProvenance.fixed_clock !== true ||
489
848
  metaProvenance.author_identity !== provenance.author_identity ||
490
849
  metaProvenance.executor_identity !== provenance.executor_identity ||
850
+ metaProvenance.git_head !== expectedProvenance.git_head ||
851
+ JSON.stringify(metaProvenance.git_diff_scope) !== JSON.stringify(PROVENANCE_DIFF_SCOPE) ||
852
+ metaProvenance.git_diff_binary_sha256 !== expectedProvenance.git_diff_binary_sha256 ||
853
+ JSON.stringify(metaProvenance.source_sha256) !== JSON.stringify(expectedProvenance.source_sha256) ||
491
854
  stateEvidence.composer_visible !== true
492
855
  )
493
856
  fail(`metadata schema mismatch for ${key}`);
494
- if (stateEvidence.transcript_capacity !== transcriptCapacity(text))
857
+ // Anchor identity is validated here, before any downstream metadata check, so
858
+ // a forged, transplanted, or aliased id cannot ride through on an
859
+ // earlier-passing path.
860
+ verifySemanticAnchor(key, state!, stateEvidence.semantic_anchor, text, hash(ansi), anchorIds);
861
+ // Frame-derived geometry must AGREE with the renderer's self-report, which is
862
+ // strictly stronger than either alone. `transcript_capacity` and
863
+ // `pin_boundary.row` both come from one renderer local, so they can only be
864
+ // falsified against the committed paint: a renderer reporting stale capacity
865
+ // or pin geometry now fails here instead of passing self-consistently.
866
+ const frameGeom = frameGeometry(key, text);
867
+ if (
868
+ !Number.isInteger(stateEvidence.transcript_capacity) ||
869
+ stateEvidence.transcript_capacity !== frameGeom.capacity ||
870
+ frameGeom.noticeRows !== (state === "manual-new-output" ? 1 : 0)
871
+ )
495
872
  fail(`capacity metadata/frame mismatch for ${key}`);
496
- validateOracle(key, text, metadata, stateEvidence);
497
- if (state === "capacity-zero" && transcriptCapacity(text) !== 0)
498
- fail(`zero capacity frame invariant mismatch for ${key}`);
499
- if (state === "capacity-one" && transcriptCapacity(text) !== 1)
500
- fail(`one capacity frame invariant mismatch for ${key}`);
501
- if (state === "capacity-many" && transcriptCapacity(text) < 2)
502
- fail(`many capacity frame invariant mismatch for ${key}`);
873
+ // Ordered suffix markers follow the production root order: status-line (5),
874
+ // hooks-above (6), editor-container (7). Painted order, not reported order.
875
+ let markerPosition = -1;
876
+ for (const marker of [STATUS_ROW_MARKER, "hook: ready", "> "]) {
877
+ const next = text.indexOf(marker, markerPosition + 1);
878
+ if (next < 0) fail(`entry ${key} ordered suffix marker missing: ${marker}`);
879
+ markerPosition = next;
880
+ }
881
+ const observations = array(stateEvidence.resize_probes, `metadata ${key} resize observations`);
882
+ const probeWidths = [64, 65, 80, 120, 160, 120, 80, 65, 64];
883
+ const rootOrder = array(stateEvidence.root_order, `metadata ${key} root order`);
884
+ const pinBoundary = object(stateEvidence.pin_boundary, `metadata ${key} pin boundary`);
885
+ const cursor = object(stateEvidence.cursor, `metadata ${key} cursor`);
886
+ const selection = stateEvidence.selection;
887
+ const expectedManual = state !== "live-overflow" && state !== "capacity-zero";
888
+ const expectedNotice = state === "manual-new-output";
889
+ const expectedRevision = expectedNotice ? "1" : "0";
890
+ if (
891
+ stateEvidence.manual !== expectedManual ||
892
+ stateEvidence.notice !== expectedNotice ||
893
+ stateEvidence.observed_output_revision !== expectedRevision ||
894
+ metadata.output_revision !== stateEvidence.observed_output_revision
895
+ )
896
+ fail(`renderer-owned viewport state mismatch for ${key}`);
897
+ const visibleEmpty = object(stateEvidence.visible_empty_irc_frame, `metadata ${key} visible empty IRC frame`);
898
+ exactKeys(stateEvidence, STATE_KEYS, `metadata ${key} state`);
899
+ strings(rootOrder, SEMANTIC_ROOT_IDS, `metadata ${key} semantic root IDs`);
900
+ const coverage = object(stateEvidence.coverage, `metadata ${key} coverage`);
901
+ exactKeys(
902
+ coverage,
903
+ ["irc", "todo", "widths", "heights", "viewport", "chrome", "evidence"],
904
+ `metadata ${key} coverage`,
905
+ );
906
+ strings(coverage.irc, ["empty", "streaming", "long"], `metadata ${key} IRC coverage`);
907
+ strings(
908
+ coverage.todo,
909
+ ["empty", "populated", "long", "multi-phase", "collapsed", "expanded"],
910
+ `metadata ${key} todo coverage`,
911
+ );
912
+ const emptyText = visibleEmpty.text as string;
913
+ const capacityConstrained = state === "capacity-one" || state === "capacity-zero";
914
+ if (
915
+ JSON.stringify(coverage.widths) !== JSON.stringify(probeWidths) ||
916
+ emptyText.includes("worker → you") ||
917
+ emptyText.includes("long IRC observation") ||
918
+ observations.length !== probeWidths.length ||
919
+ observations.some((value, index) => {
920
+ const probe = object(value, `metadata ${key} resize observation`);
921
+ const frame = object(probe.frame, `metadata ${key} resize frame`);
922
+ const split = probeWidths[index]! >= 65;
923
+ return (
924
+ probe.columns !== probeWidths[index] ||
925
+ probe.effective_lane !== (split ? "split" : "transcript") ||
926
+ probe.separator_width !== (split ? 3 : 0) ||
927
+ (probe.left_width as number) + (probe.separator_width as number) + (probe.right_width as number) !==
928
+ probeWidths[index] ||
929
+ probe.irc_records !== (split ? 1 : 0) ||
930
+ probe.todo_rows !== (split ? 1 : 0) ||
931
+ probe.todo_expanded !== (probe.columns as number) >= 80 ||
932
+ typeof frame.ansi !== "string" ||
933
+ frame.text !== Bun.stripANSI(frame.ansi) ||
934
+ frame.sha256 !== hash(frame.ansi) ||
935
+ // Required ASCII metadata frames must be escape-free too, or the bundle
936
+ // digest stays host-dependent even when the top-level payload is canonical.
937
+ (mode === "ascii-no-color" && /\x1b\[/.test(frame.ansi as string)) ||
938
+ (!capacityConstrained && split && !frame.text.includes("│")) ||
939
+ (!capacityConstrained &&
940
+ !split &&
941
+ (frame.text.includes("worker → you") || frame.text.includes("Todos"))) ||
942
+ (!capacityConstrained &&
943
+ (probe.columns as number) >= 80 &&
944
+ (!frame.text.includes("long IRC observation") ||
945
+ !frame.text.includes("☑ verify production todo") ||
946
+ !frame.text.includes("☐ expanded production todo"))) ||
947
+ (!capacityConstrained && (probe.columns as number) >= 120 && !frame.text.includes("worker → you"))
948
+ );
949
+ }) ||
950
+ typeof visibleEmpty.ansi !== "string" ||
951
+ visibleEmpty.text !== Bun.stripANSI(visibleEmpty.ansi) ||
952
+ visibleEmpty.sha256 !== hash(visibleEmpty.ansi) ||
953
+ (mode === "ascii-no-color" && /\x1b\[/.test(visibleEmpty.ansi as string)) ||
954
+ JSON.stringify(rootOrder) !==
955
+ JSON.stringify([
956
+ "irc-split",
957
+ "pending-messages",
958
+ "status-container",
959
+ "todos",
960
+ "btw",
961
+ "status-line",
962
+ "hooks-above",
963
+ "editor-container",
964
+ "pet-floor",
965
+ "hooks-below",
966
+ ]) ||
967
+ pinBoundary.component !== "status-line" ||
968
+ pinBoundary.index !== 5 ||
969
+ pinBoundary.row !== frameGeom.capacity ||
970
+ pinBoundary.pinned !== true ||
971
+ stateEvidence.focused_component !== "editor" ||
972
+ !Number.isInteger(cursor.row) ||
973
+ (cursor.row as number) < 0 ||
974
+ (cursor.row as number) >= rows ||
975
+ !Number.isInteger(cursor.col) ||
976
+ (cursor.col as number) < 0 ||
977
+ (cursor.col as number) >= columns ||
978
+ cursor.frame_sha256 !== hash(ansi) ||
979
+ cursor.blink !== true
980
+ )
981
+ fail(`runtime observation mismatch for ${key}`);
982
+ if (
983
+ (state === "capacity-many" && (stateEvidence.transcript_capacity as number) <= 1) ||
984
+ (state === "capacity-one" && stateEvidence.transcript_capacity !== 1) ||
985
+ (state === "capacity-zero" && stateEvidence.transcript_capacity !== 0)
986
+ )
987
+ fail(`capacity scenario mismatch for ${key}`);
988
+ if (state === "selection-boundary") {
989
+ const selected = object(selection, `metadata ${key} selection`);
990
+ const start = object(selected.start, `metadata ${key} selection start`);
991
+ const end = object(selected.end, `metadata ${key} selection end`);
992
+ if (
993
+ !Number.isInteger(start.row) ||
994
+ !Number.isInteger(start.col) ||
995
+ !Number.isInteger(end.row) ||
996
+ !Number.isInteger(end.col) ||
997
+ (start.row as number) < 0 ||
998
+ (end.row as number) >= (stateEvidence.transcript_capacity as number) ||
999
+ ((start.row as number) === (end.row as number) && (start.col as number) >= (end.col as number))
1000
+ )
1001
+ fail(`selection boundary evidence missing for ${key}`);
1002
+ } else if (selection !== null) fail(`unexpected selection evidence for ${key}`);
503
1003
  if (state === "narrow-cjk") {
504
1004
  strings(metadata.cjk_phrase_boundaries, CJK, "narrow CJK boundaries");
505
- if (CJK.some(boundary => !text.includes(boundary))) fail("narrow CJK visible terminal evidence missing");
1005
+ const probeTexts = observations.map(value => {
1006
+ const probe = object(value, `metadata ${key} resize observation`);
1007
+ return object(probe.frame, `metadata ${key} resize frame`).text;
1008
+ });
1009
+ if (
1010
+ CJK.some(
1011
+ boundary => ![text, ...probeTexts].some(frame => typeof frame === "string" && frame.includes(boundary)),
1012
+ )
1013
+ )
1014
+ fail("narrow CJK visible terminal evidence missing");
1015
+ verifyCjkCellOracle(text, columns, pinBoundary.row, cursor.row);
506
1016
  } else strings(metadata.cjk_phrase_boundaries, [], `non-narrow CJK boundaries for ${key}`);
1017
+ // Whole-frame pin, LAST in the per-entry loop. It runs after the semantic
1018
+ // anchor guard so an anchor-row mutation keeps its narrower anchor
1019
+ // attribution, and after the geometry, marker, and CJK oracles so a case that
1020
+ // mutates the paint to reach one of those keeps its own attribution too.
1021
+ // Ordering costs nothing here: every check above reads the same painted frame
1022
+ // this pins, so none of them can be subverted by a paint this guard would
1023
+ // reject. What lands here is the residue -- rows no other oracle looks at,
1024
+ // which is precisely the unpinned surface this guard exists to close.
1025
+ verifyFrameTextWitness(key, text);
507
1026
  }
508
1027
  const required = new Set([
509
1028
  "manifest.json",
@@ -513,6 +1032,14 @@ export async function verifyStickyViewportShowcase(rootInput: string, requireInd
513
1032
  ]);
514
1033
  for (const file of await allFiles(root)) if (!required.has(file)) fail(`unexpected file ${file}`);
515
1034
  const reviewInput = await readJson(path.join(root, "review-input.json"), "review input");
1035
+ const reviewProvenance = object(reviewInput.provenance, "review input provenance");
1036
+ if (
1037
+ reviewProvenance.git_head !== expectedProvenance.git_head ||
1038
+ JSON.stringify(reviewProvenance.git_diff_scope) !== JSON.stringify(PROVENANCE_DIFF_SCOPE) ||
1039
+ reviewProvenance.git_diff_binary_sha256 !== expectedProvenance.git_diff_binary_sha256 ||
1040
+ JSON.stringify(reviewProvenance.source_sha256) !== JSON.stringify(expectedProvenance.source_sha256)
1041
+ )
1042
+ fail("review input capture provenance is stale");
516
1043
  if (
517
1044
  reviewInput.schema_version !== 2 ||
518
1045
  reviewInput.manifest_sha256 !== hash(manifestText) ||