@holmes-lab/holmes-kit 0.1.17 → 0.2.0

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 (38) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/approve-context.d.ts +2 -0
  4. package/dist/holmes/cli/approve-context.js +180 -0
  5. package/dist/holmes/cli/approve-ref.d.ts +27 -0
  6. package/dist/holmes/cli/approve-ref.js +40 -0
  7. package/dist/holmes/cli/approve-watch.d.ts +29 -0
  8. package/dist/holmes/cli/approve-watch.js +94 -0
  9. package/dist/holmes/cli/approve.d.ts +61 -13
  10. package/dist/holmes/cli/approve.js +374 -36
  11. package/dist/holmes/cli/doctor.js +262 -0
  12. package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
  13. package/dist/holmes/cli/gitignore-merge.js +17 -1
  14. package/dist/holmes/cli/index.d.ts +23 -0
  15. package/dist/holmes/cli/index.js +496 -21
  16. package/dist/holmes/cli/init.js +14 -0
  17. package/dist/holmes/cli/mcp-launcher.js +6 -1
  18. package/dist/holmes/cli/screen-safe.d.ts +94 -0
  19. package/dist/holmes/cli/screen-safe.js +760 -0
  20. package/dist/holmes/governance/approval-queue.js +56 -4
  21. package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
  22. package/dist/holmes/governance/ledger-rechain.js +95 -0
  23. package/dist/holmes/governance/provenance-chain.d.ts +33 -6
  24. package/dist/holmes/governance/provenance-chain.js +91 -16
  25. package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
  26. package/dist/holmes/governance/provenance-ledger.js +10 -0
  27. package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
  28. package/dist/holmes/guardrail/risk-gate.js +10 -0
  29. package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
  30. package/dist/holmes/mcp/elicit-approval.js +79 -0
  31. package/dist/holmes/mcp/handlers.d.ts +7 -2
  32. package/dist/holmes/mcp/handlers.js +190 -24
  33. package/dist/holmes/mcp/server.js +39 -2
  34. package/dist/holmes/server/dashboard.js +2 -2
  35. package/dist/holmes/spec/id-collision.d.ts +39 -0
  36. package/dist/holmes/spec/id-collision.js +86 -0
  37. package/dist/holmes/spec/spec-store.js +9 -1
  38. package/package.json +1 -1
