@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
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env bash
2
+ # tf-develop.sh — *develop-end-to-end: a brief in, a greenfield app out, up to UAT, unattended (2026-10-08).
3
+ #
4
+ # bash .tfcore/utils/tf-develop.sh <app-dir> --app <App> --brief <file> [--harness claude|opencode]
5
+ # [--model <id>] [--tier standard] [--no-push] [--dry-run] [--detach]
6
+ # bash .tfcore/utils/tf-develop.sh <app-dir> --report # write the Build Report again from the saved state
7
+ # bash .tfcore/utils/tf-develop.sh <app-dir> --resume # continue a stopped run where it stopped
8
+ #
9
+ # The flow-master's *develop-end-to-end starts this; the owner never types it. It runs four phases, each
10
+ # one an unattended goal run (tf-goal.sh: no questions, survives usage limits by waiting for the reset
11
+ # and resuming the same session):
12
+ # day1 *day1-greenfield <App> BRD, Architecture, mockups — no owner review stop
13
+ # day1-2 *day1-greenfield <App> --stage2 checklist and the remaining documents
14
+ # build *build-phase <App> every row built, verified inline, FIX mode on failures
15
+ # handoff *handoff-phase <App> UsageGuide with its smoke checklist and test users: UAT-ready
16
+ # While it runs, .tfcore/.session/develop.json exists and every task follows .tfcore/tasks/_develop-mode.md.
17
+ #
18
+ # GIT — the one exception to "agents never write to git", for this command only. The agent still
19
+ # cannot commit: its git guard is unchanged. THIS script commits after each phase, and pushes, from
20
+ # its own shell, never through an agent's tool. When it ends, nothing is left switched on.
21
+ #
22
+ # METRICS — after every phase it writes docs/metrics/develop-report.json and docs/<App>-Build-Report.md:
23
+ # per phase the wall-clock time (limit waits included), the agent's working time and its tokens (summed
24
+ # from that phase's run records in docs/metrics/runs.jsonl), and the totals: "built in …".
25
+ #
26
+ # Exit 0 UAT-ready · 3 the agent declared a phase BLOCKED (the owner must act; listed in the report)
27
+ # · 4 a phase ran out of cycles · 5 the provider refused the model · 2 usage error.
28
+ set -uo pipefail
29
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ case "${1:-}" in
31
+ -h|--help) sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
32
+ "") sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
33
+ esac
34
+
35
+ DIR="$(cd "$1" 2>/dev/null && pwd)" || { echo "tf-develop: no such dir: $1" >&2; exit 2; }; shift
36
+ APP=""; BRIEF=""; HARNESS="claude"; MODEL=""; TIER="standard"; PUSH=1; DRY=0; RESUME=0; DETACH=0; REPORT_ONLY=0
37
+ ARGS_IN=("$@")
38
+ while [[ $# -gt 0 ]]; do
39
+ case "$1" in
40
+ --app) APP="${2:-}"; shift 2 ;;
41
+ --brief) BRIEF="${2:-}"; shift 2 ;;
42
+ --harness) HARNESS="${2:-}"; shift 2 ;;
43
+ --model) MODEL="${2:-}"; shift 2 ;;
44
+ --tier) TIER="${2:-}"; shift 2 ;;
45
+ --no-push) PUSH=0; shift ;;
46
+ --dry-run) DRY=1; shift ;;
47
+ --resume) RESUME=1; shift ;;
48
+ --detach) DETACH=1; shift ;;
49
+ --report) REPORT_ONLY=1; shift ;;
50
+ *) echo "tf-develop: unknown argument $1" >&2; exit 2 ;;
51
+ esac
52
+ done
53
+ # --detach: start this same run in its own session and return at once, so it outlives the agent session
54
+ # that started it (the flow-master's *develop-end-to-end), in a session of its own (tf_setsid, portable).
55
+ if [[ $DETACH -eq 1 ]]; then
56
+ mkdir -p "$DIR/.tfcore/.session"
57
+ rest=(); for a in "${ARGS_IN[@]}"; do [[ "$a" != --detach ]] && rest+=("$a"); done
58
+ source "$HERE/tf-portable.sh" # tf_setsid works on macOS too (a python3 or perl stand-in)
59
+ tf_setsid bash "$0" "$DIR" "${rest[@]}" > "$DIR/.tfcore/.session/develop.out" 2>&1 < /dev/null &
60
+ echo "tf-develop: started (pid $!) — follow .tfcore/.session/develop.log; the result is docs/<App>-Build-Report.md"
61
+ exit 0
62
+ fi
63
+ STATE="$DIR/.tfcore/.session/develop.json"
64
+ GOAL_SH="${TF_DEVELOP_GOAL_SH:-$DIR/.tfcore/utils/tf-goal.sh}" # tests point this at a fake
65
+ mkdir -p "$DIR/.tfcore/.session" "$DIR/docs/metrics"
66
+ ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
67
+ # The app folder must be the TOP of its own repository. A folder inside another repository answers
68
+ # rev-parse too, and `git add -A` there staged the whole outer repository (found by dev_001 on its first run).
69
+ own_repo() { local top; top="$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null)" || return 1
70
+ [[ -n "$top" && "$(cd "$top" && pwd -P)" == "$(cd "$DIR" && pwd -P)" ]]; }
71
+ log() { printf '[%s] tf-develop: %s\n' "$(ts)" "$*" | tee -a "$DIR/.tfcore/.session/develop.log" >&2; }
72
+
73
+ st() { python3 - "$STATE" "$@" <<'PY'
74
+ import json, sys
75
+ p, op, *a = sys.argv[1:]
76
+ try: d = json.load(open(p))
77
+ except Exception: d = {}
78
+ if op == "get": print(d.get(a[0], ""))
79
+ elif op == "init":
80
+ d = {"app": a[0], "brief": a[1], "harness": a[2], "model": a[3], "tier": a[4], "push": a[5] == "1", "started": a[6], "phases": []}
81
+ elif op == "done": print("yes" if any(x["name"] == a[0] and x["status"] == "done" for x in d.get("phases", [])) else "")
82
+ elif op == "attempts": print(sum(1 for x in d.get("phases", []) if x["name"] == a[0]))
83
+ elif op == "phase": # name status started ended exit commit pushed attempt — one entry per attempt, keyed by its number:
84
+ # a blocked attempt and its resume can start in the same second on a fast machine, and keying by the
85
+ # start made the second overwrite the first (dev_001 on the CI runner, 2026-10-09)
86
+ ph = d.get("phases", [])
87
+ new = dict(zip(("name", "status", "started", "ended", "goal_exit", "commit", "pushed", "attempt"), a))
88
+ hit = [i for i, x in enumerate(ph) if x["name"] == a[0] and str(x.get("attempt", "")) == a[7]]
89
+ if hit: ph[hit[0]] = new
90
+ else: ph.append(new)
91
+ d["phases"] = ph
92
+ if op in ("init", "phase"): json.dump(d, open(p, "w"), indent=1)
93
+ PY
94
+ }
95
+
96
+ if [[ $REPORT_ONLY -eq 1 ]]; then :
97
+ elif [[ $RESUME -eq 1 ]]; then
98
+ [[ -f "$STATE" ]] || { echo "tf-develop: --resume: no $STATE" >&2; exit 2; }
99
+ APP="$(st get app)"; BRIEF="$(st get brief)"; HARNESS="$(st get harness)"; MODEL="$(st get model)"; TIER="$(st get tier)"
100
+ [[ "$(st get push)" == "False" ]] && PUSH=0
101
+ else
102
+ [[ -n "$APP" && -n "$BRIEF" ]] || { echo "tf-develop: --app <App> and --brief <file> are required" >&2; exit 2; }
103
+ [[ "$APP" =~ ^[A-Z][A-Za-z0-9]*$ ]] || { echo "tf-develop: --app must be PascalCase with no spaces" >&2; exit 2; }
104
+ [[ -f "$BRIEF" ]] || { echo "tf-develop: brief not found: $BRIEF" >&2; exit 2; }
105
+ own_repo || { echo "tf-develop: $DIR is not a git repository of its own (a folder inside another repository does not count) — set it up first" >&2; exit 2; }
106
+ if [[ $PUSH -eq 1 && -z "$(git -C "$DIR" remote 2>/dev/null)" ]]; then echo "tf-develop: $DIR has no git remote to push to — add one, or pass --no-push" >&2; exit 2; fi
107
+ if [[ $DRY -eq 0 ]]; then
108
+ [[ "$(cd "$(dirname "$BRIEF")" && pwd)/$(basename "$BRIEF")" == "$DIR/docs/$APP-Brief.md" ]] || cp "$BRIEF" "$DIR/docs/$APP-Brief.md"
109
+ st init "$APP" "docs/$APP-Brief.md" "$HARNESS" "$MODEL" "$TIER" "$PUSH" "$(ts)"
110
+ fi
111
+ fi
112
+
113
+ MODE="Act as the flow-master (.tfcore/agents/flow-master.md). The command is *develop-end-to-end for $APP, in develop mode: read .tfcore/tasks/_develop-mode.md first and follow it in every task — no question to the owner, no stop for an owner review; every decision the owner would make is yours, written where that rule says. The brief is docs/$APP-Brief.md."
114
+ goal_of() {
115
+ case "$1" in
116
+ day1) echo "$MODE Run *day1-greenfield $APP, stage 1 (.tfcore/tasks/day1-greenfield.md), with the brief as the concept, all of it. When stage 1 is complete and rendered, the goal is met: do not stop for review and do not start stage 2." ;;
117
+ day1-2) echo "$MODE Run *day1-greenfield $APP --stage2. The goal is met when stage 2 is complete and its status gate has run." ;;
118
+ build) echo "$MODE Run *build-phase $APP (.tfcore/tasks/build-phase.md) on the whole phase checklist: every row built, the verifier chained inline, FIX mode on every failing row, until every row is terminal. Create and migrate the database the Architecture names, locally, as that rule says." ;;
119
+ handoff) echo "$MODE Run *handoff-phase $APP (.tfcore/tasks/handoff-phase.md): the UsageGuide with its smoke checklist and test users, the guides, the HTMLs, the status gate. The goal is met when the app is ready for the owner's UAT." ;;
120
+ esac
121
+ }
122
+
123
+ commit_phase() { # $1 phase → prints "<sha> <pushed yes|no|off>"
124
+ local sha="" pushed="off"
125
+ own_repo || { echo "none no"; return; } # never commit into a repository the app folder merely sits inside
126
+ if [[ -n "$(git -C "$DIR" status --porcelain 2>/dev/null)" ]]; then
127
+ git -C "$DIR" add -A >/dev/null 2>&1
128
+ git -C "$DIR" commit -q -m "TechieFlow develop-end-to-end: $1 done — $APP" -m "Written by tf-develop.sh after the $1 phase; per-phase time and tokens in docs/metrics/develop-report.json." >/dev/null 2>&1
129
+ fi
130
+ sha="$(git -C "$DIR" rev-parse --short HEAD 2>/dev/null)"
131
+ if [[ $PUSH -eq 1 ]]; then
132
+ if git -C "$DIR" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
133
+ git -C "$DIR" push -q >/dev/null 2>&1 && pushed=yes || pushed=no
134
+ else
135
+ git -C "$DIR" push -q -u "$(git -C "$DIR" remote | head -1)" HEAD >/dev/null 2>&1 && pushed=yes || pushed=no
136
+ fi
137
+ fi
138
+ echo "${sha:-none} $pushed"
139
+ }
140
+
141
+ report() { # writes docs/metrics/develop-report.json and docs/<App>-Build-Report.md from the state and runs.jsonl
142
+ python3 - "$STATE" "$DIR" <<'PY'
143
+ import datetime, json, os, sys
144
+ state, root = json.load(open(sys.argv[1])), sys.argv[2]
145
+ app = state["app"]
146
+ def t(s):
147
+ try: return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ")
148
+ except Exception: return None
149
+ runs = []
150
+ try:
151
+ for line in open(os.path.join(root, "docs", "metrics", "runs.jsonl"), encoding="utf-8", errors="replace"):
152
+ try: r = json.loads(line)
153
+ except Exception: continue
154
+ if r.get("kind", "run") == "run" and r.get("app") in (None, app) and r.get("started"): runs.append(r)
155
+ except OSError: pass
156
+ voided = {(r.get("cmd"), r.get("started")) for r in runs if r.get("kind") == "run-void"}
157
+ def hm(s):
158
+ s = int(s or 0); return f"{s // 3600} h {s % 3600 // 60:02d} min" if s >= 3600 else f"{s // 60} min {s % 60:02d} s"
159
+ NAMES = {"day1": "Day 1, stage 1 (BRD, Architecture, mockups)", "day1-2": "Day 1, stage 2 (checklist, documents)",
160
+ "build": "Build and verify", "handoff": "Handoff (UsageGuide, UAT-ready)"}
161
+ out, tot = [], {"wall_s": 0, "work_s": 0, "tokens_in": 0, "tokens_out": 0, "tokens_cache_read": 0, "tokens_cache_write": 0, "runs": 0}
162
+ models = {}
163
+ # One row per phase, summing every attempt of it (a phase stopped "blocked" and resumed is run again; its
164
+ # first attempts' time and tokens are part of what the phase cost). A run record belongs to the attempt
165
+ # whose window holds its start, the window's first second included and its last excluded: a record that
166
+ # starts the second the previous phase ends belongs to the next one (the first proof run counted the
167
+ # build's 301,201 tokens under Day 1 stage 2).
168
+ order, attempts = [], {}
169
+ for x in state.get("phases", []):
170
+ if x["name"] not in attempts: order.append(x["name"])
171
+ attempts.setdefault(x["name"], []).append(x)
172
+ def inside(r, wins):
173
+ s = t(r.get("started"))
174
+ return s is not None and any(a <= s < b for a, b in wins)
175
+ for name in order:
176
+ ats = attempts[name]; p = ats[-1]
177
+ wins = [(t(x.get("started")), t(x.get("ended")) or datetime.datetime.utcnow()) for x in ats if t(x.get("started"))]
178
+ rs = [r for r in runs if (r.get("cmd"), r.get("started")) not in voided and inside(r, wins)]
179
+ # A record's tokens are measured over its own window of the session, so a command chained inside
180
+ # another (devguide inside handoff-phase) is counted twice if both are summed: a record lying wholly
181
+ # inside another is dropped, and working time is the union of the windows, not their sum.
182
+ span = lambda r: (t(r.get("started")), t(r.get("ended")) or t(r.get("started")))
183
+ rs = [r for i, r in enumerate(rs) if not any(
184
+ j != i and span(o)[0] and span(r)[0] and span(o)[0] <= span(r)[0] and span(r)[1] <= span(o)[1]
185
+ and (span(o) != span(r) or j < i) for j, o in enumerate(rs))]
186
+ iv = sorted(span(r) for r in rs if span(r)[0]); union, cur = 0, None
187
+ for a_, b_ in iv:
188
+ if cur and a_ <= cur[1]: cur = (cur[0], max(cur[1], b_))
189
+ else:
190
+ if cur: union += (cur[1] - cur[0]).total_seconds()
191
+ cur = (a_, b_)
192
+ if cur: union += (cur[1] - cur[0]).total_seconds()
193
+ row = {"phase": name, "title": NAMES.get(name, name), "status": p.get("status"), "attempts": len(ats),
194
+ "started": ats[0].get("started"), "ended": p.get("ended"),
195
+ "wall_s": int(sum((b - a).total_seconds() for a, b in wins)), "work_s": int(union),
196
+ "tokens_in": sum(int(r.get("tokens_in") or 0) for r in rs), "tokens_out": sum(int(r.get("tokens_out") or 0) for r in rs),
197
+ "tokens_cache_read": sum(int(r.get("tokens_cache_read") or 0) for r in rs),
198
+ "tokens_cache_write": sum(int(r.get("tokens_cache_write") or 0) for r in rs),
199
+ "runs": len(rs), "commands": sorted({r.get("cmd") for r in rs if r.get("cmd")}), "commit": p.get("commit"), "pushed": p.get("pushed")}
200
+ for r in rs:
201
+ for m, n in (r.get("model_tokens_out") or {r.get("model") or "unknown": r.get("tokens_out") or 0}).items():
202
+ models[m] = models.get(m, 0) + int(n or 0)
203
+ for k in tot: tot[k] += row[k]
204
+ out.append(row)
205
+ done = [p for p in out if p["status"] == "done"]
206
+ ready = len(done) == 4
207
+ starts = [t(p["started"]) for p in out if t(p["started"])]; ends = [t(p["ended"]) for p in out if t(p["ended"])]
208
+ span = int((max(ends) - min(starts)).total_seconds()) if starts and ends else 0
209
+ rep = {"app": app, "harness": state.get("harness"), "model": state.get("model") or None, "tier": state.get("tier"),
210
+ "started": state.get("started"), "status": "uat-ready" if ready else (out[-1]["status"] if out else "not-started"),
211
+ "elapsed_s": span, "totals": tot, "tokens_out_by_model": models, "phases": out,
212
+ "note": "elapsed_s is first phase start to last phase end, usage-limit waits included; work_s is the agent's own run time from its run records"}
213
+ json.dump(rep, open(os.path.join(root, "docs", "metrics", "develop-report.json"), "w"), indent=1)
214
+ L = [f"# {app} — Build Report", "", "| | |", "|---|---|",
215
+ f"| What | {app}, developed end to end from docs/{app}-Brief.md by *develop-end-to-end |",
216
+ f"| Result | {'Ready for UAT' if ready else 'Stopped at ' + (out[-1]['title'] if out else 'the start') + ' (' + rep['status'] + ')'} |",
217
+ f"| Built in | {hm(span)} from start to finish, of which the agent worked {hm(tot['work_s'])} |",
218
+ f"| Harness and model | {state.get('harness')}, {', '.join(sorted(models)) or state.get('model') or 'default'} |",
219
+ f"| Tokens | {tot['tokens_in']:,} in, {tot['tokens_out']:,} out, {tot['tokens_cache_read']:,} read from cache, {tot['tokens_cache_write']:,} written to cache |",
220
+ "", "## Phases", "", "| Phase | Result | Time | Agent working | Tokens out | Commit |", "|---|---|---|---|---|---|"]
221
+ for p in out:
222
+ L.append(f"| {p['title']}{' (' + str(p['attempts']) + ' attempts)' if p['attempts'] > 1 else ''} | {p['status']} | {hm(p['wall_s'])} | {hm(p['work_s'])} | {p['tokens_out']:,} | {p['commit'] or '—'}{'' if p['pushed'] in ('yes', 'off') else ' (not pushed)'} |")
223
+ L += ["", "Time is the clock from the phase's start to its end, summed over its attempts, waits for a usage limit to reset included. "
224
+ "Built in also counts the time between attempts, while a stopped run waited to be resumed. "
225
+ "Agent working is the sum of the phase's run records. The same figures, for a comparison harness, are in "
226
+ "`docs/metrics/develop-report.json`.", ""]
227
+ open(os.path.join(root, "docs", f"{app}-Build-Report.md"), "w", encoding="utf-8").write("\n".join(L))
228
+ print(f"report: {rep['status']}, {hm(span)} elapsed, {tot['tokens_out']:,} tokens out — docs/{app}-Build-Report.md")
229
+ PY
230
+ }
231
+
232
+ # No --model: the tier's routed model (.tfcore/routing.yaml), never the machine's own default. The proof
233
+ # run on OpenCode fell back to a local model server that was not running and failed every cycle.
234
+ if [[ -z "$MODEL" && -f "$DIR/.tfcore/utils/tf-model-pick.sh" ]]; then
235
+ MODEL="$(cd "$DIR" && bash .tfcore/utils/tf-model-pick.sh pick "$TIER" "$HARNESS" 2>/dev/null)"
236
+ [[ "$MODEL" == inherit ]] && MODEL=""
237
+ fi
238
+ # --report: write the report again from the saved state (the run's, or the finished run's), nothing else
239
+ if [[ $REPORT_ONLY -eq 1 ]]; then
240
+ [[ -f "$STATE" ]] || STATE="$DIR/.tfcore/.session/develop-done.json"
241
+ [[ -f "$STATE" ]] || { echo "tf-develop: --report: no develop run recorded here" >&2; exit 2; }
242
+ report; exit 0
243
+ fi
244
+ GOAL_ARGS=(--harness "$HARNESS" --tier "$TIER"); [[ -n "$MODEL" ]] && GOAL_ARGS+=(--model "$MODEL")
245
+ for phase in day1 day1-2 build handoff; do
246
+ if [[ $DRY -eq 1 ]]; then echo "$phase: bash $GOAL_SH ${GOAL_ARGS[*]} $DIR \"$(goal_of "$phase" | cut -c1-90)…\"; then commit$([[ $PUSH -eq 1 ]] && echo ' and push')"; continue; fi
247
+ [[ -n "$(st done "$phase")" ]] && { log "$phase already done — skipped"; continue; }
248
+ start="$(ts)"; log "$phase started"; attempt=$(( $(st attempts "$phase") + 1 ))
249
+ st phase "$phase" running "$start" "" "" "" "" "$attempt"
250
+ bash "$GOAL_SH" "${GOAL_ARGS[@]}" "$DIR" "$(goal_of "$phase")"; rc=$?
251
+ status=done; [[ $rc -eq 3 ]] && status=blocked; [[ $rc -ne 0 && $rc -ne 3 ]] && status="failed (exit $rc)"
252
+ end="$(ts)"
253
+ st phase "$phase" "$status" "$start" "$end" "$rc" "" "" "$attempt"
254
+ report >/dev/null # the phase's figures go into its own commit
255
+ read -r sha pushed <<<"$(commit_phase "$phase")"
256
+ st phase "$phase" "$status" "$start" "$end" "$rc" "$sha" "$pushed" "$attempt"
257
+ log "$phase $status — commit $sha, pushed $pushed"
258
+ if [[ $rc -ne 0 ]]; then report; commit_phase "report" >/dev/null; exit "$rc"; fi
259
+ done
260
+ [[ $DRY -eq 1 ]] && exit 0
261
+ report; read -r sha pushed <<<"$(commit_phase "build report")"
262
+ log "UAT-ready — final report commit $sha, pushed $pushed"
263
+ mv -f "$STATE" "$DIR/.tfcore/.session/develop-done.json" # develop mode ends with the run
264
+ exit 0
@@ -3,7 +3,7 @@
3
3
 
