@techierathore/techieflow 1.1.0 → 1.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 (106) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +7 -5
  5. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  6. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
  9. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  10. package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
  11. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  12. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  14. package/.opencode/plugin/techieflow.js +15 -7
  15. package/.tfcore/agents/flow-master.md +2 -0
  16. package/.tfcore/agents/verifier.md +2 -2
  17. package/.tfcore/core-config.yaml +2 -1
  18. package/.tfcore/hooks/block-git.sh +25 -2
  19. package/.tfcore/hooks/guard-db.sh +106 -9
  20. package/.tfcore/hooks/guard-status-html.sh +5 -1
  21. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  22. package/.tfcore/hooks/yolo-allow.sh +27 -0
  23. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  24. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  25. package/.tfcore/tasks/_develop-mode.md +34 -0
  26. package/.tfcore/tasks/_smoke-test-policy.md +7 -5
  27. package/.tfcore/tasks/_yolo-mode.md +2 -2
  28. package/.tfcore/tasks/amend-docs.md +1 -1
  29. package/.tfcore/tasks/day1-brownfield.md +2 -2
  30. package/.tfcore/tasks/day1-greenfield.md +4 -4
  31. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  32. package/.tfcore/tasks/fix-issues.md +1 -0
  33. package/.tfcore/tasks/mockups.md +1 -1
  34. package/.tfcore/tasks/triage-and-fix.md +2 -2
  35. package/.tfcore/tasks/verify-phase.md +12 -11
  36. package/.tfcore/telemetry/SCHEMA.md +6 -2
  37. package/.tfcore/telemetry/tf-metrics.sh +19 -0
  38. package/.tfcore/templates/stack-defaults/dotnet.md +5 -1
  39. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  40. package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
  41. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  42. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  43. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
  44. package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
  45. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  46. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  47. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/tf-appium.mjs +179 -0
  52. package/.tfcore/utils/tf-brd-status.py +10 -1
  53. package/.tfcore/utils/tf-build-list.py +16 -1
  54. package/.tfcore/utils/tf-build.sh +75 -0
  55. package/.tfcore/utils/tf-checklist-edit.py +28 -3
  56. package/.tfcore/utils/tf-day1-files.py +55 -28
  57. package/.tfcore/utils/tf-develop.sh +264 -0
  58. package/.tfcore/utils/tf-devguide-list.py +6 -2
  59. package/.tfcore/utils/tf-doc-check.py +124 -21
  60. package/.tfcore/utils/tf-doc-tests.sh +40 -3
  61. package/.tfcore/utils/tf-emit.sh +40 -10
  62. package/.tfcore/utils/tf-fix-close.sh +13 -7
  63. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  64. package/.tfcore/utils/tf-menus.mjs +401 -0
  65. package/.tfcore/utils/tf-mockup-parity.mjs +361 -28
  66. package/.tfcore/utils/tf-mockup-parity.sh +14 -3
  67. package/.tfcore/utils/tf-owner-text.py +27 -5
  68. package/.tfcore/utils/tf-phase.sh +24 -4
  69. package/.tfcore/utils/tf-render-html.py +19 -3
  70. package/.tfcore/utils/tf-req-ids.py +36 -0
  71. package/.tfcore/utils/tf-stack-check.sh +3 -0
  72. package/.tfcore/utils/tf-status-facts.py +90 -15
  73. package/.tfcore/utils/tf-theme.mjs +189 -0
  74. package/.tfcore/utils/tf-triage.py +30 -4
  75. package/.tfcore/utils/tf-verify-boot.sh +296 -21
  76. package/.tfcore/utils/tf-verify-emit.sh +37 -17
  77. package/.tfcore/utils/tf-verify-list.py +219 -20
  78. package/.tfcore/utils/tf-verify-list.sh +3 -1
  79. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  80. package/.tfcore/utils/tf-verify-screens.mjs +184 -20
  81. package/.tfcore/utils/tf-verify-screens.sh +17 -4
  82. package/.tfcore/utils/tf-verify-tests.sh +107 -17
  83. package/.tfcore/utils/tf-verify-verdict.py +118 -15
  84. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  85. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  86. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  87. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  88. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  89. package/.tfcore/utils/tf-yolo.sh +31 -3
  90. package/.tfcore/utils/tf_feedback.py +86 -18
  91. package/.tfcore/utils/tf_stack_check.py +93 -0
  92. package/README.md +2 -2
  93. package/opencode.jsonc +6 -2
  94. package/package.json +1 -1
  95. package/scripts/install.mjs +14 -3
  96. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  99. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  100. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  101. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  102. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  103. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  104. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  105. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  106. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -0,0 +1,193 @@
