@techierathore/techieflow 1.0.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 (151) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +97 -0
  2. package/.claude/commands/TechieFlow/agents/architect.md +84 -0
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +217 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +72 -0
  5. package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +138 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +98 -0
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +78 -0
  8. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +77 -0
  9. package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +117 -0
  10. package/.claude/commands/TechieFlow/tasks/amend-docs.md +149 -0
  11. package/.claude/commands/TechieFlow/tasks/author-brd.md +79 -0
  12. package/.claude/commands/TechieFlow/tasks/build-phase.md +231 -0
  13. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +278 -0
  14. package/.claude/commands/TechieFlow/tasks/create-doc.md +101 -0
  15. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +507 -0
  16. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +224 -0
  17. package/.claude/commands/TechieFlow/tasks/devguide.md +224 -0
  18. package/.claude/commands/TechieFlow/tasks/document-project.md +343 -0
  19. package/.claude/commands/TechieFlow/tasks/execute-checklist.md +86 -0
  20. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +136 -0
  21. package/.claude/commands/TechieFlow/tasks/fix-issues.md +134 -0
  22. package/.claude/commands/TechieFlow/tasks/generate-html.md +115 -0
  23. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +126 -0
  24. package/.claude/commands/TechieFlow/tasks/index-docs.md +173 -0
  25. package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +75 -0
  26. package/.claude/commands/TechieFlow/tasks/log-miss.md +160 -0
  27. package/.claude/commands/TechieFlow/tasks/metrics-report.md +143 -0
  28. package/.claude/commands/TechieFlow/tasks/mockups.md +122 -0
  29. package/.claude/commands/TechieFlow/tasks/productguide.md +105 -0
  30. package/.claude/commands/TechieFlow/tasks/refresh-status.md +135 -0
  31. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +117 -0
  32. package/.claude/commands/TechieFlow/tasks/shard-doc.md +185 -0
  33. package/.claude/commands/TechieFlow/tasks/split-brd.md +131 -0
  34. package/.claude/commands/TechieFlow/tasks/triage-issues.md +157 -0
  35. package/.claude/commands/TechieFlow/tasks/verify-phase.md +579 -0
  36. package/.claude/commands/generate-html.md +124 -0
  37. package/.codex/config.toml +46 -0
  38. package/.codex/hooks.json +63 -0
  39. package/.codex/rules/techieflow.rules +8 -0
  40. package/.opencode/command/generate-html.md +129 -0
  41. package/.opencode/plugin/techieflow.js +350 -0
  42. package/.tfcore/TOKEN-GUIDE.md +59 -0
  43. package/.tfcore/agents/analyst.md +97 -0
  44. package/.tfcore/agents/architect.md +84 -0
  45. package/.tfcore/agents/flow-master.md +217 -0
  46. package/.tfcore/agents/verifier.md +72 -0
  47. package/.tfcore/checklists/architect-checklist.md +438 -0
  48. package/.tfcore/core-config.yaml +47 -0
  49. package/.tfcore/data/brainstorming-techniques.md +36 -0
  50. package/.tfcore/data/elicitation-methods.md +154 -0
  51. package/.tfcore/data/techieflow-kb.md +807 -0
  52. package/.tfcore/data/technical-preferences.md +3 -0
  53. package/.tfcore/enhanced-ide-development-workflow.md +248 -0
  54. package/.tfcore/hooks/block-git.sh +301 -0
  55. package/.tfcore/hooks/codex-adapter.py +248 -0
  56. package/.tfcore/hooks/guard-artifacts.sh +101 -0
  57. package/.tfcore/hooks/guard-status-html.sh +102 -0
  58. package/.tfcore/hooks/guard-status.sh +124 -0
  59. package/.tfcore/hooks/guard-verify.sh +108 -0
  60. package/.tfcore/hooks/metrics-session.sh +129 -0
  61. package/.tfcore/hooks/session-pointer.sh +52 -0
  62. package/.tfcore/hooks/sweep-artifacts.sh +198 -0
  63. package/.tfcore/install-manifest.yaml +231 -0
  64. package/.tfcore/routing.yaml +105 -0
  65. package/.tfcore/tasks/_metrics-emit-gate.md +138 -0
  66. package/.tfcore/tasks/_smoke-test-policy.md +98 -0
  67. package/.tfcore/tasks/_status-update-gate.md +78 -0
  68. package/.tfcore/tasks/_yolo-mode.md +77 -0
  69. package/.tfcore/tasks/advanced-elicitation.md +117 -0
  70. package/.tfcore/tasks/amend-docs.md +149 -0
  71. package/.tfcore/tasks/author-brd.md +79 -0
  72. package/.tfcore/tasks/build-phase.md +231 -0
  73. package/.tfcore/tasks/create-deep-research-prompt.md +278 -0
  74. package/.tfcore/tasks/create-doc.md +101 -0
  75. package/.tfcore/tasks/day1-brownfield.md +507 -0
  76. package/.tfcore/tasks/day1-greenfield.md +224 -0
  77. package/.tfcore/tasks/devguide.md +224 -0
  78. package/.tfcore/tasks/document-project.md +343 -0
  79. package/.tfcore/tasks/execute-checklist.md +86 -0
  80. package/.tfcore/tasks/facilitate-brainstorming-session.md +136 -0
  81. package/.tfcore/tasks/fix-issues.md +134 -0
  82. package/.tfcore/tasks/generate-html.md +115 -0
  83. package/.tfcore/tasks/handoff-phase.md +126 -0
  84. package/.tfcore/tasks/index-docs.md +173 -0
  85. package/.tfcore/tasks/kb-mode-interaction.md +75 -0
  86. package/.tfcore/tasks/log-miss.md +160 -0
  87. package/.tfcore/tasks/metrics-report.md +143 -0
  88. package/.tfcore/tasks/mockups.md +122 -0
  89. package/.tfcore/tasks/productguide.md +105 -0
  90. package/.tfcore/tasks/refresh-status.md +135 -0
  91. package/.tfcore/tasks/render-workflow-docs.md +117 -0
  92. package/.tfcore/tasks/shard-doc.md +185 -0
  93. package/.tfcore/tasks/split-brd.md +131 -0
  94. package/.tfcore/tasks/triage-issues.md +157 -0
  95. package/.tfcore/tasks/verify-phase.md +579 -0
  96. package/.tfcore/telemetry/SCHEMA.md +678 -0
  97. package/.tfcore/telemetry/install-metrics.sh +390 -0
  98. package/.tfcore/telemetry/pre-commit +94 -0
  99. package/.tfcore/telemetry/tf-metrics.sh +1415 -0
  100. package/.tfcore/templates/architecture-tmpl.yaml +650 -0
  101. package/.tfcore/templates/brainstorming-output-tmpl.yaml +156 -0
  102. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +476 -0
  103. package/.tfcore/templates/competitor-analysis-tmpl.yaml +306 -0
  104. package/.tfcore/templates/front-end-architecture-tmpl.yaml +218 -0
  105. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +823 -0
  106. package/.tfcore/templates/market-research-tmpl.yaml +252 -0
  107. package/.tfcore/templates/project-brief-tmpl.yaml +221 -0
  108. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +61 -0
  109. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +127 -0
  110. package/.tfcore/templates/v4custom/app-brd-tmpl.md +204 -0
  111. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +90 -0
  112. package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +33 -0
  113. package/.tfcore/templates/v4custom/app-devguide-tmpl.md +167 -0
  114. package/.tfcore/templates/v4custom/app-editorconfig-tmpl.editorconfig +44 -0
  115. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +31 -0
  116. package/.tfcore/templates/v4custom/app-productguide-tmpl.md +54 -0
  117. package/.tfcore/templates/v4custom/app-project-status-tmpl.md +98 -0
  118. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +48 -0
  119. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +67 -0
  120. package/.tfcore/templates/v4custom/build-invocation-ladder.md +190 -0
  121. package/.tfcore/templates/v4custom/html-render-shell.md +502 -0
  122. package/.tfcore/templates/v4custom/metrics-report-template.md +273 -0
  123. package/.tfcore/templates/v4custom/opencode-operating-contract.md +56 -0
  124. package/.tfcore/user-guide.md +633 -0
  125. package/.tfcore/utils/techieflow-doc-template.md +325 -0
  126. package/.tfcore/utils/tf-assets.sh +310 -0
  127. package/.tfcore/utils/tf-codex-bind.py +196 -0
  128. package/.tfcore/utils/tf-codex-telemetry.py +60 -0
  129. package/.tfcore/utils/tf-emit.sh +1075 -0
  130. package/.tfcore/utils/tf-gitignore-audit.sh +281 -0
  131. package/.tfcore/utils/tf-goal.sh +421 -0
  132. package/.tfcore/utils/tf-harness.sh +160 -0
  133. package/.tfcore/utils/tf-mockup-parity.mjs +555 -0
  134. package/.tfcore/utils/tf-mockup-parity.sh +65 -0
  135. package/.tfcore/utils/tf-opencode-audit.py +126 -0
  136. package/.tfcore/utils/tf-perf.sh +241 -0
  137. package/.tfcore/utils/tf-render-html.py +715 -0
  138. package/.tfcore/utils/tf-render-html.sh +28 -0
  139. package/.tfcore/utils/tf-routing-bind.sh +216 -0
  140. package/.tfcore/utils/tf-routing.sh +247 -0
  141. package/.tfcore/utils/tf-yolo.sh +119 -0
  142. package/.tfcore/working-in-the-brownfield.md +606 -0
  143. package/LICENSE +201 -0
  144. package/README.md +1212 -0
  145. package/WORKFLOW.html +1986 -0
  146. package/docs/TechieFlow-Installation.md +299 -0
  147. package/opencode.jsonc +929 -0
  148. package/package.json +60 -0
  149. package/scripts/install.mjs +992 -0
  150. package/scripts/test-install.mjs +505 -0
  151. package/scripts/validate.mjs +155 -0
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """Audit an app's ROOT opencode.jsonc against the framework template.
3
+
4
+ Prints ONE verdict line on stdout:
5
+ current byte-identical to the template — nothing to do
6
+ refresh carries no project-only content — safe to replace wholesale
7
+ project|<k1,k2,...> carries project-only keys — must be preserved, keys listed
8
+ unknown|<reason> could not be parsed — caller falls back to preserving
9
+
10
+ Also prints, after the verdict, zero or more DIAG lines the caller echoes:
11
+ DIAG|dead-ref|<ref> a {file:...} reference that does not resolve in the app
12
+ DIAG|bare-bash-allow|<where> a genuine agent-level "bash": "allow" string value
13
+
14
+ "Project-only" means a key the framework template does not define, excluding the
15
+ framework's own `techieflow:` command namespace (retired framework commands are
16
+ framework litter, not project content, and must NOT block a refresh).
17
+ """
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+
23
+
24
+ def strip_jsonc(text):
25
+ """Remove // line comments and trailing commas. Quote-aware: a // inside a
26
+ string literal (e.g. a URL) is left alone."""
27
+ out, in_str, esc, i = [], False, False, 0
28
+ while i < len(text):
29
+ c = text[i]
30
+ if in_str:
31
+ out.append(c)
32
+ if esc:
33
+ esc = False
34
+ elif c == "\\":
35
+ esc = True
36
+ elif c == '"':
37
+ in_str = False
38
+ i += 1
39
+ continue
40
+ if c == '"':
41
+ in_str = True
42
+ out.append(c)
43
+ i += 1
44
+ continue
45
+ if c == "/" and i + 1 < len(text) and text[i + 1] == "/":
46
+ while i < len(text) and text[i] != "\n":
47
+ i += 1
48
+ continue
49
+ if c == "/" and i + 1 < len(text) and text[i + 1] == "*":
50
+ j = text.find("*/", i + 2)
51
+ i = len(text) if j < 0 else j + 2
52
+ continue
53
+ out.append(c)
54
+ i += 1
55
+ s = "".join(out)
56
+ return re.sub(r",(\s*[}\]])", r"\1", s)
57
+
58
+
59
+ def load(path):
60
+ raw = open(path, encoding="utf-8").read()
61
+ return json.loads(strip_jsonc(raw)), raw
62
+
63
+
64
+ def main():
65
+ tmpl_path, app_path = sys.argv[1], sys.argv[2]
66
+ app_dir = os.path.dirname(os.path.abspath(app_path)) or "."
67
+
68
+ try:
69
+ tmpl, tmpl_raw = load(tmpl_path)
70
+ except Exception as e:
71
+ print("unknown|template unreadable: %s" % e)
72
+ return
73
+ try:
74
+ app, app_raw = load(app_path)
75
+ except Exception as e:
76
+ print("unknown|app config does not parse: %s" % e)
77
+ return
78
+
79
+ diags = []
80
+
81
+ # --- genuine bare agent-level "bash": "allow" -------------------------
82
+ # Checked on the PARSED tree, never on raw text: the template's own comment
83
+ # contains the literal string `"bash": "allow"`, so a grep over the raw file
84
+ # matches its own documentation and fires on every correct config.
85
+ for name, block in (app.get("agent") or {}).items():
86
+ if not isinstance(block, dict):
87
+ continue
88
+ perm = block.get("permission")
89
+ if isinstance(perm, dict) and perm.get("bash") == "allow":
90
+ diags.append("DIAG|bare-bash-allow|agent.%s" % name)
91
+ root_perm = app.get("permission")
92
+ if isinstance(root_perm, dict) and root_perm.get("bash") == "allow":
93
+ diags.append("DIAG|bare-bash-allow|permission")
94
+
95
+ # --- dead {file:...} refs (hard-fails OpenCode's whole config load) ----
96
+ for ref in sorted(set(re.findall(r"\{file:([^}]*)\}", app_raw))):
97
+ rel = ref[2:] if ref.startswith("./") else ref
98
+ if not os.path.isfile(os.path.join(app_dir, rel)):
99
+ diags.append("DIAG|dead-ref|%s" % ref)
100
+
101
+ # --- project-only content ---------------------------------------------
102
+ project = []
103
+ for k in app:
104
+ if k not in tmpl:
105
+ project.append(k)
106
+ for k in (app.get("agent") or {}):
107
+ if k not in (tmpl.get("agent") or {}):
108
+ project.append("agent.%s" % k)
109
+ for k in (app.get("command") or {}):
110
+ if k in (tmpl.get("command") or {}):
111
+ continue
112
+ if k.startswith("techieflow:"):
113
+ continue # retired FRAMEWORK command — litter, not project content
114
+ project.append("command.%s" % k)
115
+
116
+ if app_raw == tmpl_raw:
117
+ print("current")
118
+ elif project:
119
+ print("project|%s" % ",".join(sorted(project)))
120
+ else:
121
+ print("refresh")
122
+ for d in diags:
123
+ print(d)
124
+
125
+
126
+ main()
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env bash
2
+ # TechieFlow — performance measurement harness. Feeds verify-phase §4c (the perf gate).
3
+ #
4
+ # bash .tfcore/utils/tf-perf.sh --base http://localhost:5099 \
5
+ # --paths "/,/posts,/admin/dashboard" --levels 1,50 --build-config Release
6
+ #
7
+ # WHAT IT IS: a deterministic TTFB / full-load sampler over a declared path set at
8
+ # declared concurrency levels. It prints ONE JSON object and measures nothing else.
9
+ #
10
+ # WHAT IT IS NOT: a verdict. It never decides pass/fail, never reads a budget, never
11
+ # touches a checklist, and never emits telemetry. verify-phase §4c compares its numbers
12
+ # against the budget declared in the REQ; this script only produces the numbers. That
13
+ # separation is deliberate — a measurement tool that also judges is a tool you cannot
14
+ # audit when a number looks wrong.
15
+ #
16
+ # MEASUREMENT DEFINITIONS (stated because a perf number is meaningless without them):
17
+ # ttfb_ms wall time from issuing the request to the FIRST BYTE of the response body
18
+ # (headers already received). This is what a page-load budget is really about.
19
+ # load_ms wall time until the response body is fully read.
20
+ # Both EXCLUDE connection setup of the warm-up requests, which are discarded: ASP.NET
21
+ # JITs on first hit and a cold first request is not what a user experiences.
22
+ #
23
+ # HONESTY RULES BAKED IN:
24
+ # - p95 is the headline, never the mean. One 4-second stall matters; an average hides it.
25
+ # - Every sample count is reported. A p95 over 4 samples is noise and says so (`"weak":true`).
26
+ # - build_config is stamped, never guessed. A Debug-build number is not evidence and
27
+ # verify-phase §4c refuses to fail a REQ on one.
28
+ # - machine fingerprint is printed for the HUMAN so two runs are never silently compared
29
+ # across hosts. It is NOT telemetry and must never be emitted to gates.jsonl.
30
+ #
31
+ # Exit codes: 0 measured · 2 app unreachable (nothing measured) · 3 bad arguments
32
+ # · 4 every request redirected (auth wall — nothing measured).
33
+ # Requires python3 only (the framework already depends on it for the guard hooks).
34
+
35
+ set -uo pipefail
36
+
37
+ BASE=""; PATHS="/"; LEVELS="1"; REQS=5; WARMUP=3; TIMEOUT=30; BUILD="unknown"; LABEL=""; OUT=""
38
+ HDRS="" # newline-separated "K: V" pairs (--header, repeatable; --cookie is sugar)
39
+ while [[ $# -gt 0 ]]; do
40
+ case "$1" in
41
+ --base) BASE="${2:-}"; shift 2 ;;
42
+ --paths) PATHS="${2:-}"; shift 2 ;;
43
+ --levels) LEVELS="${2:-}"; shift 2 ;;
44
+ --requests) REQS="${2:-}"; shift 2 ;;
45
+ --warmup) WARMUP="${2:-}"; shift 2 ;;
46
+ --timeout) TIMEOUT="${2:-}"; shift 2 ;;
47
+ --build-config) BUILD="${2:-}"; shift 2 ;;
48
+ --label) LABEL="${2:-}"; shift 2 ;;
49
+ --json-out) OUT="${2:-}"; shift 2 ;;
50
+ --header) HDRS="${HDRS}${2:-}"$'\n'; shift 2 ;;
51
+ --cookie) HDRS="${HDRS}Cookie: ${2:-}"$'\n'; shift 2 ;;
52
+ -h|--help) sed -n '2,33p' "$0"; exit 0 ;;
53
+ *) echo "unknown argument: $1" >&2; exit 3 ;;
54
+ esac
55
+ done
56
+
57
+ [[ -n "$BASE" ]] || { printf '%s\n' \
58
+ "usage: tf-perf.sh --base URL [--paths /,/a] [--levels 1,50] [--build-config Release]" \
59
+ " [--cookie 'k=v; k2=v2'] [--header 'Authorization: Bearer ...']" >&2; exit 3; }
60
+ command -v python3 >/dev/null 2>&1 || { echo '{"status":"no-python3"}'; exit 3; }
61
+ case "$BUILD" in Release|Debug|unknown) ;; *) echo "--build-config must be Release|Debug|unknown" >&2; exit 3 ;; esac
62
+
63
+ TF_BASE="$BASE" TF_PATHS="$PATHS" TF_LEVELS="$LEVELS" TF_REQS="$REQS" TF_WARMUP="$WARMUP" \
64
+ TF_TIMEOUT="$TIMEOUT" TF_BUILD="$BUILD" TF_LABEL="$LABEL" TF_OUT="$OUT" TF_HEADERS="$HDRS" python3 - <<'PY'
65
+ import http.client, json, os, platform, ssl, statistics, sys, threading, time
66
+ from urllib.parse import urlparse
67
+
68
+ BASE = os.environ["TF_BASE"].rstrip("/")
69
+ PATHS = [p.strip() for p in os.environ["TF_PATHS"].split(",") if p.strip()]
70
+ LEVELS = [int(x) for x in os.environ["TF_LEVELS"].split(",") if x.strip()]
71
+ REQS = int(os.environ["TF_REQS"]); WARMUP = int(os.environ["TF_WARMUP"])
72
+ TIMEOUT = float(os.environ["TF_TIMEOUT"]); BUILD = os.environ["TF_BUILD"]
73
+ LABEL = os.environ["TF_LABEL"] or None; OUT = os.environ["TF_OUT"]
74
+
75
+ # Extra request headers (--header / --cookie). Without these the harness can only
76
+ # ever measure the anonymous surface: on a login-gated app every route answers 302
77
+ # to /login and the "latency" is the speed of being turned away (TfLens TF-002).
78
+ EXTRA = {}
79
+ for _h in os.environ.get("TF_HEADERS", "").split("\n"):
80
+ if ":" in _h:
81
+ _k, _v = _h.split(":", 1)
82
+ EXTRA[_k.strip()] = _v.strip()
83
+
84
+ u = urlparse(BASE)
85
+ HOST, PORT, HTTPS = u.hostname, u.port or (443 if u.scheme == "https" else 80), u.scheme == "https"
86
+ CTX = ssl._create_unverified_context() if HTTPS else None # dev certs: verified elsewhere, not here
87
+
88
+
89
+ def hit(path):
90
+ """One request. Returns (ttfb_ms, load_ms, status) or (None, None, error-string)."""
91
+ conn = None
92
+ try:
93
+ conn = (http.client.HTTPSConnection(HOST, PORT, timeout=TIMEOUT, context=CTX) if HTTPS
94
+ else http.client.HTTPConnection(HOST, PORT, timeout=TIMEOUT))
95
+ t0 = time.perf_counter()
96
+ _hdrs = {"Accept": "text/html,*/*", "Connection": "close"}
97
+ _hdrs.update(EXTRA)
98
+ conn.request("GET", path, headers=_hdrs)
99
+ resp = conn.getresponse()
100
+ resp.read(1) # <- first body byte defines TTFB
101
+ ttfb = (time.perf_counter() - t0) * 1000.0
102
+ resp.read()
103
+ return ttfb, (time.perf_counter() - t0) * 1000.0, resp.status
104
+ except Exception as e:
105
+ return None, None, type(e).__name__
106
+ finally:
107
+ if conn:
108
+ try: conn.close()
109
+ except Exception: pass
110
+
111
+
112
+ # --- reachability, before anything is measured -------------------------------
113
+ probe = None
114
+ for _ in range(3):
115
+ probe = hit(PATHS[0])
116
+ if probe[0] is not None:
117
+ break
118
+ time.sleep(1)
119
+ if probe[0] is None:
120
+ print(json.dumps({"status": "unreachable", "base": BASE, "path": PATHS[0], "error": probe[2]}))
121
+ sys.exit(2)
122
+
123
+ # --- warm-up: discarded on purpose (JIT / first-request compilation) ---------
124
+ for p in PATHS:
125
+ for _ in range(WARMUP):
126
+ hit(p)
127
+
128
+
129
+ def pct(xs, q):
130
+ if not xs: return None
131
+ xs = sorted(xs)
132
+ if len(xs) == 1: return round(xs[0], 1)
133
+ k = (len(xs) - 1) * q
134
+ lo, hi = int(k), min(int(k) + 1, len(xs) - 1)
135
+ return round(xs[lo] + (xs[hi] - xs[lo]) * (k - lo), 1)
136
+
137
+
138
+ def summarize(xs):
139
+ return {"p50": pct(xs, 0.50), "p95": pct(xs, 0.95), "max": round(max(xs), 1) if xs else None,
140
+ "mean": round(statistics.fmean(xs), 1) if xs else None}
141
+
142
+
143
+ results = []
144
+ for conc in LEVELS:
145
+ samples, lock = {p: {"ttfb": [], "load": []} for p in PATHS}, threading.Lock()
146
+ errors, non200, redirects = [], [], []
147
+
148
+ def worker():
149
+ for _ in range(REQS):
150
+ for p in PATHS:
151
+ ttfb, load, st = hit(p)
152
+ with lock:
153
+ if ttfb is None:
154
+ errors.append(st)
155
+ else:
156
+ if st != 200: non200.append((p, st))
157
+ if 300 <= st < 400: redirects.append((p, st))
158
+ samples[p]["ttfb"].append(ttfb); samples[p]["load"].append(load)
159
+
160
+ threads = [threading.Thread(target=worker, daemon=True) for _ in range(conc)]
161
+ t0 = time.perf_counter()
162
+ for t in threads: t.start()
163
+ for t in threads: t.join()
164
+ elapsed = time.perf_counter() - t0
165
+
166
+ all_ttfb = [v for p in PATHS for v in samples[p]["ttfb"]]
167
+ all_load = [v for p in PATHS for v in samples[p]["load"]]
168
+ results.append({
169
+ "concurrency": conc,
170
+ "samples": len(all_ttfb),
171
+ # A p95 needs samples behind it. Below 20 the number is indicative, not evidence,
172
+ # and verify-phase §4c must not fail a REQ on it.
173
+ "weak": len(all_ttfb) < 20,
174
+ "errors": len(errors),
175
+ # Rate, not just count — verify-phase §4c branches on it. A handful of errors
176
+ # contaminates a latency sample (do not grade); a large fraction IS the result
177
+ # (the app did not serve the declared concurrency). Only the rate separates them.
178
+ "attempted": len(all_ttfb) + len(errors),
179
+ "error_rate": (round(len(errors) / (len(all_ttfb) + len(errors)), 3)
180
+ if (len(all_ttfb) + len(errors)) else None),
181
+ "error_kinds": sorted(set(errors))[:5],
182
+ "non_200": sorted({f"{p}:{s}" for p, s in non200})[:5],
183
+ # A redirect is not a page. Surfaced as a first-class rate so neither
184
+ # verify-phase §4c nor a human has to infer it from the non_200 list.
185
+ "redirects": len(redirects),
186
+ "redirect_rate": (round(len(redirects) / len(all_ttfb), 3) if all_ttfb else None),
187
+ "wall_s": round(elapsed, 2),
188
+ "ttfb_ms": summarize(all_ttfb),
189
+ "load_ms": summarize(all_load),
190
+ "per_path": [{"path": p,
191
+ "samples": len(samples[p]["ttfb"]),
192
+ "ttfb_p95": pct(samples[p]["ttfb"], 0.95),
193
+ "load_p95": pct(samples[p]["load"], 0.95)} for p in PATHS],
194
+ })
195
+
196
+ # --- an all-redirect run is a REFUSAL, never a latency figure ----------------
197
+ # On a login-gated app every route answers 302 to /login in a few ms, and the old
198
+ # harness reported that as a page-load p95 — "the speed of being turned away at
199
+ # the door" (TfLens TF-002). The redirects were visible in non_200, but nothing
200
+ # marked the number meaningless, so the figure read as real. Refuse instead:
201
+ # a gate that quietly reports a wrong number is worse than one that declines.
202
+ _sampled = sum(l["samples"] for l in results)
203
+ _redir = sum(l["redirects"] for l in results)
204
+ if _sampled and _redir == _sampled:
205
+ print(json.dumps({
206
+ "status": "redirected",
207
+ "schema": "tf-perf/1",
208
+ "base": BASE,
209
+ "paths": PATHS,
210
+ "redirects": _redir,
211
+ "codes": sorted({c for l in results for c in l["non_200"]})[:8],
212
+ "hint": ("every request was redirected — nothing was measured. These paths "
213
+ "need a session: re-run with --cookie 'name=value' or "
214
+ "--header 'Authorization: Bearer …'. Grade the REQ "
215
+ "PERF-UNMEASURED until then (verify-phase §4c)."),
216
+ }, indent=2))
217
+ sys.exit(4)
218
+
219
+ out = {
220
+ "status": "ok",
221
+ "schema": "tf-perf/1",
222
+ "base": BASE,
223
+ "label": LABEL,
224
+ "build_config": BUILD,
225
+ "paths": PATHS,
226
+ "warmup_discarded": WARMUP,
227
+ "started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
228
+ # For the HUMAN reading the report: never compare two runs whose fingerprints differ.
229
+ # NOT telemetry — must never reach gates.jsonl (SCHEMA.md §3.3, constraint 7).
230
+ "machine": {"platform": platform.system(), "release": platform.release(),
231
+ "cpus": os.cpu_count(), "python": platform.python_version()},
232
+ "levels": results,
233
+ }
234
+ text = json.dumps(out, indent=2)
235
+ if OUT:
236
+ os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)
237
+ open(OUT, "w", encoding="utf-8").write(text + "\n")
238
+ print(text)
239
+ else:
240
+ print(text)
241
+ PY