4
4
  bash .tfcore/utils/tf-devguide-list.sh <App> [--phase N] [--update]
5
5
 
6
- Reads appKind and appPhase from .tfcore/core-config.yaml and prints:
6
+ Reads appKind (or metrics.project_type) and appPhase from .tfcore/core-config.yaml and prints:
7
7
  app every routed page found in the code (route, file), matched to the phase's
8
8
  UIDesign screens and to the roles in the UsageGuide's Test users table;
9
9
  UIDesign screens with no code (a defect), routes with no UIDesign screen
@@ -151,7 +151,11 @@ def main(argv):
151
151
  app = argv[1]
152
152
  update = "--update" in argv
153
153
  phase = int(argv[argv.index("--phase") + 1]) if "--phase" in argv and argv.index("--phase") + 1 < len(argv) else int(cfg("appPhase", "1"))
154
- kind = cfg("appKind", "app").lower()
154
+ kind = cfg("appKind", "").lower()
155
+ if not kind: # metrics.project_type: library says it when appKind is not set (TrBlazeUI TF-005)
156
+ pt = re.search(r"(?m)^\s+project_type:\s*(\S+)", read(os.path.join(".tfcore", "core-config.yaml"))) \
157
+ if os.path.isfile(os.path.join(".tfcore", "core-config.yaml")) else None
158
+ kind = pt.group(1).strip("'\"").lower() if pt else "app"
155
159
  if "--kind" in argv and argv.index("--kind") + 1 < len(argv):
