@techierathore/techieflow 1.1.0 → 1.1.1

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 (98) 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/_yolo-mode.md +2 -2
  5. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  6. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
  8. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  9. package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
  10. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  11. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  12. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  13. package/.opencode/plugin/techieflow.js +15 -7
  14. package/.tfcore/agents/flow-master.md +2 -0
  15. package/.tfcore/agents/verifier.md +2 -2
  16. package/.tfcore/core-config.yaml +2 -1
  17. package/.tfcore/hooks/guard-db.sh +106 -9
  18. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  19. package/.tfcore/hooks/yolo-allow.sh +27 -0
  20. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  21. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  22. package/.tfcore/tasks/_develop-mode.md +34 -0
  23. package/.tfcore/tasks/_yolo-mode.md +2 -2
  24. package/.tfcore/tasks/amend-docs.md +1 -1
  25. package/.tfcore/tasks/day1-brownfield.md +2 -2
  26. package/.tfcore/tasks/day1-greenfield.md +4 -4
  27. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  28. package/.tfcore/tasks/fix-issues.md +1 -0
  29. package/.tfcore/tasks/mockups.md +1 -1
  30. package/.tfcore/tasks/triage-and-fix.md +2 -2
  31. package/.tfcore/tasks/verify-phase.md +12 -11
  32. package/.tfcore/telemetry/SCHEMA.md +6 -2
  33. package/.tfcore/telemetry/tf-metrics.sh +19 -0
  34. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  35. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  36. package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
  37. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  38. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  39. package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
  40. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  41. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  42. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  43. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  44. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  45. package/.tfcore/utils/tf-appium.mjs +179 -0
  46. package/.tfcore/utils/tf-build-list.py +16 -1
  47. package/.tfcore/utils/tf-build.sh +75 -0
  48. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  49. package/.tfcore/utils/tf-day1-files.py +55 -28
  50. package/.tfcore/utils/tf-develop.sh +264 -0
  51. package/.tfcore/utils/tf-devguide-list.py +6 -2
  52. package/.tfcore/utils/tf-doc-check.py +83 -21
  53. package/.tfcore/utils/tf-doc-tests.sh +40 -3
  54. package/.tfcore/utils/tf-emit.sh +40 -10
  55. package/.tfcore/utils/tf-fix-close.sh +13 -7
  56. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  57. package/.tfcore/utils/tf-menus.mjs +401 -0
  58. package/.tfcore/utils/tf-mockup-parity.mjs +355 -27
  59. package/.tfcore/utils/tf-mockup-parity.sh +14 -3
  60. package/.tfcore/utils/tf-phase.sh +24 -4
  61. package/.tfcore/utils/tf-render-html.py +19 -3
  62. package/.tfcore/utils/tf-req-ids.py +36 -0
  63. package/.tfcore/utils/tf-stack-check.sh +3 -0
  64. package/.tfcore/utils/tf-status-facts.py +90 -15
  65. package/.tfcore/utils/tf-theme.mjs +189 -0
  66. package/.tfcore/utils/tf-triage.py +12 -1
  67. package/.tfcore/utils/tf-verify-boot.sh +274 -19
  68. package/.tfcore/utils/tf-verify-emit.sh +37 -17
  69. package/.tfcore/utils/tf-verify-list.py +219 -20
  70. package/.tfcore/utils/tf-verify-list.sh +3 -1
  71. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  72. package/.tfcore/utils/tf-verify-screens.mjs +178 -20
  73. package/.tfcore/utils/tf-verify-screens.sh +17 -4
  74. package/.tfcore/utils/tf-verify-tests.sh +107 -17
  75. package/.tfcore/utils/tf-verify-verdict.py +118 -15
  76. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  77. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  78. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  79. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  80. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  81. package/.tfcore/utils/tf-yolo.sh +31 -3
  82. package/.tfcore/utils/tf_feedback.py +79 -16
  83. package/.tfcore/utils/tf_stack_check.py +93 -0
  84. package/README.md +2 -2
  85. package/opencode.jsonc +6 -2
  86. package/package.json +1 -1
  87. package/scripts/install.mjs +13 -2
  88. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  90. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  91. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  92. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  93. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  94. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  95. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  96. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -1251,8 +1251,17 @@ def analyse(repos):
1251
1251
  voided_runs = 0
1252
1252
  void_reasons = []
1253
1253
  void_orphans = 0
1254
+ gates_malformed = []
1254
1255
  for repo in repos:
1255
1256
  g = read_stream(repo, "gates")
1257
+ # A gate record is one REQ verdict (SCHEMA.md §3). A suite-level note with no req_id or no
1258
+ # verdict ({"gate":"verify-suite","result":"pass"}) was counted as a failure, passes
1259
+ # included (Lekhak TF-020). It is scored nowhere and listed as malformed instead.
1260
+ bad_g = [x for x in g if not x.get("req_id") or not x.get("verdict")]
1261
+ if bad_g:
1262
+ g = [x for x in g if x.get("req_id") and x.get("verdict")]
1263
+ gates_malformed += [{"app": app_name(repo), "ts": x.get("ts"), "gate": x.get("gate"),
1264
+ "result": x.get("result")} for x in bad_g]
1256
1265
  r, vn, vr, vo = apply_voids(read_stream(repo, "runs"), app_name(repo))
1257
1266
  voided_runs += vn; void_reasons += vr; void_orphans += vo
1258
1267
  repo_misses = read_stream(repo, "misses")