1
+ // tf-verify-zoom.mjs — presses the OS zoom keys on a Blazor Hybrid head and measures what they did
2
+ // (coding-standards-dotnet.md §9b; Lekhak, 2026-10-11). Called by tf-verify-zoom.sh; see there.
3
+ //
4
+ // Mac (--appium): mac2 presses Cmd+= / Cmd+- / Cmd+0 as real keys, so they go through the menu bar the
5
+ // way a person's do; macOS takes the window alone after each. The accessibility tree does not follow a
6
+ // WKWebView's page zoom (its text heights stayed 13 px at 130% on BlogAdmin), so the level is measured
7
+ // from the pictures: the scale that best maps the actual-size picture onto the zoomed one, from the
8
+ // first row where the two differ (the web view's top edge; the title bar above it does not zoom).
9
+ // Windows and any Chromium web view (--cdp): Playwright presses Ctrl+= / Ctrl+- / Ctrl+0 in the page
10
+ // and reads the zoom it paints at (CSS zoom on <html> times the device pixel ratio).
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
14
+ import { inflateSync } from 'node:zlib';
15
+ import { join } from 'node:path';
16
+
17
+ const args = process.argv.slice(2);
18
+ const opt = (k, d = '') => { const i = args.indexOf(k); return i >= 0 && i + 1 < args.length ? args[i + 1] : d; };
19
+ const flag = (k) => args.includes(k);
20
+ const OUT = opt('--out', 'tests/.artifacts/verify/zoom');
21
+ const LEAVE = parseInt(opt('--leave', '0'), 10) || 0;
22
+ const STEPS = [
23
+ // what is pressed, how often, the level expected afterwards (1.0 = actual size)
24
+ { key: 'reset', n: 1, want: 1.0, say: 'actual size' },
25
+ { key: 'in', n: 3, want: 1.3, say: 'zoom in 3 times' },
26
+ { key: 'out', n: 1, want: 1.2, say: 'zoom out once' },
27
+ { key: 'reset', n: 1, want: 1.0, say: 'actual size again' },
28
+ ];
29
+ const TOL = 0.05;
30
+
31
+ // ---- PNG, 8-bit RGB or RGBA, not interlaced: what screencapture and sips write -------------------
32
+ export function decodePng(buf) {
33
+ let p = 8, w = 0, h = 0, ct = 0; const idat = [];
34
+ while (p < buf.length) {
35
+ const len = buf.readUInt32BE(p); const type = buf.toString('latin1', p + 4, p + 8);
36
+ const data = buf.subarray(p + 8, p + 8 + len);
37
+ if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); ct = data[9]; if (data[8] !== 8 || data[12]) throw new Error('only 8-bit, non-interlaced PNG'); }
38
+ if (type === 'IDAT') idat.push(data);
39
+ if (type === 'IEND') break;
40
+ p += 12 + len;
41
+ }
42
+ const bpp = ct === 6 ? 4 : ct === 2 ? 3 : ct === 0 ? 1 : ct === 4 ? 2 : 0;
43
+ if (!bpp) throw new Error(`PNG colour type ${ct} not read`);
44
+ const raw = inflateSync(Buffer.concat(idat)); const stride = w * bpp;
45
+ const px = Buffer.alloc(h * stride); const gray = new Float32Array(w * h);
46
+ for (let y = 0; y < h; y++) {
47
+ const f = raw[y * (stride + 1)]; const src = y * (stride + 1) + 1; const row = y * stride;
48
+ for (let x = 0; x < stride; x++) {
49
+ const a = x >= bpp ? px[row + x - bpp] : 0; const b = y ? px[row - stride + x] : 0; const c = x >= bpp && y ? px[row - stride + x - bpp] : 0;
50
+ let v = raw[src + x];
51
+ if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1;
52
+ else if (f === 4) { const pp = a + b - c; const pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }
53
+ px[row + x] = v & 255;
54
+ }
55
+ for (let x = 0; x < w; x++) { const o = row + x * bpp; gray[y * w + x] = bpp >= 3 ? 0.299 * px[o] + 0.587 * px[o + 1] + 0.114 * px[o + 2] : px[o]; }
56
+ }
57
+ return { w, h, gray };
58
+ }
59
+
60
+ // a picture shrunk to 640 px wide by sips (macOS), so the search stays quick
61
+ function load(file) {
62
+ const small = file.replace(/\.png$/, '.small.png');
63
+ try { execFileSync('sips', ['-Z', '1000', file, '--out', small], { stdio: 'ignore' }); } catch { /* not macOS: read it whole */ }
64
+ return decodePng(readFileSync(existsSync(small) ? small : file));
65
+ }
66
+
67
+ // The page zoom that turns picture `base` (actual size) into picture `zoomed`. Page zoom scales every
68
+ // horizontal position from the web view's left edge, but not always vertical ones: a header or sidebar
69
+ // sized to the window height keeps its height (BlogAdmin's did), so a 2-D match read 121% for 130%.
70
+ // So the two pictures are compared across the window only: for each column, how much vertical edge
71
+ // (a border, a box side, the strokes of text) it holds below the title bar, and the scale that best
72
+ // lines the actual-size profile up with the zoomed one. The title bar is the rows the two share.
73
+ export function scaleBetween(zoomed, base) {
74
+ const { w, h } = base; if (zoomed.w !== w || zoomed.h !== h) return { scale: 0, why: 'the two pictures differ in size' };
75
+ let top = 0;
76
+ for (; top < h; top++) { let d = 0; for (let x = 0; x < w; x++) d += Math.abs(zoomed.gray[top * w + x] - base.gray[top * w + x]); if (d / w > 2) break; }
77
+ if (top >= h - 10) return { scale: 1, top, why: 'the pictures are the same' };
78
+ const profile = (img) => {
79
+ const p = new Float64Array(w);
80
+ for (let y = top; y < h; y++) for (let x = 0; x + 1 < w; x++) p[x] += Math.abs(img.gray[y * w + x + 1] - img.gray[y * w + x]);
81
+ return p;
82
+ };
83
+ const pz = profile(zoomed), pb = profile(base);
84
+ let best = { scale: 0, r: -2 };
85
+ for (let s = 0.5; s <= 3.0001; s += 0.01) {
86
+ let n = 0, sa = 0, sb = 0, saa = 0, sbb = 0, sab = 0;
87
+ for (let x = 0; x < w; x++) {
88
+ const bx = x / s; const i = Math.floor(bx); if (i + 1 >= w) break;
89
+ const a = pz[x], b = pb[i] + (pb[i + 1] - pb[i]) * (bx - i);
90
+ n++; sa += a; sb += b; saa += a * a; sbb += b * b; sab += a * b;
91
+ }
92
+ if (n < w / 4) continue;
93
+ const r = (sab - (sa * sb) / n) / Math.sqrt(Math.max(1e-9, (saa - (sa * sa) / n) * (sbb - (sb * sb) / n)));
94
+ if (r > best.r) best = { scale: Math.round(s * 100) / 100, r };
95
+ }
96
+ return { scale: best.scale, top };
97
+ }
98
+
99
+ const RESULT = { checks: [], kept_checked: false, head: '', evidence: '' };
100
+ const line = (ok, text) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${text}`); RESULT.checks.push({ ok, text }); return ok; };
101
+
102
+ async function mac() {
103
+ const { openSession, parseSource, appPid, windowShot } = await import('./tf-appium.mjs');
104
+ let boot = {}; try { boot = JSON.parse(readFileSync('tests/.artifacts/verify/boot.json', 'utf8')); } catch { /* flags only */ }
105
+ const appPath = opt('--app', boot.app_path || ''); const bundleId = opt('--bundle', boot.bundle || '');
106
+ let s;
107
+ try { s = await openSession(opt('--appium'), { bundleId, appPath }); }
108
+ catch (e) { line(false, `no session on the app at ${opt('--appium')}: ${e.message} (boot it with bash .tfcore/utils/tf-verify-boot.sh start --head maccatalyst)`); return 2; }
109
+ const pid = appPid(appPath);
110
+ let ok = true; let k = 0;
111
+ const shot = (name, same = false) => { const f = join(OUT, `${String(same ? k : k++).padStart(2, '0')}-${name}-mac.png`); return windowShot(pid, f) ? f : ''; };
112
+ const press = async (key) => {
113
+ await s.call('POST', '/execute/sync', { script: 'macos: keys', args: [{ keys: [{ key: { in: '=', out: '-', reset: '0' }[key], modifierFlags: 1 << 4 }] }] });
114
+ await new Promise((r) => setTimeout(r, 700));
115
+ };
116
+ // the View menu names the three commands
117
+ const nodes = parseSource(await s.source());
118
+ const view = nodes.find((n) => n.type === 'MenuBarItem' && /^view$/i.test(n.title || n.label));
119
+ const items = []; if (view) { const todo = [...view.kids]; while (todo.length) { const n = nodes[todo.shift()]; if (n.type === 'MenuItem') items.push(n.title || n.label); todo.push(...n.kids); } }
120
+ const missing = ['Zoom In', 'Zoom Out', 'Actual Size'].filter((t) => !items.includes(t));
121
+ ok = line(!missing.length, missing.length ? `the View menu has no ${missing.join(', ')} (it has: ${items.filter(Boolean).join(', ') || 'no View menu'})` : 'View › Zoom In, Zoom Out, Actual Size are in the menu bar') && ok;
122
+ // A mac2 session starts the app again: wait for a drawn page that holds still for two pictures
123
+ // (the first ones on BlogAdmin were the bare window while Blazor started)
124
+ const ink = (f) => { const g = load(f); let e = 0; for (let i = 1; i < g.gray.length; i++) e += Math.abs(g.gray[i] - g.gray[i - 1]); return e / g.gray.length; };
125
+ let opened = ''; let last = -1;
126
+ for (let t = Date.now(); Date.now() - t < 60000;) {
127
+ const f = shot('as-opened', true); const e = f ? ink(f) : 0; // a bare window reads 0.04, a drawn page 0.96
128
+ if (e > 0.2 && last > 0 && Math.abs(e - last) / last < 0.02) { opened = f; break; }
129
+ last = e; await new Promise((r) => setTimeout(r, 1500));
130
+ }
131
+ k++;
132
+ if (!opened) { await s.close(); line(false, 'the app showed no drawn page within 60 s'); return 2; }
133
+ await press('reset'); const actual = shot('actual-size');
134
+ if (!opened || !actual) { await s.close(); return line(false, 'could not take the window (the terminal needs Screen Recording)') ? 0 : 5; }
135
+ RESULT.head = 'maccatalyst'; RESULT.evidence = actual;
136
+ const base = load(actual);
137
+ const restored = scaleBetween(load(opened), base).scale;
138
+ console.log(`INFO opened at ${Math.round(restored * 100)}% (the level the last run left; run again after a restart with --leave 3 before it to check the level is kept)`);
139
+ if (flag('--expect-restored')) RESULT.kept_checked = true;
140
+ if (flag('--expect-restored')) ok = line(Math.abs(restored - parseFloat(opt('--expect-restored'))) <= TOL, `opened at ${Math.round(restored * 100)}%, expected ${Math.round(parseFloat(opt('--expect-restored')) * 100)}% from the last run`) && ok;
141
+ for (const st of STEPS.slice(1)) {
142
+ for (let i = 0; i < st.n; i++) await press(st.key);
143
+ const f = shot(st.key); const got = f ? scaleBetween(load(f), base).scale : 0;
144
+ ok = line(Math.abs(got - st.want) <= TOL, `${st.say} (Cmd+${{ in: '=', out: '-', reset: '0' }[st.key]}): ${Math.round(got * 100)}%, expected ${Math.round(st.want * 100)}%`) && ok;
145
+ }
146
+ for (let i = 0; i < LEAVE; i++) await press('in');
147
+ if (LEAVE) console.log(`INFO left at ${100 + LEAVE * 10}% for the restart check`);
148
+ await s.close();
149
+ return ok ? 0 : 5;
150
+ }
151
+
152
+ async function cdp() {
153
+ const { chromium } = await import('playwright');
154
+ let browser;
155
+ try { browser = await chromium.connectOverCDP(opt('--cdp')); }
156
+ catch (e) { line(false, `nothing answers at ${opt('--cdp')}: ${e.message.split('\n')[0]}`); return 2; }
157
+ const page = browser.contexts().flatMap((c) => c.pages()).find((p) => !p.url().startsWith('devtools:')) || null;
158
+ if (!page) { line(false, 'no page to drive at ' + opt('--cdp')); return 2; }
159
+ const level = () => page.evaluate(() => (parseFloat(getComputedStyle(document.documentElement).zoom) || 1) * window.devicePixelRatio);
160
+ let ok = true; let k = 0;
161
+ const press = async (key) => { await page.keyboard.press({ in: 'Control+Equal', out: 'Control+Minus', reset: 'Control+Digit0' }[key]); await page.waitForTimeout(400); };
162
+ RESULT.head = 'cdp';
163
+ const opened = await level(); await press('reset'); const base = await level();
164
+ console.log(`INFO opened at ${Math.round((opened / base) * 100)}% (the level the last run left)`);
165
+ if (flag('--expect-restored')) RESULT.kept_checked = true;
166
+ if (flag('--expect-restored')) ok = line(Math.abs(opened / base - parseFloat(opt('--expect-restored'))) <= TOL, `opened at ${Math.round((opened / base) * 100)}%, expected ${Math.round(parseFloat(opt('--expect-restored')) * 100)}%`) && ok;
167
+ for (const st of STEPS.slice(1)) {
168
+ for (let i = 0; i < st.n; i++) await press(st.key);
169
+ await page.screenshot({ path: join(OUT, `${String(k++).padStart(2, '0')}-${st.key}-cdp.png`) }).catch(() => {});
170
+ const got = (await level()) / base;
171
+ ok = line(Math.abs(got - st.want) <= TOL, `${st.say} (Ctrl+${{ in: '=', out: '-', reset: '0' }[st.key]}): ${Math.round(got * 100)}%, expected ${Math.round(st.want * 100)}%`) && ok;
172
+ }
173
+ // Ctrl+wheel up is one step in
174
+ await page.mouse.move(200, 200); await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); await page.waitForTimeout(400);
175
+ const wheel = (await level()) / base;
176
+ ok = line(Math.abs(wheel - 1.1) <= TOL, `Ctrl+wheel up: ${Math.round(wheel * 100)}%, expected 110%`) && ok;
177
+ await press('reset');
178
+ for (let i = 0; i < LEAVE; i++) await press('in');
179
+ if (LEAVE) console.log(`INFO left at ${100 + LEAVE * 10}% for the restart check`);
180
+ await browser.close().catch(() => {});
181
+ return ok ? 0 : 5;
182
+ }
183
+
184
+ if (process.argv[1] && process.argv[1].endsWith('tf-verify-zoom.mjs')) {
185
+ if (flag('--scale')) { // tf-verify-zoom.mjs --scale <zoomed.png> <actual.png>: the measure alone
186
+ console.log(scaleBetween(load(args[args.indexOf('--scale') + 1]), load(args[args.indexOf('--scale') + 2])).scale);
187
+ process.exit(0);
188
+ }
189
+ mkdirSync(OUT, { recursive: true });
190
+ const rc = flag('--appium') ? await mac() : flag('--cdp') ? await cdp() : (console.error('tf-verify-zoom: --appium <url> or --cdp <url>'), 3);
191
+ if (opt('--json-out')) writeFileSync(opt('--json-out'), JSON.stringify({ ...RESULT, verdict: rc === 0 ? 'PASS' : 'FAIL' }, null, 1));
192
+ process.exit(rc);
193
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # tf-verify-zoom.sh — presses the OS zoom keys on a booted Blazor Hybrid head and says what they did
3
+ # (coding-standards-dotnet.md §9b; Lekhak, 2026-10-11).
4
+ #
5
+ # bash .tfcore/utils/tf-verify-zoom.sh --appium http://localhost:4723 [--leave 3] [--expect-restored 1.3]
6
+ # a Mac Catalyst head booted by tf-verify-boot.sh (its .app and bundle come from boot.json)
7
+ # bash .tfcore/utils/tf-verify-zoom.sh --cdp <the boot url> [--leave 3] [--expect-restored 1.3]
8
+ # a Windows (WebView2) head, or any Chromium web view, over its debugging address
9
+ #
10
+ # Checks, in order: (Mac) View › Zoom In, Zoom Out and Actual Size are in the menu bar; the level the
11
+ # app opened at is printed, then Cmd/Ctrl+0 is actual size; zoom in 3 times reads 130%, zoom out once
12
+ # 120%, actual size 100%; (Windows) Ctrl+wheel up reads 110%. The kept level across a restart is two
13
+ # runs: the first with --leave 3 (it ends at 130%), then stop and start the head, then the second with
14
+ # --expect-restored 1.3. Pictures go to tests/.artifacts/verify/zoom/. The Mac level is measured from
15
+ # the window's pictures, because a WKWebView's accessibility tree does not follow its page zoom.
16
+ # Exit 0 every check passed · 5 one failed · 2 nothing to drive · 3 usage · 4 Playwright missing (--cdp).
17
+ set -uo pipefail
18
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ case "${1:-}" in
20
+ -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
21
+ "") sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
22
+ esac
23
+ command -v node >/dev/null 2>&1 || { echo "tf-verify-zoom: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
24
+ case " $* " in *" --cdp "*)
25
+ node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
26
+ echo "tf-verify-zoom: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; } ;;
27
+ esac
28
+ source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one check drives the app at a time
29
+ node "$HERE/tf-verify-zoom.mjs" "$@"
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # tf-yolo-alias.sh — put the `tfyolo` command in this machine's shell profile, once (owner, 2026-10-14).
3
+ #
4
+ # bash .tfcore/utils/tf-yolo-alias.sh install add it to ~/.bashrc and/or ~/.zshrc (idempotent)
5
+ # bash .tfcore/utils/tf-yolo-alias.sh remove take it out again
6
+ # bash .tfcore/utils/tf-yolo-alias.sh status say where it is
7
+ #
8
+ # `tfyolo` is a small shell function, the same text in bash and zsh. Typed anywhere inside a
9
+ # TechieFlow project, it finds the project's .tfcore/utils/tf-yolo-start.sh above the current folder
10
+ # and runs it: YOLO on, Claude Code started in bypass mode in this terminal, YOLO off when the session
11
+ # ends. `tfyolo -c` continues the last conversation. It never opens or closes a window: exit the
12
+ # session you are in (/exit), then type it in the same terminal, or type it in another tab.
13
+ # The function lives between two marker lines and is replaced whole on a second install, so an update
14
+ # to it reaches the profile by running install again. Profiles: ~/.zshrc when the login shell is zsh
15
+ # or the file exists (macOS), ~/.bashrc when the login shell is bash or the file exists (WSL, Linux).
16
+ # A new terminal picks it up; an open one needs `source ~/.bashrc` (or ~/.zshrc). Exit 0; 2 bad use.
17
+
18
+ set -u
19
+ BEGIN="# >>> TechieFlow tfyolo >>>"
20
+ END="# <<< TechieFlow tfyolo <<<"
21
+ HOME_DIR="${TF_HOME:-$HOME}"
22
+
23
+ block() {
24
+ cat <<'FN'
25
+ # >>> TechieFlow tfyolo >>>
26
+ # Start Claude Code in bypass mode with TechieFlow YOLO on, in the project around this folder.
27
+ # `tfyolo -c` continues the last conversation. Added by .tfcore/utils/tf-yolo-alias.sh; remove with
28
+ # `bash .tfcore/utils/tf-yolo-alias.sh remove`.
29
+ tfyolo() {
30
+ _tf_d="$PWD"
31
+ while [ "$_tf_d" != "/" ] && [ ! -f "$_tf_d/.tfcore/utils/tf-yolo-start.sh" ]; do _tf_d="$(dirname "$_tf_d")"; done
32
+ if [ ! -f "$_tf_d/.tfcore/utils/tf-yolo-start.sh" ]; then
33
+ echo "tfyolo: no TechieFlow project around $PWD (no .tfcore/utils/tf-yolo-start.sh above it)" >&2
34
+ unset _tf_d; return 2
35
+ fi
36
+ bash "$_tf_d/.tfcore/utils/tf-yolo-start.sh" "$@"
37
+ _tf_rc=$?; unset _tf_d; return $_tf_rc
38
+ }
39
+ # <<< TechieFlow tfyolo <<<
40
+ FN
41
+ }
42
+
43
+ profiles() {
44
+ local sh; sh="$(basename "${SHELL:-}")"
45
+ [[ "$sh" == zsh || -f "$HOME_DIR/.zshrc" ]] && echo "$HOME_DIR/.zshrc"
46
+ [[ "$sh" == bash || -f "$HOME_DIR/.bashrc" ]] && echo "$HOME_DIR/.bashrc"
47
+ [[ "$sh" != zsh && "$sh" != bash && ! -f "$HOME_DIR/.zshrc" && ! -f "$HOME_DIR/.bashrc" ]] && echo "$HOME_DIR/.bashrc"
48
+ return 0
49
+ }
50
+
51
+ strip() { # the file without our block
52
+ awk -v b="$BEGIN" -v e="$END" '$0==b{skip=1;next} $0==e{skip=0;next} !skip' "$1"
53
+ }
54
+
55
+ case "${1:-status}" in
56
+ install)
57
+ while IFS= read -r p; do
58
+ [[ -n "$p" ]] || continue
59
+ tmp="$p.tftmp.$$"
60
+ if [[ -f "$p" ]]; then strip "$p" > "$tmp"; else : > "$tmp"; fi
61
+ # one blank line before the block, never a growing run of them
62
+ [[ -s "$tmp" && -n "$(tail -c 1 "$tmp")" ]] && echo "" >> "$tmp"
63
+ block >> "$tmp"
64
+ cat "$tmp" > "$p"; rm -f "$tmp"
65
+ echo "tfyolo: installed in $p (open a new terminal, or: source $p)"
66
+ done < <(profiles)
67
+ ;;
68
+ remove)
69
+ for p in "$HOME_DIR/.bashrc" "$HOME_DIR/.zshrc"; do
70
+ [[ -f "$p" ]] && grep -qxF "$BEGIN" "$p" || continue
71
+ tmp="$p.tftmp.$$"; strip "$p" > "$tmp"; cat "$tmp" > "$p"; rm -f "$tmp"
72
+ echo "tfyolo: removed from $p"
73
+ done
74
+ ;;
75
+ status)
76
+ found=0
77
+ for p in "$HOME_DIR/.bashrc" "$HOME_DIR/.zshrc"; do
78
+ [[ -f "$p" ]] && grep -qxF "$BEGIN" "$p" && { echo "tfyolo: in $p"; found=1; }
79
+ done
80
+ [[ $found -eq 1 ]] || echo "tfyolo: not installed (bash .tfcore/utils/tf-yolo-alias.sh install)"
81
+ ;;
82
+ *) echo "usage: tf-yolo-alias.sh install|remove|status" >&2; exit 2 ;;
83
+ esac
84
+ exit 0
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """tf-yolo-perms.py — switch OpenCode to never ask, and back (owner, 2026-10-14). Run by tf-yolo.sh.
3
+
4
+ python3 .tfcore/utils/tf-yolo-perms.py on <project-root>
5
+ python3 .tfcore/utils/tf-yolo-perms.py off <project-root>
6
+
7
+ on OpenCode: the config file it reads LAST (it reads opencode.json, opencode.jsonc,
8
+ .opencode/opencode.json, .opencode/opencode.jsonc, in that order, the last matching rule
9
+ winning; checked on OpenCode 2.0.27) is rewritten with every "ask" made "allow", every "deny"
10
+ kept in its place, and edit, webfetch, external_directory and doom_loop allowed, in the root map
11
+ and in every agent's. In a project that file is the framework's own .opencode/opencode.jsonc.
12
+ Its exact text is saved once, in .tfcore/.session/yolo-perms.json; a second `on` keeps the
13
+ first saving.
14
+ Claude Code is NOT switched here: it ignores permissions.defaultMode "bypassPermissions" in
15
+ project and local settings ("only policy/user/flag settings may grant bypass mode", Claude Code
16
+ 2.1.299, 2026-10-14). Its side is the hook .tfcore/hooks/yolo-allow.sh, and for the prompts a
17
+ hook cannot clear (ask rules, safety checks), a session started with
18
+ `claude --permission-mode bypassPermissions` (tf-goal.sh always starts it so).
19
+ off Puts the OpenCode file back as it was (one the updater replaced meanwhile is left as the updater
20
+ wrote it) and removes the saving. Without a saving it does nothing.
21
+ Exit 0 always: YOLO never fails a phase.
22
+ """
23
+ import json
24
+ import os
25
+ import re
26
+ import sys
27
+
28
+ MARK = "// tf-yolo.sh on rewrote this file so nothing asks while YOLO is on; tf-yolo.sh off puts the original back"
29
+ OPEN_ALLOW = ("edit", "webfetch", "external_directory", "doom_loop")
30
+
31
+
32
+ def jsonc(text):
33
+ """JSON with // and /* */ comments and trailing commas, as opencode.jsonc is written"""
34
+ out, i, n, s = [], 0, len(text), None
35
+ while i < n:
36
+ c = text[i]
37
+ if s:
38
+ out.append(c)
39
+ if c == "\\" and i + 1 < n:
40
+ out.append(text[i + 1]); i += 2; continue
41
+ if c == s:
42
+ s = None
43
+ elif c == '"':
44
+ s = c; out.append(c)
45
+ elif text.startswith("//", i):
46
+ while i < n and text[i] != "\n":
47
+ i += 1
48
+ continue
49
+ elif text.startswith("/*", i):
50
+ j = text.find("*/", i + 2)
51
+ i = n if j < 0 else j + 2
52
+ continue
53
+ else:
54
+ out.append(c)
55
+ i += 1
56
+ return json.loads(re.sub(r",(\s*[}\]])", r"\1", "".join(out)))
57
+
58
+
59
+ def never_ask(perm):
60
+ """a permission map with every "ask" made "allow"; order (and so every deny's place) kept"""
61
+ if perm == "ask":
62
+ return "allow"
63
+ if isinstance(perm, dict):
64
+ out = {k: never_ask(v) for k, v in perm.items()}
65
+ for k in OPEN_ALLOW:
66
+ if k not in out or out[k] == "ask":
67
+ out[k] = "allow"
68
+ return out
69
+ return perm
70
+
71
+
72
+ def read(p):
73
+ try:
74
+ with open(p, encoding="utf-8") as f:
75
+ return f.read()
76
+ except OSError:
77
+ return None
78
+
79
+
80
+ def write(p, text):
81
+ os.makedirs(os.path.dirname(p), exist_ok=True)
82
+ with open(p, "w", encoding="utf-8") as f:
83
+ f.write(text)
84
+
85
+
86
+ def main(argv):
87
+ if len(argv) < 3 or argv[1] not in ("on", "off"):
88
+ print(__doc__)
89
+ return 0
90
+ verb, root = argv[1], argv[2]
91
+ saving = os.path.join(root, ".tfcore", ".session", "yolo-perms.json")
92
+ oc = next((os.path.join(root, *p) for p in ((".opencode", "opencode.jsonc"), (".opencode", "opencode.json"),
93
+ ("opencode.jsonc",), ("opencode.json",))
94
+ if os.path.isfile(os.path.join(root, *p))), None)
95
+
96
+ if verb == "on":
97
+ if not os.path.isfile(saving):
98
+ write(saving, json.dumps({"opencode_file": oc, "opencode_text": read(oc) if oc else None}, indent=1))
99
+ # OpenCode
100
+ text = read(oc) if oc else None
101
+ if text is not None and MARK not in text:
102
+ try:
103
+ cfg = jsonc(text)
104
+ except ValueError:
105
+ cfg = None
106
+ if isinstance(cfg, dict):
107
+ cfg["permission"] = never_ask(cfg.get("permission") or {})
108
+ for a in (cfg.get("agent") or {}).values():
109
+ if isinstance(a, dict) and a.get("permission"):
110
+ a["permission"] = never_ask(a["permission"])
111
+ write(oc, MARK + "\n" + json.dumps(cfg, indent=2) + "\n")
112
+ print("permissions: OpenCode set to never ask; Claude Code: the yolo-allow hook")
113
+ return 0
114
+
115
+ # off
116
+ if not os.path.isfile(saving):
117
+ return 0
118
+ try:
119
+ before = json.loads(read(saving) or "{}")
120
+ except ValueError:
121
+ before = {}
122
+ oc_file, oc_text = before.get("opencode_file"), before.get("opencode_text")
123
+ if oc_file and oc_text is not None and MARK in (read(oc_file) or ""):
124
+ write(oc_file, oc_text)
125
+ os.remove(saving)
126
+ print("permissions: OpenCode config put back as it was")
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ try:
132
+ sys.exit(main(sys.argv))
133
+ except Exception as e: # never a veto
134
+ print(f"tf-yolo-perms: {e}", file=sys.stderr)
135
+ sys.exit(0)
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # tf-yolo-start.sh — what `tfyolo` runs: Claude Code in bypass mode with YOLO on (owner, 2026-10-14).
3
+ #
4
+ # tfyolo start a new Claude Code session here, nothing asks
5
+ # tfyolo -c the same, continuing the last conversation in this folder (claude --continue)
6
+ # tfyolo <claude args> any other claude arguments are passed through unchanged
7
+ #
8
+ # Why it exists: Claude Code drops its permission prompts only for a session STARTED in bypass mode
9
+ # (`claude --permission-mode bypassPermissions`). Project settings cannot grant that mode (Claude Code
10
+ # 2.1.299 ignores it there), and `*yolo` typed into an open session cannot switch it. So `tfyolo`
11
+ # turns YOLO on (tf-yolo.sh on: the git-write ban stays, OpenCode stops asking too), starts Claude in
12
+ # this terminal, and turns YOLO off again when that session ends, however it ends.
13
+ #
14
+ # It refuses outside a TechieFlow project: the hooks that keep git writes refused live in the
15
+ # project, and bypass mode without them would let a commit through.
16
+ # The `tfyolo` command itself is a shell function put in ~/.bashrc / ~/.zshrc by tf-yolo-alias.sh.
17
+ # Exit: claude's own exit code; 2 when it could not start.
18
+
19
+ set -u
20
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ ROOT="$(cd "$HERE/../.." && pwd)"
22
+ if [[ ! -f "$ROOT/.tfcore/hooks/block-git.sh" || ! -f "$ROOT/.claude/settings.json" ]]; then
23
+ echo "tfyolo: $ROOT is not a TechieFlow project with its Claude Code hooks (no .tfcore/hooks/block-git.sh or .claude/settings.json); not starting in bypass mode." >&2
24
+ exit 2
25
+ fi
26
+ grep -q 'block-git.sh' "$ROOT/.claude/settings.json" 2>/dev/null || {
27
+ echo "tfyolo: $ROOT/.claude/settings.json does not register block-git.sh, so git writes would not be refused; run update-framework.sh on this project first." >&2
28
+ exit 2
29
+ }
30
+ CLAUDE_BIN="${TF_CLAUDE_BIN:-$(command -v claude 2>/dev/null)}"
31
+ [[ -n "$CLAUDE_BIN" ]] || { echo "tfyolo: the claude command is not on PATH" >&2; exit 2; }
32
+
33
+ export CLAUDE_PROJECT_DIR="$ROOT"
34
+ bash "$HERE/tf-yolo.sh" on --source tfyolo >/dev/null
35
+ off() { CLAUDE_PROJECT_DIR="$ROOT" bash "$HERE/tf-yolo.sh" off >/dev/null 2>&1; }
36
+ trap 'off' EXIT
37
+ trap 'off; exit 130' INT TERM
38
+
39
+ echo "tfyolo: YOLO on, Claude Code starting in bypass mode in $ROOT (git writes stay refused; YOLO goes off when this session ends)"
40
+ cd "$ROOT" || exit 2
41
+ "$CLAUDE_BIN" --permission-mode bypassPermissions "$@"
42
+ rc=$?
43
+ echo "tfyolo: session ended; YOLO off"
44
+ exit $rc
@@ -8,6 +8,26 @@
8
8
  # bash .tfcore/utils/tf-yolo.sh done [complete|blocked] ["<summary>"] goal finished → write the sentinel
9
9
  # the tf-goal.sh supervisor stops on
10
10
  # bash .tfcore/utils/tf-yolo.sh clear-done remove the sentinel (supervisor does this)
11
+ # bash .tfcore/utils/tf-yolo.sh sync put the harness permissions back when
12
+ # the flag has gone or expired (hooks call it)
13
+ #
14
+ # YOLO NEVER ASKS (owner, 2026-10-14: "if it's still asking for permission, what's the purpose of
15
+ # YOLO mode?"). While the flag is live both harnesses stop asking, and `off`, `done` and an expired
16
+ # flag put them back:
17
+ # - Claude Code: the hook .tfcore/hooks/yolo-allow.sh approves every tool call, in the session
18
+ # already running. Claude Code still asks where a hook's allow is not enough — an ask rule or one
19
+ # of its own safety checks ("requires the full permission pipeline") — and it refuses bypass mode
20
+ # from project or local settings ("only policy/user/flag settings may grant bypass mode"; both
21
+ # seen in its debug log, 2.1.299, 2026-10-14). Those prompts go only in a session started with
22
+ # `claude --permission-mode bypassPermissions`, which tf-goal.sh always uses.
23
+ # - OpenCode: the config file it reads last (.opencode/opencode.jsonc in a project) is rewritten
24
+ # with each "ask" turned to "allow" and every deny kept, and edits, web fetches, folders outside
25
+ # the project and repeated calls allowed; `off` puts its exact text back. OpenCode reads it when
26
+ # a session starts; the plugin's permission.ask approves everything while the flag is live
27
+ # (OpenCode 1.x).
28
+ # Version-control WRITES stay refused in both: the deny rules are kept, and block-git.sh refuses
29
+ # them before any permission check. What each file held before is kept under .tfcore/.session/ and
30
+ # restored exactly.
11
31
  #
12
32
  # YOLO is ON when ANY of these holds (checked in this order by block-git.sh too):
13
33
  # 1. env TF_YOLO=1 (tf-goal.sh exports it for the whole run)
@@ -54,6 +74,8 @@ flag_live() { # exit 0 = flag present AND not expired
54
74
  [[ -n "$(find "$FLAG" -mmin "+$(( TTL_HOURS * 60 ))" 2>/dev/null)" ]] && return 1
55
75
  return 0
56
76
  }
77
+ PERM_PY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/tf-yolo-perms.py"
78
+ perms() { python3 "$PERM_PY" "$1" "$ROOT" 2>/dev/null || true; } # on | off; never fails the caller
57
79
  json_escape() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "${1//\"/\\\"}"; }
58
80
 
59
81
  cmd="${1:-status}"; shift || true
@@ -74,11 +96,16 @@ case "$cmd" in
74
96
  "$(now_utc)" "$(json_escape "$SOURCE")" "$(json_escape "$GOAL")" "$(json_escape "${TF_HARNESS:-claude-code}")"
75
97
  } > "$FLAG" 2>/dev/null || true
76
98
  rm -f "$DONE" 2>/dev/null || true
77
- echo "YOLO: ON (source=$SOURCE) — no confirmations; deletes + read-only git allowed; git WRITES still blocked; run to completion."
99
+ perms on
100
+ echo "YOLO: ON (source=$SOURCE) — no confirmations; OpenCode never asks (from its next session); Claude Code approves every call it can (its own safety checks still ask unless the session was started with: claude --permission-mode bypassPermissions); deletes + read-only git allowed; git WRITES still blocked; run to completion."
78
101
  ;;
79
102
  off)
80
103
  rm -f "$FLAG" 2>/dev/null || true
81
- echo "YOLO: OFF — back to ask-on-delete, no-git, confirm-at-phase-boundaries."
104
+ perms off
105
+ echo "YOLO: OFF — back to ask-on-delete, no-git, confirm-at-phase-boundaries; OpenCode's asks restored."
106
+ ;;
107
+ sync)
108
+ if [[ "${TF_YOLO:-0}" == "1" ]] || flag_live; then :; else perms off; fi
82
109
  ;;
83
110
  is-on)
84
111
  if [[ "${TF_YOLO:-0}" == "1" ]] || flag_live; then exit 0; else exit 1; fi
@@ -166,13 +193,14 @@ PY2
166
193
  # unprompted and read-only git open and nothing on screen saying so. The
167
194
  # supervisor reads $DONE, never $FLAG, so clearing it here stops nothing.
168
195
  rm -f "$FLAG" 2>/dev/null || true
196
+ perms off
169
197
  echo "GOAL-DONE ($OUTCOME) recorded at $DONE — the supervisor (tf-goal.sh) will stop. YOLO flag cleared."
170
198
  ;;
171
199
  clear-done)
172
200
  rm -f "$DONE" 2>/dev/null || true
173
201
  ;;
174
202
  *)
175
- echo "usage: tf-yolo.sh on|off|status|is-on|done [complete|blocked] [summary]|clear-done" >&2
203
+ echo "usage: tf-yolo.sh on|off|status|is-on|done [complete|blocked] [summary]|clear-done|sync" >&2
176
204
  ;;
177
205
  esac
178
206
  exit 0