156
160
  kind = argv[argv.index("--kind") + 1].lower() # override for a project whose config lacks it
157
161
  sub = "app"
@@ -70,7 +70,9 @@ DOC_NAME = re.compile(
70
70
  r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
71
71
  re.I)
72
72
  PHASE_FORM = re.compile(r"^(\d+)\s+of\s+(\d+)$", re.I)
73
- PHASE_STATUS = {"planned", "building", "done"}
73
+ # kept-whole: a phase finished before the size cap that the owner chose to keep as one document;
74
+ # its cap and word maximum are reported as WARN, not FAIL (Lekhak TF-029)
75
+ PHASE_STATUS = {"planned", "building", "done", "kept-whole"}
74
76
  BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re.I)
75
77
 
76
78
  SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
@@ -96,9 +98,10 @@ NAMES_SCREEN = re.compile(r"\b(on|opens?|from|in)\b", re.I)
96
98
 
97
99
 
98
100
  def acceptance_words(line: str) -> int:
99
- """Words in an acceptance line, without the "Acceptance:" label and without a perf-budget tail."""
101
+ """Words in an acceptance line, without the "Acceptance:" label and without a perf-budget or
102
+ zoom-check tail (the markers the verifier reads)."""
100
103
  t = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", line)
101
- t = re.split(r"perf-budget:", t, flags=re.I)[0]
104
+ t = re.split(r"perf-budget:|zoom-check:", t, flags=re.I)[0]
102
105
  return len(re.findall(r"\S+", t))