@@ -1273,6 +1282,7 @@ def analyse(repos):
1273
1282
  per_repo.append({"repo": repo, "app": app_name(repo),
1274
1283
  "project_type": project_type(repo)[0],
1275
1284
  "gates": len(g), "gates_backfilled": sum(1 for x in g if x.get("backfilled")),
1285
+ "gates_malformed": len(bad_g),
1276
1286
  "runs": len(r), "sessions": len(s), "commits": len(c),
1277
1287
  "misses": sum(1 for x in repo_misses if x.get("kind") == "miss" and x.get("miss_id") not in
1278
1288
  {v.get("miss_id") for v in repo_misses if v.get("kind") == "miss-void"}),
@@ -1338,6 +1348,8 @@ def analyse(repos):
1338
1348
  "runs_voided_n": voided_runs,
1339
1349
  "runs_voided": void_reasons,
1340
1350
  "run_voids_orphaned_n": void_orphans,
1351
+ "gates_malformed_n": len(gates_malformed),
1352
+ "gates_malformed": gates_malformed,
1341
1353
  "misses": analyse_misses(misses),
1342
1354
  "phases": analyse_phases(runs)}
1343
1355
 
@@ -1446,6 +1458,13 @@ def print_report(a, repos):
1446
1458
  print(" Commit telemetry is not being written here. Fix it for good with")
1447
1459
  print(" update-framework.sh <repo>, or reconcile what is already in the log:")
1448
1460
  print(" .tfcore/telemetry/tf-metrics.sh --backfill-commits <repo>")
1461
+ if a.get("gates_malformed"):
1462
+ print("")
1463
+ print(" ⚠ %d gate record(s) carry no req_id or no verdict, so they are not one REQ's"
1464
+ % a["gates_malformed_n"])
1465
+ print(" verdict and are left out of every figure below (SCHEMA.md §3):")
1466
+ for x in a["gates_malformed"]:
1467
+ print(" %s %s gate=%s result=%s" % (x["app"], x["ts"], x["gate"], x["result"]))
1449
1468
  print("")
1450
1469
 
1451
1470
  for label in ("live", "backfilled"):
@@ -20,11 +20,13 @@
20
20
  | Q5 | Logging | Serilog, always, with file logging wired at startup before anything else can fail. | No |
21
21
  | Q6 | Tests | xUnit, always. A test project exists from day-1. | No |
22
22
  | Q7 | Layout and naming | `src/` and `tests/` at the root. The primary executable project is named exactly `<App>`; `<App>.App` is banned. Secondary heads take a descriptive suffix (`<App>.Api`, `<App>.Desktop`). | No |
23
- | Q8 | User interface | TrBlazeUI for every Blazor head and every MAUI Blazor Hybrid head. Native controls for WinForms, WPF and non-Blazor MAUI heads. Rendering mode is chosen per project and recorded. | Rendering mode only |
23
+ | Q8 | User interface | TrBlazeUI for every Blazor head and every MAUI Blazor Hybrid head: NuGet package `TrBlazeUI.Components` (it brings `TrBlazeUI.Primitives`), icons from `TrBlazeUI.Icons.Lucide`. Native controls for WinForms, WPF and non-Blazor MAUI heads. Rendering mode is chosen per project and recorded. | Rendering mode only |
24
24
  | Q9 | Hosting | Web applications run containerised on a Bluehost VPS. The owner supplies a pipeline guidance document at UAT go-ahead, and a deployment checklist is produced from it. | Asked at UAT go-ahead, as the questionnaire specifies |
25
25
  | Q10 | Production secrets | Decided when the owner asks for the production pipeline, after UAT. Until then no document states where production secrets live. | Asked after UAT |
26
26
  | Q11 | Standing rules and prohibitions | 1. Never add a NuGet package without a line in the Architecture document saying why. 2. Never create a second configuration mechanism. 3. When TechieFlow, TrBlazeUI, TechieRag or any other internal library lacks something, record it in that library's feedback file and hold the feature until the library is fixed. Never implement a workaround. 4. Data access is always Dapper. 5. Database migrations live in a dedicated project named `<App>Db` (console or library) using the DbUp package. The web application runs the migration at startup, or the pipeline runs it after deployment. Never a `database` folder of loose scripts at the repository root. 6. Log files are written under the build output folder (`bin/`), which is git-ignored. Never at the repository root. 7. No unnecessary folders at the repository root (`data`, `database`, `scripts` and the like). Everything sits under `src/` or `tests/` according to what it belongs to. | No |
27
27
 
28
+ Required packages: `TrBlazeUI.Components` — for a project with a Blazor or MAUI Blazor Hybrid head. The Architecture copies this line into its Stack decisions for each package that applies, and `tf-stack-check.sh` fails the build check when no project file references one of them.
29
+
28
30
  ---
29
31
 
30
32
  ## Notes
@@ -22,6 +22,9 @@ rule: decisions-log
22
22
  <!-- Authoring notes (agent only; never visible text).
23
23
  Sections and order are fixed by the schema above. The Stack decisions table answers the stack
24
24
  questions (.tfcore/templates/stack-questions.md); answers taken from an answer set cite it.
25
+ The "Required packages:" line copies the answer set's own line, keeping only the packages that
26
+ apply to this project's heads; tf-stack-check.sh fails the build check when the code references
27
+ none of a listed package (a model once replaced the mandated UI library with plain components).
25
28
  No Deployment section: hosting is decided after UAT and lives in the Deployment Checklist; how a
26
29
  developer runs the app lives in the UsageGuide. Detailed per-screen flows live in the DevGuide.
27
30
  A brownfield project describes the code as it is; a planned change is a Decisions log row with
@@ -54,6 +57,8 @@ One row per stack question. "Source" says where the answer came from: the answer
54
57
  | Q8 | User interface | {…} | {…} |
55
58
  | Q11 | Standing rules | {…} | {…} |
56
59
 
60
+ Required packages: {each package the answer set requires for this project's heads, in backticks, or "none"}
61
+
57
62
  ## 2. Solution structure
58
63
 
59
64
  | Project | Kind | Purpose |
@@ -94,8 +94,9 @@ One item per thing the verifier will test. Each names its screen, links the mock
94
94
  | BRD-{N} | Performance | {…} | perf-budget: p95 load <= 2000ms @ concurrency 1 |
95
95
  | BRD-{N} | Security | {…} | {…} |
96
96
  | BRD-{N} | Logging | {from the Stack answer set} | {…} |
97
+ | BRD-{N} | Zoom (a desktop head drawn in a web view) | Cmd/Ctrl+=, - and 0 zoom 50% to 300% in 10% steps; the level is kept across restarts | zoom-check: keys · zoom-check: kept |
97
98
 
98
- The `perf-budget:` measure is machine-read by the verifier, in exactly this form: `perf-budget: <p50|p95|max> <ttfb|load> <= <N>ms [@ concurrency <N>]`. Write one only where the owner stated a number.
99
+ The `perf-budget:` measure is machine-read by the verifier, in exactly this form: `perf-budget: <p50|p95|max> <ttfb|load> <= <N>ms [@ concurrency <N>]`. Write one only where the owner stated a number. The zoom row is for an app whose desktop head draws its screens in a web view (the stack's coding standards say how), and is deleted otherwise; its two checklist rows end `zoom-check: keys` and `zoom-check: kept`, which the verifier grades with `tf-verify-zoom.sh`.
99
100
 
100
101
  ## 7. Development status
101
102
 
@@ -45,6 +45,8 @@ rule: checklist-rows
45
45
  | REQ-UI-001 | {short name} | Not Started | 0% | — | [view](#d-req-ui-001) |
46
46
  | REQ-FN-001 | {short name} | Not Started | 0% | — | [view](#d-req-fn-001) |
47
47
  | REQ-NFR-001 | {short name} | Not Started | 0% | — | [view](#d-req-nfr-001) |
48
+ | REQ-NFR-002 | Zoom with the OS keys (web-view desktop head only) | Not Started | 0% | — | [view](#d-req-nfr-002) |
49
+ | REQ-NFR-003 | Zoom level kept (web-view desktop head only) | Not Started | 0% | — | [view](#d-req-nfr-003) |
48
50
 
49
51
  **Status values:** `Not Started` · `In Progress` · `Implemented` · `Verified` · `Done (pre-existing)` · `Needs re-verify` · `PARTIAL` · `FAIL` · `Blocked` · `Owner-UAT` · `N/A`. `Owner-UAT` marks a row only the owner can close, by following the UsageGuide test plan; the verifier cannot reach it from this machine.
50
52
 
@@ -63,3 +65,12 @@ rule: checklist-rows
63
65
  <a id="d-req-nfr-001"></a>
64
66
  - **REQ-NFR-001** — {the requirement}. *BRD:* BRD-{N}
65
67
  - *Acceptance:* When {the measurement is taken}, then {the value within its limit}; perf-budget: p95 load <= 2000ms @ concurrency 1
68
+
69
+ <!-- A desktop head drawn in a web view carries these two rows (the stack's coding standards say how it zooms); delete them otherwise. -->
70
+ <a id="d-req-nfr-002"></a>
71
+ - **REQ-NFR-002** — Zoom with the OS keys. *BRD:* BRD-{N}
72
+ - *Acceptance:* When a user presses Cmd+= (Mac) or Ctrl+= (Windows) three times on {first screen}, then the page is drawn at 130%; zoom-check: keys
73
+
74
+ <a id="d-req-nfr-003"></a>
75
+ - **REQ-NFR-003** — The zoom level is kept. *BRD:* BRD-{N}
76
+ - *Acceptance:* When a user zooms to 130% on {first screen} and restarts the app, then it opens at 130%; zoom-check: kept
@@ -13,7 +13,8 @@ rule: phases-table
13
13
  phases and are never reused, so the ranges never overlap. An item added to an earlier phase after a
14
14
  later phase exists takes the next free id and the row gets a second range: "BRD-1 to BRD-73, BRD-111 to BRD-118". Every screen sits in exactly one phase;
15
15
  the screens of a phase are exactly the "### Screen:" entries of that phase's UIDesign.
16
- Status is planned, building or done. The phase being worked is appPhase in core-config.yaml
16
+ Status is planned, building, done or kept-whole. kept-whole is only for a phase finished before the
17
+ size cap that the owner chose to keep as one document: its cap and word maximum then WARN, not FAIL. The phase being worked is appPhase in core-config.yaml
17
18
  (bash .tfcore/utils/tf-day1-files.sh {App} --phase N); handoff moves it on.
18
19
  tf-doc-check.sh --app {App} refuses a screen in two phases, an id in two phases, a BRD id outside
19
20
  its phase's range, and a phase row without its BRD file. -->
@@ -48,6 +48,8 @@ The same slug function MUST produce the `id="..."` on each heading AND the `href
48
48
 
49
49
  The MD inline TOC and the HTML sidebar TOC use the SAME slug. The heading `id` uses the SAME slug. No exceptions.
50
50
 
51
+ **A second id, GitHub's.** A markdown table of contents written by hand links to the id GitHub gives the heading: digits kept, punctuation dropped, spaces to hyphens, a repeat suffixed `-1`, `-2` (`### 3.7 Group Service (GroupSvc)` → `#37-group-service-groupsvc`). When that id differs from the slug above, the heading also carries `<a id="{github-id}"></a>`, so those links land too (AppManager TF-029). A heading with no slug (H1, H5, H6) carries the GitHub id as its own.
52
+
51
53
  ---
52
54
 
53
55
  ## 2. CSS (inline at top of `<head>`, verbatim)
@@ -0,0 +1,179 @@
1
+ // tf-appium.mjs — the few WebDriver calls a native head needs, over Node's own fetch (no package).
2
+ // Used by tf-verify-native.mjs; an acceptance test of a native head may import it too:
3
+ // import { openSession, parseSource } from '<repo>/.tfcore/utils/tf-appium.mjs';
4
+ // const s = await openSession(process.env.APPIUM_URL, { bundleId: process.env.TF_BUNDLE_ID, appPath: process.env.TF_APP_PATH });
5
+ // await s.click(await s.find('accessibility id', 'save')); … await s.close();
6
+ // Every control is found inside the app under test by its AutomationId (the accessibility
7
+ // identifier); nothing here types or clicks at a screen position. Type into a field with
8
+ // `await s.type(el, 'text')`, the one way a Blazor Hybrid form on a Mac took the text.
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { statSync } from 'node:fs';
12
+
13
+ const W3C_EL = 'element-6066-11e4-a52e-4f735466cecf';
14
+
15
+ async function call(base, method, path, body) {
16
+ const r = await fetch(base + path, {
17
+ method, headers: { 'Content-Type': 'application/json' },
18
+ body: body === undefined ? undefined : JSON.stringify(body),
19
+ signal: AbortSignal.timeout(300000),
20
+ });
21
+ const j = await r.json().catch(() => ({ value: { message: `HTTP ${r.status}, not JSON` } }));
22
+ if (!r.ok || (j.value && j.value.error)) throw new Error((j.value && j.value.message || `HTTP ${r.status}`).split('\n')[0]);
23
+ return j.value;
24
+ }
25
+
26
+ // A session on the Mac Catalyst app (mac2), by its .app path when known: by bundle id alone mac2
27
+ // answered "could not be found" for a build outside /Applications that macOS had not registered
28
+ // (Lekhak TF-003; seen again 2026-10-07). skipAppKill: the app is tf-verify-boot.sh's to stop, so
29
+ // ending the session leaves it running for the next check.
30
+ export async function openSession(url, { bundleId, appPath = '', automationName = 'Mac2', platformName = 'mac', extra = {} } = {}) {
31
+ const base = url.replace(/\/$/, '');
32
+ const which = appPath ? { 'appium:appPath': appPath } : { 'appium:bundleId': bundleId };
33
+ const v = await call(base, 'POST', '/session', { capabilities: { alwaysMatch: {
34
+ platformName, 'appium:automationName': automationName, ...which,
35
+ 'appium:skipAppKill': true, 'appium:newCommandTimeout': 300, ...extra } } });
36
+ const sp = `/session/${v.sessionId}`;
37
+ const s = {
38
+ id: v.sessionId,
39
+ call: (m, p, b) => call(base, m, sp + p, b),
40
+ source: () => s.call('GET', '/source'),
41
+ find: async (using, value, from = null) => Object.values(await s.call('POST', from ? `/element/${from}/element` : '/element', { using, value }))[0],
42
+ findAll: async (using, value, from = null) => (await s.call('POST', from ? `/element/${from}/elements` : '/elements', { using, value })).map((e) => e[W3C_EL] || Object.values(e)[0]),
43
+ click: (el) => s.call('POST', `/element/${el}/click`, {}),
44
+ // Typing into a field. A Blazor Hybrid form on a Mac kept its @bind empty or half filled when the
45
+ // value was set through Accessibility, through mac2's "set value", or by key events posted to the
46
+ // process ("The text must have valid value", Lekhak TF-026). What reached it: a click on the field,
47
+ // then W3C key actions, a key down and up per character.
48
+ type: async (el, text) => {
49
+ await s.click(el);
50
+ const actions = [...String(text)].flatMap((c) => [{ type: 'keyDown', value: c }, { type: 'keyUp', value: c }]);
51
+ await s.call('POST', '/actions', { actions: [{ type: 'key', id: 'keyboard', actions }] });
52
+ await s.call('DELETE', '/actions').catch(() => {});
53
+ },
54
+ shot: (el) => s.call('GET', el ? `/element/${el}/screenshot` : '/screenshot'),
55
+ close: () => call(base, 'DELETE', sp).catch(() => {}),
56
+ };
57
+ return s;
58
+ }
59
+
60
+ // What the app shows first, for tf-verify-boot.sh: BOOTED only when a mac2 session opens and the app
61
+ // has a window of its own that is not a system dialog (Lekhak TF-026: BOOTED was printed while no
62
+ // session could open, and once while the app's only window was macOS's "quit unexpectedly while
63
+ // reopening windows" dialog). That dialog is answered Don't Reopen, which only skips restoring old
64
+ // windows, and the answer is reported. The window alone is saved to `shot`. Never throws.
65
+ export async function firstScreen(url, { bundleId = '', appPath = '', shot = '', wait = 30000 } = {}) {
66
+ let s;
67
+ try { s = await openSession(url, { bundleId, appPath }); }
68
+ catch (e) { return { ok: false, stage: 'session', reason: e.message }; }
69
+ const sleep = (ms) => new Promise((ok) => setTimeout(ok, ms));
70
+ const DIALOG = new Set(['Dialog', 'Sheet', 'SystemDialog']);
71
+ const LEAF = new Set(['Button', 'TextField', 'SecureTextField', 'TextView', 'Image', 'CheckBox', 'Switch', 'Slider', 'PopUpButton',
72
+ 'ComboBox', 'Link', 'RadioButton', 'SegmentedControl', 'Stepper', 'DatePicker', 'ProgressIndicator', 'Cell']);
73
+ const out = { ok: false, stage: 'window', dismissed: '' };
74
+ try {
75
+ for (const t0 = Date.now(); Date.now() - t0 < wait; await sleep(500)) {
76
+ const nodes = parseSource(await s.source());
77
+ const label = (n) => n.title || n.label || '';
78
+ const reopen = nodes.find((n) => n.type === 'Button' && /^don.t reopen$/i.test(label(n)));
79
+ if (reopen && !out.dismissed) {
80
+ const el = await s.find('-ios predicate string', `elementType == 9 AND (title BEGINSWITH[c] 'Don' OR label BEGINSWITH[c] 'Don')`).catch(() => null);
81
+ if (el) { await s.click(el); out.dismissed = 'macOS asked to reopen the windows of a run that did not quit; answered Don\'t Reopen'; continue; }
82
+ }
83
+ const dialog = nodes.find((n) => DIALOG.has(n.type)) || reopen;
84
+ const win = nodes.find((n) => n.type === 'Window' && n.w >= 200 && n.h >= 150 && !descendants(nodes, n.i).some((d) => DIALOG.has(d.type)));
85
+ // drawn: text or a control in the window's content, as tf-verify-native counts it: what sits
86
+ // directly on the window (its title, its three buttons) and the bars are not content. A web view
87
+ // took about eight seconds to draw its first page; until then the window is blank.
88
+ const chrome = (n) => n.parent === win.i || ['TabBar', 'Toolbar', 'NavigationBar', 'MenuBar', 'Menu'].some((t) => {
89
+ for (let p = n.i; p >= 0; p = nodes[p].parent) if (nodes[p].type === t) return true;
90
+ return false; });
91
+ const drawn = win && descendants(nodes, win.i).some((n) => !chrome(n) && (n.type === 'StaticText' ? !!label(n).trim() || !!(n.value || '').trim() : LEAF.has(n.type)));
92
+ if (win && !dialog && drawn) {
93
+ out.ok = true; out.window = { w: win.w, h: win.h };
94
+ if (shot) {
95
+ await sleep(500); // the first frame of a web view fades in
96
+ // the window alone (TF-027: mac2's element shot is a crop of the display, banners included)
97
+ if (appPath && windowShot(appPid(appPath), shot)) out.shot = shot;
98
+ else {
99
+ const el = await s.find('-ios predicate string', 'elementType == 4').catch(() => null);
100
+ if (el) { const { writeFileSync } = await import('node:fs'); writeFileSync(shot, Buffer.from(await s.shot(el), 'base64')); out.shot = shot; }
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+ out.reason = dialog ? `the app shows a dialog, not its first screen: "${(label(dialog) || dialog.type).slice(0, 80)}"`
106
+ : win ? 'the app\'s window is on view but blank (no text, no control)' : 'the app has no window of its own on view';
107
+ }
108
+ out.reason = `${out.reason || 'no window'} after ${Math.round(wait / 1000)} s`;
109
+ return out;
110
+ } catch (e) { return { ...out, reason: `Appium: ${e.message}` }; }
111
+ finally { await s.close(); }
112
+ }
113
+
114
+ // The page source (XCUIElementType… XML) as a flat list of nodes with their parent index, so a
115
+ // caller can walk a subtree without an XML package.
116
+ export function parseSource(xml) {
117
+ const un = (s) => s.replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#10;/g, '\n').replace(/&amp;/g, '&');
118
+ const nodes = []; const stack = [];
119
+ const re = /<(\/?)(XCUIElementType\w+)([^>]*?)(\/?)>/g; let m;
120
+ while ((m = re.exec(xml))) {
121
+ if (m[1]) { stack.pop(); continue; }
122
+ const a = {}; let am; const are = /(\w+)="([^"]*)"/g;
123
+ while ((am = are.exec(m[3]))) a[am[1]] = un(am[2]);
124
+ const n = { i: nodes.length, type: m[2].replace('XCUIElementType', ''), id: a.identifier || '', label: a.label || '', value: a.value || '',
125
+ title: a.title || '', x: +a.x || 0, y: +a.y || 0, w: +a.width || 0, h: +a.height || 0, parent: stack.length ? stack[stack.length - 1] : -1, kids: [] };
126
+ if (n.parent >= 0) nodes[n.parent].kids.push(n.i);
127
+ nodes.push(n);
128
+ if (!m[4]) stack.push(n.i);
129
+ }
130
+ return nodes;
131
+ }
132
+
133
+ export function descendants(nodes, i) {
134
+ const out = []; const todo = [...nodes[i].kids];
135
+ while (todo.length) { const k = todo.shift(); out.push(nodes[k]); todo.push(...nodes[k].kids); }
136
+ return out;
137
+ }
138
+
139
+ // --- the app's own window, through macOS rather than mac2 (Lekhak TF-027) ---------------------------
140
+ // mac2 cannot resize a window, and its element screenshot is a crop of the display, so a notification
141
+ // banner over the window landed in every picture. macOS itself does both: System Events resizes the
142
+ // window (the app's minimum size still holds, and the size it really took is returned), and
143
+ // screencapture -l takes the window alone by its window number.
144
+ const run = (cmd, args) => { try { return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 20000 }).trim(); } catch { return ''; } };
145
+
146
+ // the pid of the app running from this .app (a mac2 session starts it again under a new pid)
147
+ export function appPid(appPath) {
148
+ const out = run('pgrep', ['-f', `${appPath.replace(/\/$/, '')}/Contents/MacOS/`]);
149
+ return parseInt(out.split('\n')[0], 10) || 0;
150
+ }
151
+
152
+ // { id, x, y, w, h } of the app's largest normal window on view, or null
153
+ export function macWindow(pid) {
154
+ if (!pid) return null;
155
+ const js = `ObjC.import('CoreGraphics');
156
+ const l = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, 0)));
157
+ JSON.stringify(l.filter((w) => w.kCGWindowOwnerPID == ${pid} && w.kCGWindowLayer == 0)
158
+ .map((w) => ({ id: w.kCGWindowNumber, x: w.kCGWindowBounds.X, y: w.kCGWindowBounds.Y, w: w.kCGWindowBounds.Width, h: w.kCGWindowBounds.Height })))`;
159
+ try { return JSON.parse(run('osascript', ['-l', 'JavaScript', '-e', js]) || '[]').sort((a, b) => b.w * b.h - a.w * a.h)[0] || null; }
160
+ catch { return null; }
161
+ }
162
+
163
+ // sets the front window's size; returns the size it took ({ w, h }), or null when macOS refused
164
+ // (the terminal needs Accessibility, as mac2 does)
165
+ export function resizeWindow(pid, w, h) {
166
+ if (!pid) return null;
167
+ const tell = `tell application "System Events" to tell (first process whose unix id is ${pid})`;
168
+ run('osascript', ['-e', `${tell} to set size of window 1 to {${w}, ${h}}`]);
169
+ const got = run('osascript', ['-e', `${tell} to get size of window 1`]).split(/,\s*/).map(Number);
170
+ return got.length === 2 && got.every((n) => n > 0) ? { w: got[0], h: got[1] } : null;
171
+ }
172
+
173
+ // the window alone, by its window number; true when the file was written
174
+ export function windowShot(pid, file) {
175
+ const win = macWindow(pid);
176
+ if (!win) return false;
177
+ run('screencapture', ['-x', '-o', '-l', String(win.id), file]);
178
+ try { return statSync(file).size > 0; } catch { return false; }
179
+ }
@@ -211,11 +211,26 @@ def main(argv):
211
211
  if any(r["id"].upper().startswith("REQ-UI-") for r in work):
212
212
  builder["UI"], why = ui_builder(app)
213
213
  print(f"UI rows go to: {builder['UI']} — {why}")
214
+ # A defect on a screen goes to one builder. Split by builder, the UI and the backend clusters of one
215
+ # page both carried its mockup defect, both edited the same files at once, and one left a component
216
+ # half-written that broke every other builder's boot for an hour (Chatur TF-004). Such a page is
217
+ # one cluster, built by the UI builder when one of its UI rows carries the defect.
218
+ cls_of = {r["id"]: re.match(r"REQ-(UI|FN|RAG|NFR)-", r["id"]).group(1) for r in work}
219
+ sec_of = {r["id"]: ent.get(r["id"].upper(), ("Other", "", "", ""))[0] for r in work}
220
+ fixed = {}
221
+ for r in work:
222
+ s, cls = sec_of[r["id"]], cls_of[r["id"]]
223
+ if r["defects"] and cls in ("UI", "FN") and (cls == "UI" or s not in fixed):
224
+ fixed[s] = builder[cls]
214
225
  for r in work:
215
226
  sec, acc, mock, brd = ent.get(r["id"].upper(), ("Other", "", "", ""))
216
227
  cls = re.match(r"REQ-(UI|FN|RAG|NFR)-", r["id"]).group(1)
217
- key = ("Non-functional" if cls == "NFR" else "RAG" if cls == "RAG" else sec, builder[cls])
228
+ who = fixed[sec] if cls in ("UI", "FN") and sec in fixed else builder[cls]
229
+ key = ("Non-functional" if cls == "NFR" else "RAG" if cls == "RAG" else sec, who)
218
230
  clusters.setdefault(key, []).append((r, acc, mock, brd))
231
+ for s in sorted(fixed):
232
+ if builder["UI"] != builder["FN"] and {"UI", "FN"} <= {cls_of[r["id"]] for r in work if sec_of[r["id"]] == s}:
233
+ print(f"One builder for {s}: it carries a defect, so its UI and backend rows go to {fixed[s]} together")
219
234
  print()
220
235
  print("## Clusters (one sub-agent each; spawn them all in one turn)")
221
236
  letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@@ -108,6 +108,47 @@ if [[ "$MODE" == "probe" ]]; then
108
108
  fi
109
109
  [[ -z "$TARGET" && "$MODE" != "run" ]] && { echo "NOT-RUN no .sln, .slnx or .csproj here; name the target"; exit 2; }
110
110
 
111
+ # ---- test on a solution: one test project at a time (TechieRag TF-004) ----------------------------
112
+ # `dotnet test <solution>` runs every test project at once. On a 7.8 GB WSL machine TechieRag's local-
113
+ # model tests took the memory while TechieRag.Tests walked folders, and a test that passes alone failed
114
+ # with "Cannot allocate memory", twice in one day. Each test project now runs on its own, through this
115
+ # same script, and the verdicts are merged into one line naming one joined log: FAIL if any failed
116
+ # (naming which), else NOT-RUN if any could not run, else PASS. TF_TEST_TOGETHER=1 runs them together.
117
+ if [[ "$MODE" == "test" && "${TF_TEST_TOGETHER:-0}" != "1" && "$TARGET" == *.sln* ]]; then
118
+ tests=()
119
+ for p in ${projects[@]+"${projects[@]}"}; do
120
+ f="$p"; [[ -f "$f" ]] || f="$base/$p"
121
+ [[ -f "$f" ]] || continue
122
+ grep -qiE 'Microsoft\.NET\.Test\.Sdk|<IsTestProject>[[:space:]]*true|"(xunit|xunit\.v3|NUnit|MSTest(\.TestFramework)?|TUnit)"|Include="(xunit|xunit\.v3|NUnit|MSTest|TUnit)' "$f" && tests+=("$f")
123
+ done
124
+ if [[ ${#tests[@]} -gt 1 ]]; then
125
+ if [[ $PRINT -eq 1 ]]; then
126
+ for t in "${tests[@]}"; do echo "dotnet test $t ${EXTRA[*]:-}"; done; exit 0
127
+ fi
128
+ mkdir -p tests/.artifacts/build
129
+ JOINED="tests/.artifacts/build/$(date -u +%Y%m%dT%H%M%SZ)-test-split-$$.log"; : > "$JOINED"
130
+ fails=(); notrun=(); passed=0; lines=(); pass=()
131
+ [[ ${#EXTRA[@]} -gt 0 ]] && pass=(-- "${EXTRA[@]}")
132
+ for t in "${tests[@]}"; do
133
+ out="$(TF_TEST_TOGETHER=1 bash "${BASH_SOURCE[0]}" test "$t" ${pass[@]+"${pass[@]}"} 2>&1)"
134
+ v="$(grep -m1 -E '^(PASS|FAIL|NOT-RUN)' <<<"$out")"
135
+ lg="$(sed -n 's/.*log \(tests\/\.artifacts\/build\/[^ ;]*\).*/\1/p' <<<"$v" | head -1)"
136
+ { echo "### test project: $t"; [[ -n "$lg" && -f "$lg" ]] && cat "$lg" || echo "$out"; } >> "$JOINED"
137
+ lines+=(" $(basename "$t" .csproj): $v")
138
+ case "$v" in PASS*) passed=$((passed+1)) ;; FAIL*) fails+=("$(basename "$t" .csproj)") ;; *) notrun+=("$(basename "$t" .csproj)") ;; esac
139
+ done
140
+ if [[ ${#fails[@]} -gt 0 ]]; then
141
+ echo "FAIL tests failed in ${fails[*]} (${#tests[@]} test projects, each run on its own; $passed passed); log $JOINED"; rc=1
142
+ elif [[ ${#notrun[@]} -gt 0 ]]; then
143
+ echo "NOT-RUN ${notrun[*]} could not run (${#tests[@]} test projects, each run on its own; $passed passed); log $JOINED"; rc=2
144
+ else
145
+ echo "PASS test on $PLATFORM — ${#tests[@]} test projects, each run on its own, all passed; log $JOINED"; rc=0
146
+ fi
147
+ printf '%s\n' "${lines[@]}"
148
+ exit $rc
149
+ fi
150
+ fi
151
+
111
152
  # ---- the dotnet verb -------------------------------------------------------------------
112
153
  verb="$MODE"
113
154
  args=("$verb")
@@ -162,6 +203,26 @@ cross_side() { # side: clear what another side built with its own paths in it, t
162
203
  }
163
204
  WRONG_RUNG='NETSDK1178|Microsoft\.(iOS|Android|MacCatalyst|tvOS)\.Sdk|Workload ID|not recognized|WindowsAppSDK|command not found|No such file or directory|is not recognized as an internal or external command|The term .* is not recognized|workload.*not installed|Inadequate permissions'
164
205
 
206
+ # A Mac Catalyst build after Platforms/MacCatalyst/Info.plist (or an .entitlements file) changed: the
207
+ # incremental build refreshed the DLLs and kept the .app's old Info.plist, so a fix that lives there
208
+ # looked as if it had failed (Lekhak TF-026: the scene manifest was in the source and not in the
209
+ # bundle). When such a file is newer than the bundle, that target's bin/ and obj/ are cleared first.
210
+ mac_tfm="$(printf '%s\n' ${EXTRA[@]+"${EXTRA[@]}"} | grep -oE '^net[0-9.]+-maccatalyst[0-9.]*$' | head -1)"
211
+ if [[ -n "$mac_tfm" && "$MODE" == build && "$TARGET" == *.csproj ]]; then
212
+ pd="$(dirname "$TARGET")"
213
+ for cfgdir in "$pd"/bin/*/"$mac_tfm"; do
214
+ [[ -d "$cfgdir" ]] || continue
215
+ plist="$(ls -t "$cfgdir"/*.app/Contents/Info.plist "$cfgdir"/*/*.app/Contents/Info.plist 2>/dev/null | head -1)"
216
+ [[ -n "$plist" ]] || continue
217
+ newer="$(find "$pd/Platforms/MacCatalyst" -maxdepth 1 \( -name '*.plist' -o -name '*.entitlements' \) -newer "$plist" 2>/dev/null | head -1)"
218
+ if [[ -n "$newer" ]]; then
219
+ cfg="$(basename "$(dirname "$cfgdir")")"
220
+ rm -rf "$cfgdir" "$pd/obj/$cfg/$mac_tfm"
221
+ echo "note $newer is newer than the built app ($plist); cleared bin/$cfg/$mac_tfm and obj/$cfg/$mac_tfm so the bundle is built again"
222
+ fi
223
+ done
224
+ fi
225
+
165
226
  tried=()
166
227
  n=0
167
228
  for r in "${rungs[@]}"; do
@@ -183,6 +244,20 @@ for r in "${rungs[@]}"; do
183
244
  [[ "$rc" != "0" ]] && grep -qE "$LOCKED" <<<"$seg" || break
184
245
  [[ $attempt -lt ${TF_BUILD_LOCK_RETRIES:-2} ]] && sleep "${TF_BUILD_LOCK_WAIT:-15}"
185
246
  done
247
+ # A Mac Catalyst SDK names the one Xcode it was made for and refuses a newer one ("requires Xcode
248
+ # 26.6. The current version of Xcode is 27.0"): not the code, not the rung, so it fell through to
249
+ # NOT-RUN, while tf-verify-boot.sh built the same head with the check off (Lekhak TF-032). The same
250
+ # rung runs once more with the check off, and a note line says so before the verdict.
251
+ if [[ "$rc" != "0" && "$PLATFORM" == macos ]] && grep -qE 'requires Xcode [0-9.]+[.] The current version of Xcode is' <<<"$seg" \
252
+ && [[ " ${cmd[*]} " != *ValidateXcodeVersion* ]]; then
253
+ xnote="$(grep -oE 'requires Xcode [0-9.]+[.] The current version of Xcode is [0-9.]+' <<<"$seg" | head -1 | sed 's/[.]$//')"
254
+ echo "note the Mac Catalyst SDK $xnote; built again with -p:ValidateXcodeVersion=false (xcode-check=off)"
255
+ cmd+=(-p:ValidateXcodeVersion=false)
256
+ from=$(( $(wc -l < "$LOG") + 1 ))
257
+ { echo "### rung $n, Xcode check off: ${cmd[*]}"; "${cmd[@]}" 2>&1; echo "### exit $?"; } >> "$LOG" 2>&1
258
+ rc="$(tail -1 "$LOG" | sed 's/### exit //')"
259
+ seg="$(tail -n +"$from" "$LOG")"
260
+ fi
186
261
  if [[ "$rc" == "0" ]]; then
187
262
  warns="$(grep -cE 'warning [A-Z]+[0-9]+' <<<"$seg" || true)"
188
263
  echo "PASS $MODE on $PLATFORM via $label (rung $n) — $warns warning line(s); log $LOG"
@@ -72,8 +72,26 @@ def set_cells(path, rid, status=None, pct=None, remark=None, append_remark=None)
72
72
  return prior
73
73
 
74
74
 
75
- def next_id(text, prefix):
76
- ids = [int(m.group(1)) for m in re.finditer(rf"REQ-{prefix}-(\d{{3}})", text)]
75
+ def siblings(path):
76
+ """every checklist of the same application: docs/<App>-Checklist.md and docs/<App>-P<N>-Checklist.md"""
77
+ d, f = os.path.split(path)
78
+ m = re.match(r"^(.+?)(?:-P\d+)?-Checklist\.md$", f)
79
+ if not m:
80
+ return []
81
+ app = re.escape(m.group(1))
82
+ try:
83
+ names = os.listdir(d or ".")
84
+ except OSError:
85
+ return []
86
+ return [os.path.join(d, n) for n in sorted(names) if n != f and re.match(rf"^{app}(?:-P\d+)?-Checklist\.md$", n)]
87
+
88
+
89
+ def next_id(text, prefix, others=()):
90
+ """one past the highest id of that prefix in this checklist AND every other checklist of the app:
91
+ counting this one alone gave REQ-UI-055 twice, once in phase 1 and once in phase 3 (Chatur TF-011)"""
92
+ ids = []
93
+ for t in (text, *(read(o) for o in others)):
94
+ ids += [int(m.group(1)) for m in re.finditer(rf"REQ-{prefix}-(\d{{3,}})", t)]
77
95
  return f"REQ-{prefix}-{(max(ids) + 1 if ids else 1):03d}"
78
96
 
79
97
 
@@ -81,7 +99,7 @@ def add_row(path, prefix, title, acceptance, section=None, remark=None, status="
81
99
  """append a row to the Requirements Status table and a detail entry under `section`
82
100
  (a new section at the end when it does not exist); returns the new id"""
83
101
  text = read(path)
84
- rid = next_id(text, prefix)
102
+ rid = next_id(text, prefix, siblings(path))
85
103
  nl = "\r\n" if "\r\n" in text else "\n"
86
104
  lines = text.split(nl)
87
105
  # the table: after the last REQ row
@@ -4,15 +4,17 @@
4
4
  bash .tfcore/utils/tf-day1-files.sh <App> [--size S|M|L] [--kind app|library] [--prefix obj|none] [--phase N]
5
5
  bash .tfcore/utils/tf-day1-files.sh --archive <file> [<file> ...]
6
6
 
7
- Default mode, for <App>:
8
- - .tfcore/core-config.yaml: customTechnicalDocuments (brd, architecture, codingStandards),
9
- devLoadAlwaysFiles, and appSize / appKind / appPhase when given.
7
+ Default mode, for <App>. Each flag does its own part and nothing else:
8
+ - --size, --kind, --phase set appSize, appKind, appPhase in .tfcore/core-config.yaml; --phase is the
9
+ phase being worked (handoff moves it on).
10
10
  - --size L also writes docs/<App>-Phases.md from app-phases-tmpl.md when it does not exist
11
11
  (the Large layout: docs/TechieFlow-Document-Schemas.md §2, §3.11). The agent fills the table.
12
- - --phase N sets appPhase, the phase being worked (handoff moves it on).
13
- - .editorconfig from app-editorconfig-tmpl.editorconfig (overwritten; machine config).
14
- - AGENTS.md and CLAUDE.md from their templates with {AppName} substituted and the
15
- field-prefix line resolved (--prefix, default obj). An existing copy is archived first.
12
+ - --prefix obj|none (day-1 stage 2) writes .editorconfig (overwritten; machine config), AGENTS.md and
13
+ CLAUDE.md from their templates with {AppName} substituted and the field-prefix line resolved, an
14
+ existing copy archived first; and adds the brd, architecture and codingStandards paths to
15
+ customTechnicalDocuments and devLoadAlwaysFiles where missing, keeping every entry already there.
16
+ Raising the size of a project with *amend-docs (--size L) once replaced Sevak's AGENTS.md and CLAUDE.md
17
+ with blank templates and cut its document list to three entries (Sevak TF-001). tests/regression sv_001.
16
18
  --archive: move each file (and its sibling .html) to docs/OldDocs/, date-suffixed when a
17
19
  file of that name is already there. Never asks; never writes a -v2 variant.
18
20
 
@@ -86,22 +88,45 @@ def set_key(text, key, value_lines):
86
88
  return "\n".join(out) + "\n"
87
89
 
88
90
 
89
- def update_config(app, size, kind, phase):
91
+ def block_of(text, key):
92
+ """The indented lines under a top-level YAML key, [] when the key is absent or empty."""
93
+ lines, out, inside = text.splitlines(), [], False
94
+ for line in lines:
95
+ if re.match(rf"^{re.escape(key)}:", line):
96
+ inside = True
97
+ continue
98
+ if inside:
99
+ if line.startswith((" ", "\t")):
100
+ out.append(line)
101
+ elif line.strip():
102
+ break
103
+ return out
104
+
105
+
106
+ def add_documents(s, app):
107
+ """The day-1 document paths, added where missing; every entry already listed is kept."""
108
+ docs = block_of(s, "customTechnicalDocuments")
109
+ have = {m.group(1) for m in (re.match(r"^\s+([\w-]+):", l) for l in docs) if m}
110
+ for k, v in (("brd", f"docs/{app}-BRD.md"), ("architecture", f"docs/{app}-Architecture.md"),
111
+ ("codingStandards", f"docs/{app}-Coding-Standards.md")):
112
+ if k not in have:
113
+ docs.append(f" {k}: {v}")
114
+ s = set_key(s, "customTechnicalDocuments", ["customTechnicalDocuments:"] + docs)
115
+ files = block_of(s, "devLoadAlwaysFiles")
116
+ listed = {l.strip().lstrip("-").strip() for l in files}
117
+ for f in (f"docs/{app}-Coding-Standards.md", f"docs/{app}-Architecture.md"):
118
+ if f not in listed:
119
+ files.append(f" - {f}")
120
+ return set_key(s, "devLoadAlwaysFiles", ["devLoadAlwaysFiles:"] + files)
121
+
122
+
123
+ def update_config(app, size, kind, phase, documents):
90
124
  p = os.path.join(".tfcore", "core-config.yaml")
91
125
  if not os.path.isfile(p):
92
126
  die(f"{p} does not exist at its literal path; is the framework installed here?")
93
127
  s = read(p)
94
- s = set_key(s, "customTechnicalDocuments", [
95
- "customTechnicalDocuments:",
96
- f" brd: docs/{app}-BRD.md",
97
- f" architecture: docs/{app}-Architecture.md",
98
- f" codingStandards: docs/{app}-Coding-Standards.md",
99
- ])
100
- s = set_key(s, "devLoadAlwaysFiles", [
101
- "devLoadAlwaysFiles:",
102
- f" - docs/{app}-Coding-Standards.md",
103
- f" - docs/{app}-Architecture.md",
104
- ])
128
+ if documents:
129
+ s = add_documents(s, app)
105
130
  if size:
106
131
  s = set_key(s, "appSize", [f"appSize: {size}"])
107
132
  if kind:
@@ -109,9 +134,9 @@ def update_config(app, size, kind, phase):
109
134
  if phase:
110
135
  s = set_key(s, "appPhase", [f"appPhase: {phase}"])
111
136
  write(p, s)
112
- print(f"tf-day1-files: .tfcore/core-config.yaml updated (documents for {app}"
113
- + (f", appSize {size}" if size else "") + (f", appKind {kind}" if kind else "")
114
- + (f", appPhase {phase}" if phase else "") + ")")
137
+ done = ([f"documents for {app}"] if documents else []) + ([f"appSize {size}"] if size else []) \
138
+ + ([f"appKind {kind}"] if kind else []) + ([f"appPhase {phase}"] if phase else [])
139
+ print(f"tf-day1-files: .tfcore/core-config.yaml updated ({', '.join(done)})")
115
140
 
116
141
 
117
142
  def write_phases_doc(app):
@@ -161,8 +186,7 @@ def main(argv):
161
186
  app = argv[1]
162
187
  if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", app):
163
188
  die(f"app name {app!r} must be PascalCase with no spaces")
164
- size = kind = phase = None
165
- prefix = "obj"
189
+ size = kind = phase = prefix = None
166
190
  args = argv[2:]
167
191
  i = 0
168
192
  while i < len(args):
@@ -190,12 +214,15 @@ def main(argv):
190
214
  i += 2
191
215
  else:
192
216
  die(f"unknown argument {a!r}")
193
- update_config(app, size, kind, phase)
217
+ if not (size or kind or phase or prefix):
218
+ die("nothing to do: give --size, --kind, --phase or --prefix")
219
+ update_config(app, size, kind, phase, documents=bool(prefix))
194
220
  if size == "L":
195
221
  write_phases_doc(app)
196
- write_from_template(app, "app-editorconfig-tmpl.editorconfig", ".editorconfig", None)
197
- write_from_template(app, "app-agents-md-tmpl.md", "AGENTS.md", prefix)
198
- write_from_template(app, "app-claude-md-tmpl.md", "CLAUDE.md", prefix)
222
+ if prefix: # day-1 stage 2 only: a size, kind or phase change never rewrites these (Sevak TF-001)
223
+ write_from_template(app, "app-editorconfig-tmpl.editorconfig", ".editorconfig", None)
224
+ write_from_template(app, "app-agents-md-tmpl.md", "AGENTS.md", prefix)
225
+ write_from_template(app, "app-claude-md-tmpl.md", "CLAUDE.md", prefix)
199
226
  return 0
200
227
 
201
228