@@ -0,0 +1,760 @@
1
+ "use strict";
2
+ // @implements A-SPEC-262.1
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.KIND_COLS = exports.ROW_COLS = exports.ROW_MAX = void 0;
5
+ exports.isClipped = isClipped;
6
+ exports.displayWidth = displayWidth;
7
+ exports.flattenField = flattenField;
8
+ exports.rowLiteral = rowLiteral;
9
+ exports.rowField = rowField;
10
+ exports.wrapColumns = wrapColumns;
11
+ exports.stripControl = stripControl;
12
+ exports.clipCodePoints = clipCodePoints;
13
+ exports.shellQuote = shellQuote;
14
+ exports.safeRef = safeRef;
15
+ /**
16
+ * The decision screen shows AGENT-CONTROLLED text.
17
+ *
18
+ * A queue entry's `target` is whatever the agent was blocked on (the hook enqueues the command
19
+ * verbatim), and `why`/`question`/`reason` travel the same way. Round-1 measured the cost of raw
20
+ * interpolation: a crafted target forged list rows and hid the real ones behind an ANSI conceal, and
21
+ * the operator granted a `curl … | sh` they never saw. LINE STRUCTURE IS THE TEMPLATE'S, never the
22
+ * data's (the elicitation dialog's lesson, A-SPEC-263.1 §5b, on the other surface).
23
+ *
24
+ * Three properties this module must hold, each learned from a round that broke one of them:
25
+ * 1. NOTHING HIDDEN — no escape, control byte, or bidi mark reaches the terminal (round-1).
26
+ * 2. NOTHING VANISHES SILENTLY — a removal is marked, and the marker states its SIZE, because a
27
+ * removal whose size is invisible is still a concealment (round-2 made the sanitiser itself a
28
+ * concealment primitive; round-3 gave the marker a size).
29
+ * 3. NOTHING INFLATES — the output is bounded, or escape spam floods the very confirmation screen
30
+ * that exists so nothing is approved unseen (round-4: a max of 8000 could return 56,009 chars,
31
+ * ~467 terminal rows, scrolling the summaries away).
32
+ *
33
+ * A RUN of removable characters collapses to ONE marker carrying the run's length: that is what
34
+ * makes (2) and (3) compatible — 8000 escapes become `⟪8000자 제거⟫`, not 8000 markers.
35
+ *
36
+ * PURE. Control characters appear here as escapes only: a literal one in source is itself a thing
37
+ * the reader cannot see.
38
+ */
39
+ // C0 (newlines handled per function), DEL, C1, NEL/LS/PS, bidi controls, zero-width and BOM.
40
+ // Round-5 completed this: U+061C ALM (the seventh Bidi_Control, strong RTL) survived while its six
41
+ // siblings were removed, and the whole Default_Ignorable smuggling set — soft hyphen, the Mongolian
42
+ // and Hangul fillers, U+2060-206F, the TAG block U+E0000-E007F — passed through invisibly. TAG
43
+ // characters inside a URL are the sharp case: the operator reads a benign address while the grant's
44
+ // scope pattern is the RAW target. Lone surrogates go too (they render as U+FFFD, the very glyph
45
+ // round-1's code-point clipping exists to prevent, arriving by another door).
46
+ const CONTROL_CLASS = '\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f'
47
+ + '\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180e'
48
+ + '\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2064\\u2066-\\u206f'
49
+ + '\\u2028\\u2029\\u0085\\u3164\\ud800-\\udfff\\ufff9-\\ufffb\\ufeff';
50
+ // The TAG block is astral, so it needs its own alternative rather than a BMP class entry.
51
+ const TAG_BLOCK = '\\u{E0000}-\\u{E007F}';
52
+ // ANSI escapes: CSI (colour, cursor, conceal) and the two-byte introducers — which already include
53
+ // `ESC ]` (OSC), `ESC \\` (ST), `ESC P` (DCS), `ESC ^`/`ESC _`. Round-4: a dedicated OSC alternative
54
+ // that consumed to end of line was honest but useless — an entire command vanished behind one
55
+ // marker, on the screen whose job is showing the subject. Removing only the INTRODUCER leaves the
56
+ // payload as ordinary, inert, VISIBLE text (the ESC is gone, so no terminal will act on it).
57
+ const ANSI_SRC = '\\u001b\\[[0-?]*[ -/]*[@-~]|\\u001b[@-Z\\\\-_]|\\u001b';
58
+ /** One or more removable things in a row — collapsed to a single marker (round-4). */
59
+ const REMOVABLE_RUN = new RegExp(`(?:${ANSI_SRC}|[${CONTROL_CLASS}]|[${TAG_BLOCK}])+`, 'gu');
60
+ /** Newline-preserving variant: a body's newlines are its content. */
61
+ const REMOVABLE_RUN_KEEP_NL = new RegExp(`(?:${ANSI_SRC}|[${CONTROL_CLASS}]|[${TAG_BLOCK}])+`, 'gu');
62
+ /**
63
+ * TAB is the one whitespace character that MOVES THE CURSOR BY AN AMOUNT THE DATA CHOOSES: the
64
+ * terminal advances to the next multiple-of-eight column, while every width function here — and the
65
+ * fold that depends on it — reads one. Round-7 measured the consequence: a spec body of tabs made
66
+ * `wrapColumns` believe a line was 47 columns, the terminal wrapped it anyway, and forged rows
67
+ * landed at column 0 of the `[v]` screen with ZERO escape bytes — defeating round-6's margin by the
68
+ * same alignment trick the margin was added to stop. It is expanded to a single space before any
69
+ * measurement: a substitution the operator can see, not a removal, and one column is one column.
70
+ */
71
+ const TAB_TO_SPACE = /\t/gu;
72
+ /** One token at a time: a removable run, a whitespace run, or one visible code point. */
73
+ const TOKEN = new RegExp(`(?:${ANSI_SRC}|[${CONTROL_CLASS}]|[${TAG_BLOCK}])+|[\\r\\n\\t ]+|[\\s\\S]`, 'gu');
74
+ /**
75
+ * One terminal row's worth of SUBJECT, with the template's own cells already subtracted: an 80
76
+ * column row, minus the widest template prefix on this screen (`\u2713 \uc2b9\uc778 \u2014 `, nine columns)
77
+ * and the kind cell (16) and its space, leaves 52. Every line the decision surface prints is bounded by it, so a queue of any size stays a
78
+ * screen the operator can read top to bottom — the property the batch confirmation depends on
79
+ * (round-5: the field was bounded and the LINE was not, so a bounded field still wrapped).
80
+ */
81
+ /** The terminal this screen promises to fit. The floor no terminal goes below. */
82
+ exports.ROW_MAX = 80;
83
+ exports.ROW_COLS = 50;
84
+ /**
85
+ * The `kind` cell. Round-7: at 16 the truncation NOTICE (`… (200자 잘림)`, fifteen columns) consumed
86
+ * the entire budget, so a non-ASCII kind rendered ZERO columns of the operator's text — the category
87
+ * of authority being granted, unreadable, in the list AND the item header AND the confirmation AND
88
+ * every decision echo. That is the round-5 lesson ("a bound met by showing nothing is the
89
+ * concealment this module exists to prevent") reached through a budget nobody re-checked. Twenty
90
+ * columns plus the short notice form below leaves real text on the line.
91
+ */
92
+ exports.KIND_COLS = 20;
93
+ const MARK_OPEN = '\u27ea';
94
+ const MARK_CLOSE = '\u27eb';
95
+ /**
96
+ * Only the sanitiser may write a marker (round-4): ⟪ and ⟫ are ordinary printable characters, so a
97
+ * target could otherwise print `⟪7자 제거⟫` itself and make a real concealment read as routine noise
98
+ * — or claim a removal that never happened. The delimiters are neutralised on the way in.
99
+ */
100
+ // Round-5: substituting `<`/`>` silently rewrote the subject into something SCARIER (a forged shell
101
+ // redirection). The direction was safe — it can only cause a spurious refusal — but this screen was
102
+ // rebuilt around "the grant is for what the human saw", so the swap uses look-alike brackets that
103
+ // are not shell metacharacters and cannot be mistaken for the sanitiser's own marker.
104
+ const neutraliseMarkers = (s) => s.split(MARK_OPEN).join('\u27e8').split(MARK_CLOSE).join('\u27e9');
105
+ const mark = (run) => `${MARK_OPEN}${[...run].length}자 제거${MARK_CLOSE}`;
106
+ /**
107
+ * How much was not shown. The long form reads better; on a narrow cell it would BE the cell, so a
108
+ * compact form is used there (round-7 measured fifteen columns of notice inside a sixteen-column
109
+ * budget — zero columns left for the operator's text). Eight columns instead of fifteen keeps the
110
+ * round-5 rule that half the budget reaches the subject. `isClipped` knows both shapes, so no caller
111
+ * ever has to parse a number to learn that something was hidden.
112
+ */
113
+ const shortNotice = (max, hidden) => max < 30 ? `…(${hidden}자)` : `… (${hidden}자 잘림)`;
114
+ /**
115
+ * Did rendering this value into `max` columns hide any of it?
116
+ *
117
+ * Round-8: this used to be a REGEX OVER THE RENDERED TEXT, and the data can end with any string it
118
+ * likes — a target ending `… (5자 잘림)` was reported as clipped when nothing had been clipped, so
119
+ * `[A]` refused to batch it and told the operator, falsely, that it did not fit on a row. An agent
120
+ * could disable batch approval for a whole session by choosing its own suffix. The renderer knows
121
+ * the answer; nobody should have to parse it back out of the output.
122
+ */
123
+ function isClipped(value, max = exports.ROW_COLS) {
124
+ return renderRow(String(value), max).hidden > 0;
125
+ }
126
+ /**
127
+ * How many terminal COLUMNS a string occupies. Round-5 measured why a code-point budget is not a
128
+ * screen budget: a 200-code-point line of markers rendered 302 columns — four wrapped rows at 80 —
129
+ * because `⟪`, `⟫` and every Hangul character in `자 제거` are East Asian WIDE (two columns each).
130
+ * The budget exists to bound SCREEN SPACE, so it must be counted in the unit the screen uses.
131
+ */
132
+ function displayWidth(s) {
133
+ let w = 0;
134
+ for (const ch of s) {
135
+ const cp = ch.codePointAt(0);
136
+ // The fast path stops at U+00A1, the first code point that is not plainly one cell (round-6:
137
+ // it used to stop at U+0300 and swallowed the Ambiguous Latin-1 punctuation — `·`, the very
138
+ // separator this screen's own detail row is built from, measured 1 instead of 2).
139
+ if (cp < 0x00a1) {
140
+ w += 1;
141
+ continue;
142
+ }
143
+ if (inRanges(ZERO, cp))
144
+ continue; // stacks on the previous cell
145
+ w += inRanges(WIDE, cp) ? 2 : 1;
146
+ }
147
+ return w;
148
+ }
149
+ /**
150
+ * Column-width tables, GENERATED from the Unicode Character Database 16.0.0: WIDE holds
151
+ * every code point whose East_Asian_Width is W, F **or A**, ZERO holds categories Mn and Me. Flat
152
+ * [lo,hi,lo,hi,…] pairs, binary-searched.
153
+ *
154
+ * Round-6 replaced a hand-written list that named the blocks someone thought of and missed 8,685
155
+ * Wide code points — \u{1F680} U+1F680, U+2705, U+2B50, the whole U+1F650..U+1F8FF span, Tangut. A
156
+ * 52-column budget of rocket emoji measured 104 columns, so `[A]` batched items that could not be
157
+ * shown whole and the confirmation ran 43 rows on a 24-row terminal: round-4's flood reached with no
158
+ * escape byte at all. An enumeration written from memory is a defect with a delay on it.
159
+ *
160
+ * AMBIGUOUS counts as WIDE deliberately. A terminal renders those one cell wide by default and two
161
+ * in CJK mode, so no single number is right — but the two errors are not symmetric. Over-counting
162
+ * makes a line SHORTER than its budget; under-counting makes it overflow, and overflow is how this
163
+ * screen loses the evidence it exists to show. Much of this UI's own furniture is Ambiguous — `·`,
164
+ * `…`, `─`, `◆` — and this operator's terminal is a Korean one. (The removal marker's brackets
165
+ * `\u27ea`/`\u27eb` are Na, narrow: round-7 corrected this comment, which still claimed otherwise.)
166
+ */
167
+ const WIDE = [
168
+ 0x000a1, 0x000a1, 0x000a4, 0x000a4, 0x000a7, 0x000a8, 0x000aa, 0x000aa, 0x000ad, 0x000ae,
169
+ 0x000b0, 0x000b4, 0x000b6, 0x000ba, 0x000bc, 0x000bf, 0x000c6, 0x000c6, 0x000d0, 0x000d0,
170
+ 0x000d7, 0x000d8, 0x000de, 0x000e1, 0x000e6, 0x000e6, 0x000e8, 0x000ea, 0x000ec, 0x000ed,
171
+ 0x000f0, 0x000f0, 0x000f2, 0x000f3, 0x000f7, 0x000fa, 0x000fc, 0x000fc, 0x000fe, 0x000fe,
172
+ 0x00101, 0x00101, 0x00111, 0x00111, 0x00113, 0x00113, 0x0011b, 0x0011b, 0x00126, 0x00127,
173
+ 0x0012b, 0x0012b, 0x00131, 0x00133, 0x00138, 0x00138, 0x0013f, 0x00142, 0x00144, 0x00144,
174
+ 0x00148, 0x0014b, 0x0014d, 0x0014d, 0x00152, 0x00153, 0x00166, 0x00167, 0x0016b, 0x0016b,
175
+ 0x001ce, 0x001ce, 0x001d0, 0x001d0, 0x001d2, 0x001d2, 0x001d4, 0x001d4, 0x001d6, 0x001d6,
176
+ 0x001d8, 0x001d8, 0x001da, 0x001da, 0x001dc, 0x001dc, 0x00251, 0x00251, 0x00261, 0x00261,
177
+ 0x002c4, 0x002c4, 0x002c7, 0x002c7, 0x002c9, 0x002cb, 0x002cd, 0x002cd, 0x002d0, 0x002d0,
178
+ 0x002d8, 0x002db, 0x002dd, 0x002dd, 0x002df, 0x002df, 0x00300, 0x0036f, 0x00391, 0x003a1,
179
+ 0x003a3, 0x003a9, 0x003b1, 0x003c1, 0x003c3, 0x003c9, 0x00401, 0x00401, 0x00410, 0x0044f,
180
+ 0x00451, 0x00451, 0x01100, 0x0115f, 0x02010, 0x02010, 0x02013, 0x02016, 0x02018, 0x02019,
181
+ 0x0201c, 0x0201d, 0x02020, 0x02022, 0x02024, 0x02027, 0x02030, 0x02030, 0x02032, 0x02033,
182
+ 0x02035, 0x02035, 0x0203b, 0x0203b, 0x0203e, 0x0203e, 0x02074, 0x02074, 0x0207f, 0x0207f,
183
+ 0x02081, 0x02084, 0x020ac, 0x020ac, 0x02103, 0x02103, 0x02105, 0x02105, 0x02109, 0x02109,
184
+ 0x02113, 0x02113, 0x02116, 0x02116, 0x02121, 0x02122, 0x02126, 0x02126, 0x0212b, 0x0212b,
185
+ 0x02153, 0x02154, 0x0215b, 0x0215e, 0x02160, 0x0216b, 0x02170, 0x02179, 0x02189, 0x02189,
186
+ 0x02190, 0x02199, 0x021b8, 0x021b9, 0x021d2, 0x021d2, 0x021d4, 0x021d4, 0x021e7, 0x021e7,
187
+ 0x02200, 0x02200, 0x02202, 0x02203, 0x02207, 0x02208, 0x0220b, 0x0220b, 0x0220f, 0x0220f,
188
+ 0x02211, 0x02211, 0x02215, 0x02215, 0x0221a, 0x0221a, 0x0221d, 0x02220, 0x02223, 0x02223,
189
+ 0x02225, 0x02225, 0x02227, 0x0222c, 0x0222e, 0x0222e, 0x02234, 0x02237, 0x0223c, 0x0223d,
190
+ 0x02248, 0x02248, 0x0224c, 0x0224c, 0x02252, 0x02252, 0x02260, 0x02261, 0x02264, 0x02267,
191
+ 0x0226a, 0x0226b, 0x0226e, 0x0226f, 0x02282, 0x02283, 0x02286, 0x02287, 0x02295, 0x02295,
192
+ 0x02299, 0x02299, 0x022a5, 0x022a5, 0x022bf, 0x022bf, 0x02312, 0x02312, 0x0231a, 0x0231b,
193
+ 0x02329, 0x0232a, 0x023e9, 0x023ec, 0x023f0, 0x023f0, 0x023f3, 0x023f3, 0x02460, 0x024e9,
194
+ 0x024eb, 0x0254b, 0x02550, 0x02573, 0x02580, 0x0258f, 0x02592, 0x02595, 0x025a0, 0x025a1,
195
+ 0x025a3, 0x025a9, 0x025b2, 0x025b3, 0x025b6, 0x025b7, 0x025bc, 0x025bd, 0x025c0, 0x025c1,
196
+ 0x025c6, 0x025c8, 0x025cb, 0x025cb, 0x025ce, 0x025d1, 0x025e2, 0x025e5, 0x025ef, 0x025ef,
197
+ 0x025fd, 0x025fe, 0x02605, 0x02606, 0x02609, 0x02609, 0x0260e, 0x0260f, 0x02614, 0x02615,
198
+ 0x0261c, 0x0261c, 0x0261e, 0x0261e, 0x02630, 0x02637, 0x02640, 0x02640, 0x02642, 0x02642,
199
+ 0x02648, 0x02653, 0x02660, 0x02661, 0x02663, 0x02665, 0x02667, 0x0266a, 0x0266c, 0x0266d,
200
+ 0x0266f, 0x0266f, 0x0267f, 0x0267f, 0x0268a, 0x0268f, 0x02693, 0x02693, 0x0269e, 0x0269f,
201
+ 0x026a1, 0x026a1, 0x026aa, 0x026ab, 0x026bd, 0x026bf, 0x026c4, 0x026e1, 0x026e3, 0x026e3,
202
+ 0x026e8, 0x026ff, 0x02705, 0x02705, 0x0270a, 0x0270b, 0x02728, 0x02728, 0x0273d, 0x0273d,
203
+ 0x0274c, 0x0274c, 0x0274e, 0x0274e, 0x02753, 0x02755, 0x02757, 0x02757, 0x02776, 0x0277f,
204
+ 0x02795, 0x02797, 0x027b0, 0x027b0, 0x027bf, 0x027bf, 0x02b1b, 0x02b1c, 0x02b50, 0x02b50,
205
+ 0x02b55, 0x02b59, 0x02e80, 0x02e99, 0x02e9b, 0x02ef3, 0x02f00, 0x02fd5, 0x02ff0, 0x0303e,
206
+ 0x03041, 0x03096, 0x03099, 0x030ff, 0x03105, 0x0312f, 0x03131, 0x0318e, 0x03190, 0x031e5,
207
+ 0x031ef, 0x0321e, 0x03220, 0x0a48c, 0x0a490, 0x0a4c6, 0x0a960, 0x0a97c, 0x0ac00, 0x0d7a3,
208
+ 0x0e000, 0x0faff, 0x0fe00, 0x0fe19, 0x0fe30, 0x0fe52, 0x0fe54, 0x0fe66, 0x0fe68, 0x0fe6b,
209
+ 0x0ff01, 0x0ff60, 0x0ffe0, 0x0ffe6, 0x0fffd, 0x0fffd, 0x16fe0, 0x16fe4, 0x16ff0, 0x16ff1,
210
+ 0x17000, 0x187f7, 0x18800, 0x18cd5, 0x18cff, 0x18d08, 0x1aff0, 0x1aff3, 0x1aff5, 0x1affb,
211
+ 0x1affd, 0x1affe, 0x1b000, 0x1b122, 0x1b132, 0x1b132, 0x1b150, 0x1b152, 0x1b155, 0x1b155,
212
+ 0x1b164, 0x1b167, 0x1b170, 0x1b2fb, 0x1d300, 0x1d356, 0x1d360, 0x1d376, 0x1f004, 0x1f004,
213
+ 0x1f0cf, 0x1f0cf, 0x1f100, 0x1f10a, 0x1f110, 0x1f12d, 0x1f130, 0x1f169, 0x1f170, 0x1f1ac,
214
+ 0x1f200, 0x1f202, 0x1f210, 0x1f23b, 0x1f240, 0x1f248, 0x1f250, 0x1f251, 0x1f260, 0x1f265,
215
+ 0x1f300, 0x1f320, 0x1f32d, 0x1f335, 0x1f337, 0x1f37c, 0x1f37e, 0x1f393, 0x1f3a0, 0x1f3ca,
216
+ 0x1f3cf, 0x1f3d3, 0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440,
217
+ 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d, 0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a, 0x1f57a,
218
+ 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f, 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc,
219
+ 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d7, 0x1f6dc, 0x1f6df, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc,
220
+ 0x1f7e0, 0x1f7eb, 0x1f7f0, 0x1f7f0, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff,
221
+ 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fa89, 0x1fa8f, 0x1fac6, 0x1face, 0x1fadc, 0x1fadf, 0x1fae9,
222
+ 0x1faf0, 0x1faf8, 0x20000, 0x2fffd, 0x30000, 0x3fffd, 0xe0100, 0xe01ef, 0xf0000, 0xffffd,
223
+ 0x100000, 0x10fffd,
224
+ ];
225
+ const ZERO = [
226
+ 0x00300, 0x0036f, 0x00483, 0x00489, 0x00591, 0x005bd, 0x005bf, 0x005bf, 0x005c1, 0x005c2,
227
+ 0x005c4, 0x005c5, 0x005c7, 0x005c7, 0x00610, 0x0061a, 0x0064b, 0x0065f, 0x00670, 0x00670,
228
+ 0x006d6, 0x006dc, 0x006df, 0x006e4, 0x006e7, 0x006e8, 0x006ea, 0x006ed, 0x00711, 0x00711,
229
+ 0x00730, 0x0074a, 0x007a6, 0x007b0, 0x007eb, 0x007f3, 0x007fd, 0x007fd, 0x00816, 0x00819,
230
+ 0x0081b, 0x00823, 0x00825, 0x00827, 0x00829, 0x0082d, 0x00859, 0x0085b, 0x00897, 0x0089f,
231
+ 0x008ca, 0x008e1, 0x008e3, 0x00902, 0x0093a, 0x0093a, 0x0093c, 0x0093c, 0x00941, 0x00948,
232
+ 0x0094d, 0x0094d, 0x00951, 0x00957, 0x00962, 0x00963, 0x00981, 0x00981, 0x009bc, 0x009bc,
233
+ 0x009c1, 0x009c4, 0x009cd, 0x009cd, 0x009e2, 0x009e3, 0x009fe, 0x009fe, 0x00a01, 0x00a02,
234
+ 0x00a3c, 0x00a3c, 0x00a41, 0x00a42, 0x00a47, 0x00a48, 0x00a4b, 0x00a4d, 0x00a51, 0x00a51,
235
+ 0x00a70, 0x00a71, 0x00a75, 0x00a75, 0x00a81, 0x00a82, 0x00abc, 0x00abc, 0x00ac1, 0x00ac5,
236
+ 0x00ac7, 0x00ac8, 0x00acd, 0x00acd, 0x00ae2, 0x00ae3, 0x00afa, 0x00aff, 0x00b01, 0x00b01,
237
+ 0x00b3c, 0x00b3c, 0x00b3f, 0x00b3f, 0x00b41, 0x00b44, 0x00b4d, 0x00b4d, 0x00b55, 0x00b56,
238
+ 0x00b62, 0x00b63, 0x00b82, 0x00b82, 0x00bc0, 0x00bc0, 0x00bcd, 0x00bcd, 0x00c00, 0x00c00,
239
+ 0x00c04, 0x00c04, 0x00c3c, 0x00c3c, 0x00c3e, 0x00c40, 0x00c46, 0x00c48, 0x00c4a, 0x00c4d,
240
+ 0x00c55, 0x00c56, 0x00c62, 0x00c63, 0x00c81, 0x00c81, 0x00cbc, 0x00cbc, 0x00cbf, 0x00cbf,
241
+ 0x00cc6, 0x00cc6, 0x00ccc, 0x00ccd, 0x00ce2, 0x00ce3, 0x00d00, 0x00d01, 0x00d3b, 0x00d3c,
242
+ 0x00d41, 0x00d44, 0x00d4d, 0x00d4d, 0x00d62, 0x00d63, 0x00d81, 0x00d81, 0x00dca, 0x00dca,
243
+ 0x00dd2, 0x00dd4, 0x00dd6, 0x00dd6, 0x00e31, 0x00e31, 0x00e34, 0x00e3a, 0x00e47, 0x00e4e,
244
+ 0x00eb1, 0x00eb1, 0x00eb4, 0x00ebc, 0x00ec8, 0x00ece, 0x00f18, 0x00f19, 0x00f35, 0x00f35,
245
+ 0x00f37, 0x00f37, 0x00f39, 0x00f39, 0x00f71, 0x00f7e, 0x00f80, 0x00f84, 0x00f86, 0x00f87,
246
+ 0x00f8d, 0x00f97, 0x00f99, 0x00fbc, 0x00fc6, 0x00fc6, 0x0102d, 0x01030, 0x01032, 0x01037,
247
+ 0x01039, 0x0103a, 0x0103d, 0x0103e, 0x01058, 0x01059, 0x0105e, 0x01060, 0x01071, 0x01074,
248
+ 0x01082, 0x01082, 0x01085, 0x01086, 0x0108d, 0x0108d, 0x0109d, 0x0109d, 0x0135d, 0x0135f,
249
+ 0x01712, 0x01714, 0x01732, 0x01733, 0x01752, 0x01753, 0x01772, 0x01773, 0x017b4, 0x017b5,
250
+ 0x017b7, 0x017bd, 0x017c6, 0x017c6, 0x017c9, 0x017d3, 0x017dd, 0x017dd, 0x0180b, 0x0180d,
251
+ 0x0180f, 0x0180f, 0x01885, 0x01886, 0x018a9, 0x018a9, 0x01920, 0x01922, 0x01927, 0x01928,
252
+ 0x01932, 0x01932, 0x01939, 0x0193b, 0x01a17, 0x01a18, 0x01a1b, 0x01a1b, 0x01a56, 0x01a56,
253
+ 0x01a58, 0x01a5e, 0x01a60, 0x01a60, 0x01a62, 0x01a62, 0x01a65, 0x01a6c, 0x01a73, 0x01a7c,
254
+ 0x01a7f, 0x01a7f, 0x01ab0, 0x01ace, 0x01b00, 0x01b03, 0x01b34, 0x01b34, 0x01b36, 0x01b3a,
255
+ 0x01b3c, 0x01b3c, 0x01b42, 0x01b42, 0x01b6b, 0x01b73, 0x01b80, 0x01b81, 0x01ba2, 0x01ba5,
256
+ 0x01ba8, 0x01ba9, 0x01bab, 0x01bad, 0x01be6, 0x01be6, 0x01be8, 0x01be9, 0x01bed, 0x01bed,
257
+ 0x01bef, 0x01bf1, 0x01c2c, 0x01c33, 0x01c36, 0x01c37, 0x01cd0, 0x01cd2, 0x01cd4, 0x01ce0,
258
+ 0x01ce2, 0x01ce8, 0x01ced, 0x01ced, 0x01cf4, 0x01cf4, 0x01cf8, 0x01cf9, 0x01dc0, 0x01dff,
259
+ 0x020d0, 0x020f0, 0x02cef, 0x02cf1, 0x02d7f, 0x02d7f, 0x02de0, 0x02dff, 0x0302a, 0x0302d,
260
+ 0x03099, 0x0309a, 0x0a66f, 0x0a672, 0x0a674, 0x0a67d, 0x0a69e, 0x0a69f, 0x0a6f0, 0x0a6f1,
261
+ 0x0a802, 0x0a802, 0x0a806, 0x0a806, 0x0a80b, 0x0a80b, 0x0a825, 0x0a826, 0x0a82c, 0x0a82c,
262
+ 0x0a8c4, 0x0a8c5, 0x0a8e0, 0x0a8f1, 0x0a8ff, 0x0a8ff, 0x0a926, 0x0a92d, 0x0a947, 0x0a951,
263
+ 0x0a980, 0x0a982, 0x0a9b3, 0x0a9b3, 0x0a9b6, 0x0a9b9, 0x0a9bc, 0x0a9bd, 0x0a9e5, 0x0a9e5,
264
+ 0x0aa29, 0x0aa2e, 0x0aa31, 0x0aa32, 0x0aa35, 0x0aa36, 0x0aa43, 0x0aa43, 0x0aa4c, 0x0aa4c,
265
+ 0x0aa7c, 0x0aa7c, 0x0aab0, 0x0aab0, 0x0aab2, 0x0aab4, 0x0aab7, 0x0aab8, 0x0aabe, 0x0aabf,
266
+ 0x0aac1, 0x0aac1, 0x0aaec, 0x0aaed, 0x0aaf6, 0x0aaf6, 0x0abe5, 0x0abe5, 0x0abe8, 0x0abe8,
267
+ 0x0abed, 0x0abed, 0x0fb1e, 0x0fb1e, 0x0fe00, 0x0fe0f, 0x0fe20, 0x0fe2f, 0x101fd, 0x101fd,
268
+ 0x102e0, 0x102e0, 0x10376, 0x1037a, 0x10a01, 0x10a03, 0x10a05, 0x10a06, 0x10a0c, 0x10a0f,
269
+ 0x10a38, 0x10a3a, 0x10a3f, 0x10a3f, 0x10ae5, 0x10ae6, 0x10d24, 0x10d27, 0x10d69, 0x10d6d,
270
+ 0x10eab, 0x10eac, 0x10efc, 0x10eff, 0x10f46, 0x10f50, 0x10f82, 0x10f85, 0x11001, 0x11001,
271
+ 0x11038, 0x11046, 0x11070, 0x11070, 0x11073, 0x11074, 0x1107f, 0x11081, 0x110b3, 0x110b6,
272
+ 0x110b9, 0x110ba, 0x110c2, 0x110c2, 0x11100, 0x11102, 0x11127, 0x1112b, 0x1112d, 0x11134,
273
+ 0x11173, 0x11173, 0x11180, 0x11181, 0x111b6, 0x111be, 0x111c9, 0x111cc, 0x111cf, 0x111cf,
274
+ 0x1122f, 0x11231, 0x11234, 0x11234, 0x11236, 0x11237, 0x1123e, 0x1123e, 0x11241, 0x11241,
275
+ 0x112df, 0x112df, 0x112e3, 0x112ea, 0x11300, 0x11301, 0x1133b, 0x1133c, 0x11340, 0x11340,
276
+ 0x11366, 0x1136c, 0x11370, 0x11374, 0x113bb, 0x113c0, 0x113ce, 0x113ce, 0x113d0, 0x113d0,
277
+ 0x113d2, 0x113d2, 0x113e1, 0x113e2, 0x11438, 0x1143f, 0x11442, 0x11444, 0x11446, 0x11446,
278
+ 0x1145e, 0x1145e, 0x114b3, 0x114b8, 0x114ba, 0x114ba, 0x114bf, 0x114c0, 0x114c2, 0x114c3,
279
+ 0x115b2, 0x115b5, 0x115bc, 0x115bd, 0x115bf, 0x115c0, 0x115dc, 0x115dd, 0x11633, 0x1163a,
280
+ 0x1163d, 0x1163d, 0x1163f, 0x11640, 0x116ab, 0x116ab, 0x116ad, 0x116ad, 0x116b0, 0x116b5,
281
+ 0x116b7, 0x116b7, 0x1171d, 0x1171d, 0x1171f, 0x1171f, 0x11722, 0x11725, 0x11727, 0x1172b,
282
+ 0x1182f, 0x11837, 0x11839, 0x1183a, 0x1193b, 0x1193c, 0x1193e, 0x1193e, 0x11943, 0x11943,
283
+ 0x119d4, 0x119d7, 0x119da, 0x119db, 0x119e0, 0x119e0, 0x11a01, 0x11a0a, 0x11a33, 0x11a38,
284
+ 0x11a3b, 0x11a3e, 0x11a47, 0x11a47, 0x11a51, 0x11a56, 0x11a59, 0x11a5b, 0x11a8a, 0x11a96,
285
+ 0x11a98, 0x11a99, 0x11c30, 0x11c36, 0x11c38, 0x11c3d, 0x11c3f, 0x11c3f, 0x11c92, 0x11ca7,
286
+ 0x11caa, 0x11cb0, 0x11cb2, 0x11cb3, 0x11cb5, 0x11cb6, 0x11d31, 0x11d36, 0x11d3a, 0x11d3a,
287
+ 0x11d3c, 0x11d3d, 0x11d3f, 0x11d45, 0x11d47, 0x11d47, 0x11d90, 0x11d91, 0x11d95, 0x11d95,
288
+ 0x11d97, 0x11d97, 0x11ef3, 0x11ef4, 0x11f00, 0x11f01, 0x11f36, 0x11f3a, 0x11f40, 0x11f40,
289
+ 0x11f42, 0x11f42, 0x11f5a, 0x11f5a, 0x13440, 0x13440, 0x13447, 0x13455, 0x1611e, 0x16129,
290
+ 0x1612d, 0x1612f, 0x16af0, 0x16af4, 0x16b30, 0x16b36, 0x16f4f, 0x16f4f, 0x16f8f, 0x16f92,
291
+ 0x16fe4, 0x16fe4, 0x1bc9d, 0x1bc9e, 0x1cf00, 0x1cf2d, 0x1cf30, 0x1cf46, 0x1d167, 0x1d169,
292
+ 0x1d17b, 0x1d182, 0x1d185, 0x1d18b, 0x1d1aa, 0x1d1ad, 0x1d242, 0x1d244, 0x1da00, 0x1da36,
293
+ 0x1da3b, 0x1da6c, 0x1da75, 0x1da75, 0x1da84, 0x1da84, 0x1da9b, 0x1da9f, 0x1daa1, 0x1daaf,
294
+ 0x1e000, 0x1e006, 0x1e008, 0x1e018, 0x1e01b, 0x1e021, 0x1e023, 0x1e024, 0x1e026, 0x1e02a,
295
+ 0x1e08f, 0x1e08f, 0x1e130, 0x1e136, 0x1e2ae, 0x1e2ae, 0x1e2ec, 0x1e2ef, 0x1e4ec, 0x1e4ef,
296
+ 0x1e5ee, 0x1e5ef, 0x1e8d0, 0x1e8d6, 0x1e944, 0x1e94a, 0xe0100, 0xe01ef,
297
+ ];
298
+ /**
299
+ * East_Asian_Width W or F **and not a space separator**, from UCD 16.0.0. Ambiguous is
300
+ * excluded here on purpose (unlike `WIDE`, which over-counts on purpose for budgeting): on a decision
301
+ * row a character whose width depends on the terminal's mode has no place at all — it escapes.
302
+ * U+3000 is Wide and is a space, so it escapes too: invisible cells are how a row gets padded into
303
+ * an alignment forgery (round-8).
304
+ */
305
+ const FIXED_WIDE = [
306
+ 0x01100, 0x0115f, 0x0231a, 0x0231b, 0x02329, 0x0232a, 0x023e9, 0x023ec, 0x023f0, 0x023f0,
307
+ 0x023f3, 0x023f3, 0x025fd, 0x025fe, 0x02614, 0x02615, 0x02630, 0x02637, 0x02648, 0x02653,
308
+ 0x0267f, 0x0267f, 0x0268a, 0x0268f, 0x02693, 0x02693, 0x026a1, 0x026a1, 0x026aa, 0x026ab,
309
+ 0x026bd, 0x026be, 0x026c4, 0x026c5, 0x026ce, 0x026ce, 0x026d4, 0x026d4, 0x026ea, 0x026ea,
310
+ 0x026f2, 0x026f3, 0x026f5, 0x026f5, 0x026fa, 0x026fa, 0x026fd, 0x026fd, 0x02705, 0x02705,
311
+ 0x0270a, 0x0270b, 0x02728, 0x02728, 0x0274c, 0x0274c, 0x0274e, 0x0274e, 0x02753, 0x02755,
312
+ 0x02757, 0x02757, 0x02795, 0x02797, 0x027b0, 0x027b0, 0x027bf, 0x027bf, 0x02b1b, 0x02b1c,
313
+ 0x02b50, 0x02b50, 0x02b55, 0x02b55, 0x02e80, 0x02e99, 0x02e9b, 0x02ef3, 0x02f00, 0x02fd5,
314
+ 0x02ff0, 0x02fff, 0x03001, 0x0303e, 0x03041, 0x03096, 0x03099, 0x030ff, 0x03105, 0x0312f,
315
+ 0x03131, 0x0318e, 0x03190, 0x031e5, 0x031ef, 0x0321e, 0x03220, 0x03247, 0x03250, 0x0a48c,
316
+ 0x0a490, 0x0a4c6, 0x0a960, 0x0a97c, 0x0ac00, 0x0d7a3, 0x0f900, 0x0faff, 0x0fe10, 0x0fe19,
317
+ 0x0fe30, 0x0fe52, 0x0fe54, 0x0fe66, 0x0fe68, 0x0fe6b, 0x0ff01, 0x0ff60, 0x0ffe0, 0x0ffe6,
318
+ 0x16fe0, 0x16fe4, 0x16ff0, 0x16ff1, 0x17000, 0x187f7, 0x18800, 0x18cd5, 0x18cff, 0x18d08,
319
+ 0x1aff0, 0x1aff3, 0x1aff5, 0x1affb, 0x1affd, 0x1affe, 0x1b000, 0x1b122, 0x1b132, 0x1b132,
320
+ 0x1b150, 0x1b152, 0x1b155, 0x1b155, 0x1b164, 0x1b167, 0x1b170, 0x1b2fb, 0x1d300, 0x1d356,
321
+ 0x1d360, 0x1d376, 0x1f004, 0x1f004, 0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191, 0x1f19a,
322
+ 0x1f200, 0x1f202, 0x1f210, 0x1f23b, 0x1f240, 0x1f248, 0x1f250, 0x1f251, 0x1f260, 0x1f265,
323
+ 0x1f300, 0x1f320, 0x1f32d, 0x1f335, 0x1f337, 0x1f37c, 0x1f37e, 0x1f393, 0x1f3a0, 0x1f3ca,
324
+ 0x1f3cf, 0x1f3d3, 0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440,
325
+ 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d, 0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a, 0x1f57a,
326
+ 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f, 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc,
327
+ 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d7, 0x1f6dc, 0x1f6df, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc,
328
+ 0x1f7e0, 0x1f7eb, 0x1f7f0, 0x1f7f0, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff,
329
+ 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fa89, 0x1fa8f, 0x1fac6, 0x1face, 0x1fadc, 0x1fadf, 0x1fae9,
330
+ 0x1faf0, 0x1faf8, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
331
+ ];
332
+ const isFixedWide = (cp) => inRanges(FIXED_WIDE, cp);
333
+ /**
334
+ * Characters that are INVISIBLE or MOVE THE CURSOR, from UCD 16.0.0: categories Cc, Cf,
335
+ * Cs, Zl, Zp, every Zs except the space bar, and the variation selectors (U+FE00-FE0F and the
336
+ * supplement U+E0100-E01EF, which `TAG_BLOCK` does not cover).
337
+ *
338
+ * Round-10 swept all 1,112,064 code points and found 2,068 of these still printed RAW and invisible
339
+ * by `stripControl` while the row escaped them — including U+FE0F and the whole variation-selector
340
+ * supplement. This module's own comment names that threat for TAG characters: "the operator reads a
341
+ * benign address while the grant's scope pattern is the RAW target". `[v]` escapes them too now.
342
+ *
343
+ * `[v]` does NOT escape everything the row does, and that asymmetry is deliberate: the row needs
344
+ * every character's width to be fixed, so it also escapes Ambiguous-width and combining characters.
345
+ * `[v]` is folded, not budgeted, so a visible glyph can stay visible there — including a combining
346
+ * mark, which composes onto a base the operator can see.
347
+ */
348
+ const INVISIBLE = [
349
+ 0x00000, 0x0001f, 0x0007f, 0x000a0, 0x000ad, 0x000ad, 0x00600, 0x00605, 0x0061c, 0x0061c,
350
+ 0x006dd, 0x006dd, 0x0070f, 0x0070f, 0x00890, 0x00891, 0x008e2, 0x008e2, 0x01680, 0x01680,
351
+ 0x0180e, 0x0180e, 0x02000, 0x0200f, 0x02028, 0x0202f, 0x0205f, 0x02064, 0x02066, 0x0206f,
352
+ 0x03000, 0x03000, 0x0d800, 0x0dfff, 0x0fe00, 0x0fe0f, 0x0feff, 0x0feff, 0x0fff9, 0x0fffb,
353
+ 0x110bd, 0x110bd, 0x110cd, 0x110cd, 0x13430, 0x1343f, 0x1bca0, 0x1bca3, 0x1d173, 0x1d17a,
354
+ 0xe0001, 0xe0001, 0xe0020, 0xe007f, 0xe0100, 0xe01ef,
355
+ ];
356
+ /** Is `cp` inside one of the flat [lo,hi] pairs? */
357
+ function inRanges(table, cp) {
358
+ let lo = 0;
359
+ let hi = (table.length >> 1) - 1;
360
+ while (lo <= hi) {
361
+ const mid = (lo + hi) >> 1;
362
+ if (cp < table[mid * 2])
363
+ hi = mid - 1;
364
+ else if (cp > table[mid * 2 + 1])
365
+ lo = mid + 1;
366
+ else
367
+ return true;
368
+ }
369
+ return false;
370
+ }
371
+ /**
372
+ * One line, no control bytes, bounded in terminal COLUMNS — for a field the template puts on a line
373
+ * of its own. `max` is a column budget, not a character count (round-5): the caller is buying screen
374
+ * space, and on this screen a character can cost one column or two.
375
+ *
376
+ * The budget is spent on the OPERATOR'S text: the source is clipped first so escape spam cannot
377
+ * evict the real command (round-3), and the sanitised result is clipped again so markers cannot
378
+ * inflate the output (round-4). The truncation notice is appended AFTER sanitising, so no sequence
379
+ * sitting at the clip boundary can swallow it (round-4).
380
+ */
381
+ function flattenField(s, max = exports.ROW_COLS) {
382
+ return renderField(s, max).text;
383
+ }
384
+ /**
385
+ * The renderer behind `flattenField`, returning HOW MUCH IT HID as well as what it shows.
386
+ *
387
+ * Round-8 split this out: callers that need to know whether anything was hidden used to test the
388
+ * rendered string against the notice's shape, which the data can imitate. A fact about the render
389
+ * belongs to the renderer.
390
+ */
391
+ function renderField(s, max = exports.ROW_COLS) {
392
+ // Walked TOKEN BY TOKEN so two round-5 defects cannot exist: the clip can never land inside a
393
+ // marker (markers are emitted whole), and the truncation notice counts SOURCE code points the
394
+ // operator is not seeing — round-4's collapse-then-clip counted the marker-expanded string, which
395
+ // overstated by 4x on one input and understated by 2x on another. On a screen whose job is telling
396
+ // the human how much of the subject is hidden, the number must come from the subject.
397
+ // No TAB expansion here, deliberately: `TOKEN`'s whitespace class already absorbs tabs and this
398
+ // function collapses any whitespace run to one space, so the replace round-7 added was an
399
+ // EQUIVALENT MUTANT — measured over 60,000 input pairs, zero differences. §11's claim that
400
+ // flattenField "expands TAB to one space" was inaccurate; it collapses a run of ten to one space,
401
+ // which is the same visible result by a different mechanism. The expansion that matters is in
402
+ // `stripControl`, where newlines are content and runs are not collapsed. Code with no effect is
403
+ // not defence in depth (round-5's rule about unexercised guards).
404
+ const src = neutraliseMarkers(String(s));
405
+ const total = [...src].length;
406
+ // A COLUMN budget alone does not bound the output, because a combining mark advances zero columns
407
+ // (round-6). Measured: a target of `git status` plus two million U+0301 came back WHOLE from a
408
+ // 52-column call — 2,000,021 code points, 4 MB written to the screen, 590 MB resident, and
409
+ // `truncated` false, so the notice claimed nothing was hidden. That is this module's property 3
410
+ // failing outright. Cells are what the screen shows; code points are what it must still transmit,
411
+ // so both are capped. Eight per column is far past any legitimate stacking (a decomposed Hangul
412
+ // or Vietnamese syllable is two or three).
413
+ const cpCap = max * 8;
414
+ const walk = (budget) => {
415
+ let out = '';
416
+ let outCols = 0;
417
+ let outCps = 0;
418
+ let consumed = 0;
419
+ let pendingSpace = false;
420
+ for (const [tok] of src.matchAll(TOKEN)) {
421
+ const tokLen = [...tok].length;
422
+ if (/^[\r\n\t ]+$/.test(tok)) {
423
+ pendingSpace = out !== '';
424
+ consumed += tokLen;
425
+ continue;
426
+ }
427
+ const piece = REMOVABLE_RUN.test(tok) ? mark(tok) : tok;
428
+ REMOVABLE_RUN.lastIndex = 0;
429
+ const withSpace = (pendingSpace ? ' ' : '') + piece;
430
+ const cost = displayWidth(withSpace);
431
+ const cps = [...withSpace].length;
432
+ if (outCols + cost > budget || outCps + cps > cpCap)
433
+ break;
434
+ out += withSpace;
435
+ outCols += cost;
436
+ outCps += cps;
437
+ pendingSpace = false;
438
+ consumed += tokLen;
439
+ }
440
+ return { out, consumed };
441
+ };
442
+ // The NOTICE IS INSIDE THE BUDGET (round-5). It used to be appended after the walk, so a caller
443
+ // asking for 72 columns got 97 — and a budget that the thing being budgeted can overrun is not a
444
+ // budget. Its width depends on how much was hidden, which depends on the budget, so the walk is
445
+ // repeated until it settles (it settles at once in every real case; the loop is for the digit
446
+ // boundaries where hiding one more character lengthens the number).
447
+ let budget = max;
448
+ for (let pass = 0; pass < 4; pass++) {
449
+ const r = walk(budget);
450
+ if (r.consumed >= total)
451
+ return { text: r.out, hidden: 0 }; // nothing hidden, nothing to say
452
+ const hidden = total - r.consumed;
453
+ const notice = shortNotice(max, hidden);
454
+ if (displayWidth(r.out) + displayWidth(notice) <= max)
455
+ return { text: r.out + notice, hidden };
456
+ budget = max - displayWidth(notice);
457
+ }
458
+ // A budget too small to hold even the notice: say the honest thing rather than a silent nothing.
459
+ // Callers on this screen pass at least 20; this is the guard, not a path.
460
+ return { text: shortNotice(max, total), hidden: total };
461
+ }
462
+ /**
463
+ * @implements A-SPEC-262.1 §13
464
+ * The form agent-controlled text takes on a DECISION ROW: only characters whose width is fixed by
465
+ * the standard, everything else as an ASCII escape.
466
+ *
467
+ * Eight adversarial rounds found the same shape of defect eight times — a character whose width the
468
+ * code guessed (Ambiguous, combining, zero-width), or whose cursor effect it did not model (TAB,
469
+ * ESC), or whose position let the data choose a line boundary (alignment, folding). None of them can
470
+ * occur here, because none of those characters survive to the row: a control byte is not stripped
471
+ * and counted, it is SHOWN as `\u001b`. Nothing is removed, so nothing is hidden — which also
472
+ * retires the removal marker, its forgery guard and its size accounting from this path entirely.
473
+ *
474
+ * What passes through: printable ASCII (one column each) and East_Asian_Width W or F (two columns
475
+ * each, fixed by the standard). Korean and CJK commands therefore stay readable on the screen the
476
+ * operator decides from — the requirement that made this a hybrid rather than a full escaping.
477
+ * A backslash is doubled first, so data can never spell an escape the renderer did not write.
478
+ */
479
+ function rowLiteral(s) {
480
+ return rowPieces(s).map((p) => p.text).join('');
481
+ }
482
+ /**
483
+ * @implements A-SPEC-262.1 §13
484
+ * The row's pieces: one per source character, except that a run of the same escaped code point
485
+ * becomes one piece carrying its count. ONE builder, so `rowLiteral` and `rowField` cannot render
486
+ * the same value two different ways — round-10 found the un-collapsed form ambiguous where the
487
+ * collapsed one was not, which is the same defect twice with different reachability.
488
+ *
489
+ * The notation belongs to the renderer. `*` and digits are printable ASCII, so the DATA could
490
+ * supply them: `chmod` + two U+200B + `777 /etc/shadow` rendered `chmod \u200b*2777 /etc/shadow`,
491
+ * absorbing the mode argument into the count — a removal on a decision row, with no notice and no
492
+ * escape byte, and two different sources producing identical rows. Doubling backslashes guarded the
493
+ * wrong half; what must be escaped is whatever could EXTEND the renderer's own token.
494
+ */
495
+ /** One source character as it appears on a decision row. Width: 1 per ASCII char, 2 per W/F char. */
496
+ function rowLiteralChar(ch) {
497
+ const cp = ch.codePointAt(0);
498
+ if (ch === '\\')
499
+ return '\\\\';
500
+ if (cp >= 0x20 && cp <= 0x7e)
501
+ return ch;
502
+ // Wide and Fullwidth only — never Ambiguous, and never a zero-advance mark, even a wide-block one.
503
+ if (!inRanges(ZERO, cp) && isFixedWide(cp))
504
+ return ch;
505
+ return cp > 0xffff
506
+ ? `\\u{${cp.toString(16)}}`
507
+ : `\\u${cp.toString(16).padStart(4, '0')}`;
508
+ }
509
+ function rowPieces(value) {
510
+ const pieces = [];
511
+ let prevWasEscape = false;
512
+ let prevWasCount = false;
513
+ for (const ch of String(value)) {
514
+ let piece = rowLiteralChar(ch);
515
+ if (piece === '*' && prevWasEscape)
516
+ piece = '\\u002a';
517
+ else if (prevWasCount && piece >= '0' && piece <= '9')
518
+ piece = `\\u003${piece}`;
519
+ const prev = pieces[pieces.length - 1];
520
+ if (prev && prev.piece === piece && piece.startsWith('\\') && piece !== '\\\\') {
521
+ prev.chars += 1;
522
+ prev.text = `${piece}*${prev.chars}`;
523
+ prevWasEscape = true;
524
+ prevWasCount = true;
525
+ continue;
526
+ }
527
+ pieces.push({ text: piece, chars: 1, piece });
528
+ prevWasEscape = piece.startsWith('\\') && piece !== '\\\\';
529
+ prevWasCount = false;
530
+ }
531
+ return pieces;
532
+ }
533
+ /**
534
+ * @implements A-SPEC-262.1 §13
535
+ * A decision row's field: the row literal, cut to `max` columns, with what it could not show stated.
536
+ */
537
+ function rowField(value, max = exports.ROW_COLS) {
538
+ return renderRow(value, max).text;
539
+ }
540
+ function renderRow(value, max) {
541
+ const chars = [...String(value)];
542
+ const pieces = rowPieces(value);
543
+ const fits = (budget) => {
544
+ let text = '';
545
+ let cols = 0;
546
+ let used = 0;
547
+ for (const p of pieces) {
548
+ const w = displayWidth(p.text);
549
+ if (cols + w > budget)
550
+ break;
551
+ text += p.text;
552
+ cols += w;
553
+ used += p.chars;
554
+ }
555
+ return { text, used };
556
+ };
557
+ let budget = max;
558
+ for (let pass = 0; pass < 4; pass++) {
559
+ const r = fits(budget);
560
+ if (r.used >= chars.length)
561
+ return { text: r.text, hidden: 0 };
562
+ const hidden = chars.length - r.used;
563
+ const notice = shortNotice(max, hidden);
564
+ if (r.text !== '' && displayWidth(r.text) + displayWidth(notice) <= max) {
565
+ return { text: r.text + notice, hidden };
566
+ }
567
+ budget = max - displayWidth(notice);
568
+ }
569
+ // Round-9: a cell whose budget cannot hold one piece AND the notice used to render as the notice
570
+ // alone — the category of authority reading `…(99999자)` and nothing else, which is the
571
+ // concealment round-5 named. One piece is always shown; the notice shrinks to whatever is left,
572
+ // and if even that does not fit the row states the count with no room wasted on punctuation.
573
+ const first = pieces[0];
574
+ if (!first)
575
+ return { text: '', hidden: 0 };
576
+ const total = chars.length;
577
+ if (first.chars >= total && displayWidth(first.text) <= max)
578
+ return { text: first.text, hidden: 0 };
579
+ // Three properties, in this order, because they cannot all hold in eight columns (round-10):
580
+ // 1. NEVER WIDER THAN THE BUDGET — a row that overflows takes the screen with it, which is the
581
+ // harm every round since the fifth has been about. This one is absolute.
582
+ // 2. NEVER HIDE SILENTLY — §13 promises the clip is stated. The notice shrinks rather than
583
+ // disappears: the count, then a bare ellipsis, which claims nothing it cannot support.
584
+ // 3. SHOW SOMETHING — a bound met by showing nothing is the concealment round-5 named.
585
+ // Round-9 had (3) and dropped (2); the first attempt at (2) dropped (3) and then broke (1) by
586
+ // printing a sixteen-column piece into eight columns.
587
+ const notices = (hidden) => [shortNotice(max, hidden), `…${hidden}`, '…'];
588
+ const withFirst = total - first.chars;
589
+ for (const notice of notices(withFirst)) {
590
+ if (displayWidth(first.text) + displayWidth(notice) <= max) {
591
+ return { text: first.text + notice, hidden: withFirst };
592
+ }
593
+ }
594
+ for (const notice of notices(total)) {
595
+ if (displayWidth(notice) <= max)
596
+ return { text: notice, hidden: total };
597
+ }
598
+ return { text: '', hidden: total };
599
+ }
600
+ /**
601
+ * Fold a sanitised string onto rows of at most `cols` columns, preferring space boundaries.
602
+ *
603
+ * Round-5: `[v]`'s whole job is showing the subject WHOLE, so clipping it to a row is the wrong
604
+ * answer there — but leaving it on one 8,000-column line is the same flood by another name. Folding
605
+ * keeps both: nothing is hidden and no line wraps unpredictably. Existing newlines are preserved
606
+ * (they are the body's own structure); a token longer than a row is hard-broken, since a URL with
607
+ * no spaces must still be readable.
608
+ *
609
+ * `indent` is a LEFT MARGIN THE TEMPLATE OWNS, and it is not decoration. Round-6: folding hands the
610
+ * choice of line boundary to the data, so a target padded to exact 76-column multiples put forged
611
+ * rows — `[1] shell git status`, `◆ 승인 대기 2건` — at column 0 of the `[v]` screen with ZERO escape
612
+ * bytes (measured on a real pty). The terminal's own wrapping had the same hole before folding
613
+ * existed. A margin the data cannot reach means no data byte ever starts a line, so nothing it emits
614
+ * can pose as a row this screen wrote.
615
+ */
616
+ /**
617
+ * Split a token into ATOMIC display units for hard-breaking: an escape sequence (`\uXXXX`,
618
+ * `\u{XXXXX}`), a doubled backslash, or one code point. Round-12: the tokenizer's escape
619
+ * alternatives only win when an escape STARTS a token, so an escape embedded in a long unbroken
620
+ * `\S+` run (`installer…x86_64ZZ\u00a0and-curl-evil`) was swallowed whole, and the char-by-char
621
+ * hard-break then cut inside it — `\u00` at a row's end, `a0` at the next's start. That is round-1's
622
+ * severed representation, in the `[v]` view a clipped row sends the operator to. Breaking by unit
623
+ * instead keeps every escape whole; only an escape that alone exceeds `room` (room=8 degenerate) is
624
+ * split, and it is split as a last resort, not by default.
625
+ */
626
+ function atomicUnits(tok) {
627
+ return tok.match(/\\u\{[0-9a-f]+\}|\\u[0-9a-f]{4}|\\\\|[\s\S]/gu) ?? [];
628
+ }
629
+ function wrapColumns(s, cols = 76, indent = '') {
630
+ const rows = [];
631
+ const room = Math.max(8, cols - displayWidth(indent));
632
+ for (const para of String(s).split('\n')) {
633
+ let row = '';
634
+ let width = 0;
635
+ const flush = () => { rows.push(row); row = ''; width = 0; };
636
+ // Round-10: an escape is ONE token. Folding used to hard-break inside `\u00a0`, leaving `\u` at
637
+ // the end of a row and `00a0` at the start of the next — the severed-surrogate lesson of round-1
638
+ // in the representation round-9 introduced. It split the truncation notice too, so a caller
639
+ // reading the screen could not even find the number.
640
+ for (const [tok] of para.matchAll(/\\u\{[0-9a-f]+\}|\\u[0-9a-f]{4}|\\\\|…\s*\([^)]*\)|\s+|\S+/gu)) {
641
+ const w = displayWidth(tok);
642
+ if (/^\s+$/.test(tok)) {
643
+ // Round-7: a run that did not fit was DROPPED here, silently. An aligned table in a spec
644
+ // body lost 120 of its 173 spaces and its columns merged into one — property 2 ("nothing
645
+ // vanishes silently") failing with no marker and no notice, on the screen whose job is
646
+ // showing the subject as it is. Whitespace is content; it folds like everything else.
647
+ // Round-8: this loop advanced by ONE per character, so U+3000 IDEOGRAPHIC SPACE — East
648
+ // Asian Wide, two cells — overflowed the fold and put data at physical column 0 again, the
649
+ // very forgery round-6's margin and round-7's tab expansion were added to stop. Round-7
650
+ // wrote this branch while fixing the silent-deletion bug and hardcoded the width it had just
651
+ // spent the round proving could not be assumed. Whitespace is measured like everything else.
652
+ for (const ch of tok) {
653
+ const cw = displayWidth(ch);
654
+ if (width + cw > room)
655
+ flush();
656
+ row += ch;
657
+ width += cw;
658
+ }
659
+ continue;
660
+ }
661
+ if (w > room) { // hard-break an unbreakable token
662
+ // By atomic UNIT, not code point (round-12): an escape embedded in this run must not be cut
663
+ // in half. A unit that alone exceeds room (only possible when room is at its 8-col floor)
664
+ // still falls back to a code-point break — unavoidable, and it cannot forge a row because
665
+ // the margin owns column 0.
666
+ for (const unit of atomicUnits(tok)) {
667
+ const uw = displayWidth(unit);
668
+ if (uw > room) {
669
+ for (const ch of unit) {
670
+ const cw = displayWidth(ch);
671
+ if (width + cw > room)
672
+ flush();
673
+ row += ch;
674
+ width += cw;
675
+ }
676
+ continue;
677
+ }
678
+ if (width + uw > room)
679
+ flush();
680
+ row += unit;
681
+ width += uw;
682
+ }
683
+ continue;
684
+ }
685
+ if (width + w > room)
686
+ flush();
687
+ row += tok;
688
+ width += w;
689
+ }
690
+ rows.push(row);
691
+ }
692
+ return rows.map((r) => indent + r).join('\n');
693
+ }
694
+ /** Newlines survive (they are the body); everything that moves the cursor or paints does not. */
695
+ /**
696
+ * Space separators that are not the space bar. Round-9: the decision row escapes these (§13 excludes
697
+ * them from FIXED_WIDE because invisible cells are how a row gets padded into a forgery), while
698
+ * `[v]` — the view the row SENDS the operator to when it clips — printed them raw and unmarked, so
699
+ * `rm -rf\u00a0/` read there exactly like `rm -rf /` though the argument is different. The fuller
700
+ * view must not be the less honest one; they are escaped here too.
701
+ *
702
+ * TAB joins them. Round-7 replaced it with one space, which already destroyed the indentation it
703
+ * was carrying, so the escape costs nothing in readability and says what the byte actually is.
704
+ */
705
+ const ODD_SPACES = /[\t\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/gu;
706
+ function stripControl(s) {
707
+ let out = '';
708
+ for (const ch of String(s)) {
709
+ if (ch === '\n') {
710
+ out += ch;
711
+ continue;
712
+ } // the body's own structure
713
+ if (ch === '\r') {
714
+ out += '\n';
715
+ continue;
716
+ }
717
+ if (ch === '\\') {
718
+ out += '\\\\';
719
+ continue;
720
+ } // round-10: so data cannot spell an escape
721
+ const cp = ch.codePointAt(0);
722
+ if (!inRanges(INVISIBLE, cp)) {
723
+ out += ch;
724
+ continue;
725
+ }
726
+ out += cp > 0xffff
727
+ ? `\\u{${cp.toString(16)}}`
728
+ : `\\u${cp.toString(16).padStart(4, '0')}`;
729
+ }
730
+ return out;
731
+ }
732
+ /**
733
+ * Clip on a CODE POINT boundary. Round-1: slicing UTF-16 units cut a surrogate pair in half and the
734
+ * last visible character of a clipped command rendered as a replacement glyph — on a screen whose
735
+ * whole job is showing the subject verbatim.
736
+ */
737
+ function clipCodePoints(s, max) {
738
+ const cps = [...s];
739
+ return cps.length <= max ? s : cps.slice(0, max).join('') + `… (${cps.length - max}자 잘림)`;
740
+ }
741
+ /**
742
+ * Quote a value that is about to appear inside a COPY-PASTEABLE command line.
743
+ *
744
+ * Round-2: the non-TTY hint (A-SPEC-260) fills the real queue id into three `holmes-kit approve …`
745
+ * lines whose whole purpose is being pasted into a shell — and the id is agent-controlled. Flattening
746
+ * stops it forging lines, but a flattened `… | sh` still PIPES when pasted. POSIX single quotes make
747
+ * any byte inert; a literal quote is closed, escaped and reopened.
748
+ */
749
+ function shellQuote(s) {
750
+ return `'${String(s).replace(/'/g, "'\\''")}'`;
751
+ }
752
+ /**
753
+ * A reference for a pasteable command line: left ALONE when it is already inert (the shape a real
754
+ * queue id has), quoted only when it is not. Round-2 wanted every pasted byte harmless; quoting
755
+ * unconditionally would also have rewritten the A-SPEC-260 hint that ships today, so the blast
756
+ * radius is kept to the case that needs it — a normal session's screen is byte-for-byte unchanged.
757
+ */
758
+ function safeRef(s) {
759
+ return /^[A-Za-z0-9_.:@/-]+$/.test(s) ? s : shellQuote(s);
760
+ }