103
106
 
104
107
 
@@ -331,7 +334,7 @@ def read_core_config(root: str) -> dict:
331
334
  if os.path.exists(p):
332
335
  with open(p, encoding="utf-8") as fh:
333
336
  for line in fh:
334
- m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line)
337
+ m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line) or re.match(r"^\s+(project_type):\s*(\S+)", line)
335
338
  if m and m.group(2) not in ("null", "~", "''", '""'):
336
339
  out[m.group(1)] = m.group(2).strip("'\"")
337
340
  return out
@@ -356,6 +359,24 @@ def foreign_prefix(path: str):
356
359
  return app if app and apps and app.lower() not in apps else None
357
360
 
358
361
 
362
+ def phase_status(path: str, app: str, phase: int):
363
+ """The Status cell of this phase's row in docs/{App}-Phases.md beside it, lower-cased; None when
364
+ there is no Phases document or no row for the phase."""
365
+ p = os.path.join(os.path.dirname(path), f"{app}-Phases.md")
366
+ if not app or not os.path.isfile(p):
367
+ return None
368
+ with open(p, encoding="utf-8") as fh:
369
+ txt = strip_noise(fh.read())
370
+ for header, rows in tables_in(txt):
371
+ low = [h.lower() for h in header]
372
+ if "phase" in low and "status" in low:
373
+ ip, ist = low.index("phase"), low.index("status")
374
+ for r in rows:
375
+ if len(r) > max(ip, ist) and r[ip].strip("`* ") == str(phase):
376
+ return r[ist].strip("`* ").lower()
377
+ return None
378
+
379
+
359
380
  def phase_file(app: str, phase: int, suffix: str) -> str:
360
381
  return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
361
382
 
@@ -450,6 +471,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
450
471
  rep.fail(rel, f'a phase-{phase} file needs the header row "Phase | {phase} of <m>"')
451
472
  elif os.path.exists(phases_doc):
452
473
  rep.fail(rel, f'the project has phases ({os.path.relpath(phases_doc, root)}); this file needs the header row "Phase | 1 of <m>"')
474
+ # a phase the owner kept whole past the cap: its size findings are WARN, never a gate failure (Lekhak TF-029)
475
+ kept = doc in PHASED_DOCS and bool(app) and phase_status(path, app, phase) == "kept-whole"
476
+ size_finding = rep.warn if kept else rep.fail
477
+ kept_why = f"; phase {phase} is kept-whole in {app}-Phases.md, so this is reported, not blocking" if kept else ""
453
478
 
454
479
  # 2. sections: strangers, order, presence
455
480
  present = [(norm_heading(h), h, txt) for h, txt in split_sections(clean, 2) if h is not None]
@@ -502,7 +527,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
502
527
  target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
503
528
  why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
504
529
  if total > mx:
505
- rep.fail(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}{why}). Shorten prose; never drop a screen, field or requirement to fit")
530
+ size_finding(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}{why}). Shorten prose; never drop a screen, field or requirement to fit{kept_why}")
506
531
  elif total > target:
507
532
  rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
508
533
  for key, h, txt in present:
@@ -564,7 +589,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
564
589
  # 5. document rules
565
590
  ctx = dict(doc=doc, body=body, clean=clean, nocomment=nocomment, present=present, header=header,
566
591
  size=size, kind=kind, root=root, path=path, rel=rel, entry_names=entry_names,
567
- app=app, phase=phase)
592
+ app=app, phase=phase, size_finding=size_finding, kept_why=kept_why)
568
593
  for rule in schema.rules:
569
594
  if not rule.startswith("entry-"):
570
595
  check_doc_rule(rule, ctx, rep)
@@ -653,7 +678,7 @@ def check_doc_rule(rule, c, rep):
653
678
  if dupes:
654
679
  rep.fail(rel, "duplicate requirement ids: " + ", ".join(f"BRD-{d}" for d in sorted(dupes, key=int)))
655
680
  if len(seen) > REQ_CAP[size]:
656
- rep.fail(rel, f"{len(seen)} requirements; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases (each phase its own BRD) instead of growing this one")
681
+ c["size_finding"](rel, f"{len(seen)} requirements; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases (each phase its own BRD) instead of growing this one{c['kept_why']}")
657
682
  c["brd_ids"] = seen
658
683
  req = section_text(present, "Requirements") or ""
659
684
  cur = None
@@ -986,7 +1011,7 @@ def check_doc_rule(rule, c, rep):
986
1011
  rep.fail(rel, f"phase {n} BRD range runs backwards ({x} to {y})")
987
1012
  st = r[ist].strip("`* ").lower()
988
1013
  if st not in PHASE_STATUS:
989
- rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building or done')
1014
+ rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building, done or kept-whole')
990
1015
  rows_out.append(dict(n=n, name=r[iname].strip("`* "), screens=screens, lo=lo, hi=hi, ranges=ranges, status=st))
991
1016
  nums = [p["n"] for p in rows_out]
992
1017
  if nums != list(range(1, len(nums) + 1)):
@@ -1033,10 +1058,12 @@ def check_checklist(c, rep):
1033
1058
  row_status[rid] = status.strip("`* ").lower()
1034
1059
  if not re.fullmatch(r"REQ-(UI|FN|RAG|NFR)-\d{3}", rid):
1035
1060
  rep.fail(rel, f'id "{rid}" is not REQ-UI/FN/RAG/NFR- plus three digits')
1061
+ removed = " A removed row reads N/A with % 0, and the date goes in Remarks"
1036
1062
  if status.strip("`* ").lower() not in STATUS_VALUES:
1037
- rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values')
1063
+ rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values.'
1064
+ + (removed if status.strip("`* ").lower().startswith("n/a") else ""))
1038
1065
  if pct.strip("`* ").rstrip("%").strip() not in ("0", "25", "50", "75", "100"):
1039
- rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100')
1066
+ rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100.' + removed)
1040
1067
  n = len(re.findall(r"\S+", remarks))
1041
1068
  if n > 60:
1042
1069
  rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
@@ -1046,6 +1073,10 @@ def check_checklist(c, rep):
1046
1073
  said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
1047
1074
  if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
1048
1075
  rep.fail(rel, f"{rid} Remarks says something is not present without naming the path that was tried; search tools skip .tfcore/, so read the literal path first")
1076
+ if row_status[rid] == "n/a":
1077
+ # a removed or reserved row is kept for traceability; it has no screen, mockup or BRD item to
1078
+ # point at, so the detail-entry rules do not apply to it (Lekhak TF-028)
1079
+ continue
1049
1080
  m = re.search(r"\(#([^)]+)\)", details)
1050
1081
  if not m:
1051
1082
  rep.fail(rel, f"{rid} Details cell has no link to its detail entry")
@@ -1054,7 +1085,7 @@ def check_checklist(c, rep):
1054
1085
  if not ids:
1055
1086
  rep.fail(rel, "the Requirements Status table has no REQ- rows")
1056
1087
  if len(ids) > REQ_CAP[size]:
1057
- rep.fail(rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist")
1088
+ c["size_finding"](rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist{c['kept_why']}")
1058
1089
  dupes = sorted({i for i in ids if ids.count(i) > 1})
1059
1090
  if dupes:
1060
1091
  rep.fail(rel, "duplicate rows: " + ", ".join(dupes))
@@ -1076,6 +1107,9 @@ def check_checklist(c, rep):
1076
1107
  start = pos + own.end()
1077
1108
  nxt = re.search(r"\n\s*(?:[-*]\s*)?<a id=|\n## |\n### ", body[start:]) # an entry is a list item: the dash precedes the anchor (miss 21)
1078
1109
  entry = body[pos: start + nxt.start()] if nxt else body[pos:]
1110
+ if row_status.get(rid) == "n/a":
1111
+ brd_refs.update(re.findall(r"BRD-(\d+)", entry)) # a retired BRD item still has its row
1112
+ continue
1079
1113
  acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
1080
1114
  if len(acc) != 1:
1081
1115
  rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
@@ -1100,7 +1134,10 @@ def check_checklist(c, rep):
1100
1134
  brd_refs.update(refs)
1101
1135
  if rid.startswith("REQ-UI"):
1102
1136
  links = links_to(entry, "mockups")
1103
- if not links:
1137
+ # a library or docs project (metrics.project_type, or kind: library) has no screens and no
1138
+ # docs/mockups/: its UI rows map to components, so a missing link is not a finding there; a
1139
+ # link it does carry must still resolve (TrBlazeUI TF-003)
1140
+ if not links and c.get("kind") == "app" and read_core_config(root).get("project_type", "app").lower() not in ("library", "docs"):
1104
1141
  rep.fail(rel, f"{rid} is a UI row without a mockup link")
1105
1142
  for l in sorted(links):
1106
1143
  if not resolve(root, path, l):
@@ -1275,14 +1312,18 @@ NAV_ITEM = re.compile(r"""<(div|li|span|button)\b[^>]*\bclass\s*=\s*["'][^"']*(?
1275
1312
 
1276
1313
 
1277
1314
  def _mock_target(mock_dir: str, t: str):
1278
- """Resolve a link the way a browser opening docs/mockups/<file> would. Returns the file
1279
- name when it lands on an existing file inside the mockup folder, else None."""
1315
+ """Resolve a link the way a browser opening docs/mockups/<file> would. Returns the path
1316
+ relative to the mockup folder when it lands on an existing file inside it, at any depth
1317
+ (Lekhak TF-008: index.html linking to admin/connection-settings.html opens, and was
1318
+ failed), else None. A leading slash, or a path that leaves docs/mockups/, is still None."""
1280
1319
  if re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I) or t.startswith("/"):
1281
1320
  return None
1321
+ t = re.split(r"[?#]", t, maxsplit=1)[0]
1322
+ base = os.path.normpath(mock_dir)
1282
1323
  p = os.path.normpath(os.path.join(mock_dir, t))
1283
- if os.path.dirname(p) != os.path.normpath(mock_dir) or not os.path.isfile(p):
1324
+ if not (p == base or p.startswith(base + os.sep)) or not os.path.isfile(p):
1284
1325
  return None
1285
- return os.path.basename(p)
1326
+ return os.path.relpath(p, base).replace(os.sep, "/")
1286
1327
 
1287
1328
 
1288
1329
  def check_mockups(root: str, rep: Report):
@@ -1315,7 +1356,8 @@ def check_mockups(root: str, rep: Report):
1315
1356
  continue
1316
1357
  name = _mock_target(mock_dir, t)
1317
1358
  if name is None:
1318
- hint = "write the file name alone, not a folder or a leading slash" if ("/" in t) else "the file does not exist"
1359
+ hint = ("a leading slash opens from the disk's root; write the path from docs/mockups/" if t.startswith("/")
1360
+ else "the file does not exist inside docs/mockups/")
1319
1361
  rep.fail(rel, f'links to "{t}", which does not open from docs/mockups/ ({hint})')
1320
1362
  else:
1321
1363
  targets.append(name)
@@ -1420,6 +1462,7 @@ def main(argv=None):
1420
1462
  if a.app:
1421
1463
  root = root or os.getcwd()
1422
1464
  files += app_files(root, a.app)
1465
+ files = list(dict.fromkeys(os.path.abspath(f) for f in files)) # a file given and listed by --app once
1423
1466
  if not files:
1424
1467
  print(f"tf-doc-check: no documents for app {a.app} under {root}", file=sys.stderr)
1425
1468
  return 2
@@ -1452,9 +1495,16 @@ def main(argv=None):
1452
1495
  base = json.load(fh) or {}
1453
1496
  except Exception:
1454
1497
  base = {}
1455
- for f in files:
1456
- r = os.path.relpath(f, root)
1457
- base[r] = sorted({l.split(": ", 1)[1] for l in rep.lines if l.startswith("FAIL ") and l.split(": ", 1)[0].endswith(" " + r)})
1498
+ # every finding, under whatever it names: the mockup-folder and cross-document checks name
1499
+ # docs/mockups/index.html or another document, not one of the files given, and were never
1500
+ # recorded, so ~60 old findings read as new on every *amend-docs (Lekhak TF-023)
1501
+ new = {os.path.relpath(f, root): set() for f in files}
1502
+ for l in rep.lines:
1503
+ if l.startswith("FAIL ") and ": " in l:
1504
+ head, msg = l.split(": ", 1)
1505
+ new.setdefault(head[len("FAIL "):], set()).add(msg)
1506
+ for r, msgs in new.items():
1507
+ base[r] = sorted(msgs)
1458
1508
  os.makedirs(os.path.dirname(base_path), exist_ok=True)
1459
1509
  with open(base_path, "w", encoding="utf-8") as fh:
1460
1510
  json.dump(base, fh, indent=1)
@@ -1468,11 +1518,23 @@ def main(argv=None):
1468
1518
  with open(base_path, encoding="utf-8") as fh:
1469
1519
  base = json.load(fh) or {}
1470
1520
  relined = []
1521
+ # A size finding carries its count ("20,677 words; the Small maximum is 8,000"), so any
1522
+ # edit to an over-limit document, a cut included, changed the text and read as new
1523
+ # (Lekhak TF-023). The same finding with a count no larger than the old one is old;
1524
+ # a document that grew past its maximum still FAILs.
1525
+ def _shape(m):
1526
+ return re.sub(r"\d[\d,]*", "#", m)
1527
+ def _first(m):
1528
+ n = re.search(r"\d[\d,]*", m)
1529
+ return int(n.group(0).replace(",", "")) if n else None
1471
1530
  for l in rep.lines:
1472
1531
  if l.startswith("FAIL "):
1473
1532
  head, msg = l.split(": ", 1)
1474
1533
  r = head[len("FAIL "):]
1475
- if msg in set(base.get(r, [])):
1534
+ was = base.get(r, [])
1535
+ same = msg in set(was) or (re.match(r"^\d[\d,]* (words|lines|rows|requirements|KB|screens)", msg) and any(
1536
+ _shape(o) == _shape(msg) and _first(o) is not None and _first(msg) <= _first(o) for o in was))
1537
+ if same:
1476
1538
  relined.append("OLD " + l[5:])
1477
1539
  rep.fails -= 1
1478
1540
  old += 1
@@ -7,7 +7,8 @@
7
7
  # docs/Lekhak-UsageGuide.md and fails when the REQ-NFR-042 paragraph is gone. *amend-docs removed that
8
8
  # paragraph, ran no test, closed green, and only CI noticed. This script looks for test source files
9
9
  # (a path containing "test") that name one of the documents, by file name or by name without ".md".
10
- # When one does, it runs the unit tests through tf-build.sh test. Prints ONE line:
10
+ # When one does, it runs the test projects holding those files, each on its own through tf-build.sh
11
+ # test (TechieRag TF-004), or the solution's tests one project at a time. Prints ONE line:
11
12
  # NONE no test reads <docs>; nothing to run exit 0
12
13
  # PASS <n> test file(s) read <docs> (<files>); the unit tests pass — <build line> exit 0
13
14
  # FAIL <n> test file(s) read <docs> (<files>); the unit tests fail — <build line> exit 1
@@ -20,7 +21,7 @@ DOCS=(); TARGET=""
20
21
  while [[ $# -gt 0 ]]; do
21
22
  case "$1" in
22
23
  --target) TARGET="${2:-}"; shift 2 ;;
23
- -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
24
+ -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
24
25
  *) DOCS+=("$1"); shift ;;
25
26
  esac
26
27
  done
@@ -71,7 +72,43 @@ if [[ -z "$TARGET" && -z "$ROOTSLN" ]]; then
71
72
  fi
72
73
  exit 2
73
74
  fi
74
- LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
75
+ # Only the test projects that hold a reader, each on its own (TechieRag TF-004): the whole solution at
76
+ # once let the local-model tests take the memory, and TechieRag.Tests failed with "Cannot allocate
77
+ # memory" on a test that passes alone. A reader in no .NET test project (a browser spec) falls back to
78
+ # the solution run, which tf-build.sh also splits into one test project at a time.
79
+ OWNERS="$(TF_READERS="$READERS" python3 - <<'PY'
80
+ import glob, os, sys
81
+ out = []
82
+ for r in os.environ.get("TF_READERS", "").split("\n"):
83
+ if not r.strip():
84
+ continue
85
+ d = os.path.dirname(r)
86
+ while d and d not in (".", "/"):
87
+ projs = glob.glob(os.path.join(d, "*.csproj"))
88
+ if projs:
89
+ p = projs[0]
90
+ if p not in out:
91
+ out.append(p)
92
+ break
93
+ d = os.path.dirname(d)
94
+ print("\n".join(out))
95
+ PY
96
+ )"
97
+ if [[ -n "$OWNERS" && -z "$TARGET" ]]; then
98
+ fails=(); notrun=(); passed=0; vl=()
99
+ while IFS= read -r p; do
100
+ [[ -z "$p" ]] && continue
101
+ v="$(bash "$HERE/tf-build.sh" test "$p" 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
102
+ vl+=("$(basename "$p" .csproj): ${v:-no verdict}")
103
+ case "$v" in PASS*) passed=$((passed+1)) ;; FAIL*) fails+=("$(basename "$p" .csproj)") ;; *) notrun+=("$(basename "$p" .csproj)") ;; esac
104
+ done <<<"$OWNERS"
105
+ k="$(grep -c . <<<"$OWNERS")"
106
+ if [[ ${#fails[@]} -gt 0 ]]; then LINE="FAIL ${fails[*]} failed ($k test project(s) holding a reader, each run on its own) — ${vl[*]}"
107
+ elif [[ ${#notrun[@]} -gt 0 ]]; then LINE="NOT-RUN ${notrun[*]} could not run — ${vl[*]}"
108
+ else LINE="PASS $k test project(s) holding a reader, each run on its own — ${vl[*]}"; fi
109
+ else
110
+ LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
111
+ fi
75
112
  case "$LINE" in
76
113
  PASS*) echo "PASS $N test file(s) read ${DOCS[*]} ($LIST); the unit tests pass — $LINE"; exit 0 ;;
77
114
  FAIL*) echo "FAIL $N test file(s) read ${DOCS[*]} ($LIST); the unit tests fail — $LINE"; exit 1 ;;