@techierathore/techieflow 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
- package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +7 -5
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +15 -7
- package/.tfcore/agents/flow-master.md +2 -0
- package/.tfcore/agents/verifier.md +2 -2
- package/.tfcore/core-config.yaml +2 -1
- package/.tfcore/hooks/block-git.sh +25 -2
- package/.tfcore/hooks/guard-db.sh +106 -9
- package/.tfcore/hooks/guard-status-html.sh +5 -1
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- package/.tfcore/hooks/yolo-allow.sh +27 -0
- package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
- package/.tfcore/standards/coding-standards-dotnet.md +31 -3
- package/.tfcore/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_smoke-test-policy.md +7 -5
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +1 -1
- package/.tfcore/tasks/day1-brownfield.md +2 -2
- package/.tfcore/tasks/day1-greenfield.md +4 -4
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +1 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +6 -2
- package/.tfcore/telemetry/tf-metrics.sh +19 -0
- package/.tfcore/templates/stack-defaults/dotnet.md +5 -1
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
- package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-appium.mjs +179 -0
- package/.tfcore/utils/tf-brd-status.py +10 -1
- package/.tfcore/utils/tf-build-list.py +16 -1
- package/.tfcore/utils/tf-build.sh +75 -0
- package/.tfcore/utils/tf-checklist-edit.py +28 -3
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +6 -2
- package/.tfcore/utils/tf-doc-check.py +124 -21
- package/.tfcore/utils/tf-doc-tests.sh +40 -3
- package/.tfcore/utils/tf-emit.sh +40 -10
- package/.tfcore/utils/tf-fix-close.sh +13 -7
- package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +361 -28
- package/.tfcore/utils/tf-mockup-parity.sh +14 -3
- package/.tfcore/utils/tf-owner-text.py +27 -5
- package/.tfcore/utils/tf-phase.sh +24 -4
- package/.tfcore/utils/tf-render-html.py +19 -3
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-facts.py +90 -15
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +30 -4
- package/.tfcore/utils/tf-verify-boot.sh +296 -21
- package/.tfcore/utils/tf-verify-emit.sh +37 -17
- package/.tfcore/utils/tf-verify-list.py +219 -20
- package/.tfcore/utils/tf-verify-list.sh +3 -1
- package/.tfcore/utils/tf-verify-native.mjs +307 -0
- package/.tfcore/utils/tf-verify-screens.mjs +184 -20
- package/.tfcore/utils/tf-verify-screens.sh +17 -4
- package/.tfcore/utils/tf-verify-tests.sh +107 -17
- package/.tfcore/utils/tf-verify-verdict.py +118 -15
- package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
- package/.tfcore/utils/tf-verify-zoom.sh +29 -0
- package/.tfcore/utils/tf-yolo-alias.sh +84 -0
- package/.tfcore/utils/tf-yolo-perms.py +135 -0
- package/.tfcore/utils/tf-yolo-start.sh +44 -0
- package/.tfcore/utils/tf-yolo.sh +31 -3
- package/.tfcore/utils/tf_feedback.py +86 -18
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +2 -2
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +14 -3
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
|
@@ -30,6 +30,7 @@ Schema grammar (one `key: value` per line inside the comment):
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
|
|
32
32
|
import argparse
|
|
33
|
+
import glob
|
|
33
34
|
import json
|
|
34
35
|
import os
|
|
35
36
|
import re
|
|
@@ -70,7 +71,9 @@ DOC_NAME = re.compile(
|
|
|
70
71
|
r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
|
|
71
72
|
re.I)
|
|
72
73
|
PHASE_FORM = re.compile(r"^(\d+)\s+of\s+(\d+)$", re.I)
|
|
73
|
-
|
|
74
|
+
# kept-whole: a phase finished before the size cap that the owner chose to keep as one document;
|
|
75
|
+
# its cap and word maximum are reported as WARN, not FAIL (Lekhak TF-029)
|
|
76
|
+
PHASE_STATUS = {"planned", "building", "done", "kept-whole"}
|
|
74
77
|
BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re.I)
|
|
75
78
|
|
|
76
79
|
SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
|
|
@@ -96,9 +99,10 @@ NAMES_SCREEN = re.compile(r"\b(on|opens?|from|in)\b", re.I)
|
|
|
96
99
|
|
|
97
100
|
|
|
98
101
|
def acceptance_words(line: str) -> int:
|
|
99
|
-
"""Words in an acceptance line, without the "Acceptance:" label and without a perf-budget
|
|
102
|
+
"""Words in an acceptance line, without the "Acceptance:" label and without a perf-budget or
|
|
103
|
+
zoom-check tail (the markers the verifier reads)."""
|
|
100
104
|
t = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", line)
|
|
101
|
-
t = re.split(r"perf-budget:", t, flags=re.I)[0]
|
|
105
|
+
t = re.split(r"perf-budget:|zoom-check:", t, flags=re.I)[0]
|
|
102
106
|
return len(re.findall(r"\S+", t))
|
|
103
107
|
|
|
104
108
|
|
|
@@ -331,7 +335,7 @@ def read_core_config(root: str) -> dict:
|
|
|
331
335
|
if os.path.exists(p):
|
|
332
336
|
with open(p, encoding="utf-8") as fh:
|
|
333
337
|
for line in fh:
|
|
334
|
-
m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line)
|
|
338
|
+
m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line) or re.match(r"^\s+(project_type):\s*(\S+)", line)
|
|
335
339
|
if m and m.group(2) not in ("null", "~", "''", '""'):
|
|
336
340
|
out[m.group(1)] = m.group(2).strip("'\"")
|
|
337
341
|
return out
|
|
@@ -356,6 +360,24 @@ def foreign_prefix(path: str):
|
|
|
356
360
|
return app if app and apps and app.lower() not in apps else None
|
|
357
361
|
|
|
358
362
|
|
|
363
|
+
def phase_status(path: str, app: str, phase: int):
|
|
364
|
+
"""The Status cell of this phase's row in docs/{App}-Phases.md beside it, lower-cased; None when
|
|
365
|
+
there is no Phases document or no row for the phase."""
|
|
366
|
+
p = os.path.join(os.path.dirname(path), f"{app}-Phases.md")
|
|
367
|
+
if not app or not os.path.isfile(p):
|
|
368
|
+
return None
|
|
369
|
+
with open(p, encoding="utf-8") as fh:
|
|
370
|
+
txt = strip_noise(fh.read())
|
|
371
|
+
for header, rows in tables_in(txt):
|
|
372
|
+
low = [h.lower() for h in header]
|
|
373
|
+
if "phase" in low and "status" in low:
|
|
374
|
+
ip, ist = low.index("phase"), low.index("status")
|
|
375
|
+
for r in rows:
|
|
376
|
+
if len(r) > max(ip, ist) and r[ip].strip("`* ") == str(phase):
|
|
377
|
+
return r[ist].strip("`* ").lower()
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
359
381
|
def phase_file(app: str, phase: int, suffix: str) -> str:
|
|
360
382
|
return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
|
|
361
383
|
|
|
@@ -391,6 +413,17 @@ def resolve_kind(header, brd_header, cfg) -> str:
|
|
|
391
413
|
return "library" if raw.startswith("lib") else "app"
|
|
392
414
|
|
|
393
415
|
|
|
416
|
+
def signs_in_through_appmanager(root, app):
|
|
417
|
+
"""the Architecture's authentication row (Q4, Auth…, Identity) names AppManager"""
|
|
418
|
+
for f in glob.glob(os.path.join(root or ".", "docs", f"{app}-Architecture.md")):
|
|
419
|
+
for _h, rows in tables_in(open(f, encoding="utf-8", errors="replace").read()):
|
|
420
|
+
for r in rows:
|
|
421
|
+
head = " ".join(r[:2]).lower()
|
|
422
|
+
if re.search(r"\bq4\b|auth|identity|sign-in", head) and re.search(r"(?i)app ?manager", " ".join(r[1:3])):
|
|
423
|
+
return True
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
|
|
394
427
|
def section_text(present, name):
|
|
395
428
|
key = norm_heading(name)
|
|
396
429
|
return next((txt for k, _h, txt in present if key_matches(key, k)), None)
|
|
@@ -450,6 +483,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
450
483
|
rep.fail(rel, f'a phase-{phase} file needs the header row "Phase | {phase} of <m>"')
|
|
451
484
|
elif os.path.exists(phases_doc):
|
|
452
485
|
rep.fail(rel, f'the project has phases ({os.path.relpath(phases_doc, root)}); this file needs the header row "Phase | 1 of <m>"')
|
|
486
|
+
# a phase the owner kept whole past the cap: its size findings are WARN, never a gate failure (Lekhak TF-029)
|
|
487
|
+
kept = doc in PHASED_DOCS and bool(app) and phase_status(path, app, phase) == "kept-whole"
|
|
488
|
+
size_finding = rep.warn if kept else rep.fail
|
|
489
|
+
kept_why = f"; phase {phase} is kept-whole in {app}-Phases.md, so this is reported, not blocking" if kept else ""
|
|
453
490
|
|
|
454
491
|
# 2. sections: strangers, order, presence
|
|
455
492
|
present = [(norm_heading(h), h, txt) for h, txt in split_sections(clean, 2) if h is not None]
|
|
@@ -502,7 +539,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
502
539
|
target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
|
|
503
540
|
why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
|
|
504
541
|
if total > mx:
|
|
505
|
-
|
|
542
|
+
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
543
|
elif total > target:
|
|
507
544
|
rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
|
|
508
545
|
for key, h, txt in present:
|
|
@@ -564,7 +601,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
564
601
|
# 5. document rules
|
|
565
602
|
ctx = dict(doc=doc, body=body, clean=clean, nocomment=nocomment, present=present, header=header,
|
|
566
603
|
size=size, kind=kind, root=root, path=path, rel=rel, entry_names=entry_names,
|
|
567
|
-
app=app, phase=phase)
|
|
604
|
+
app=app, phase=phase, size_finding=size_finding, kept_why=kept_why)
|
|
568
605
|
for rule in schema.rules:
|
|
569
606
|
if not rule.startswith("entry-"):
|
|
570
607
|
check_doc_rule(rule, ctx, rep)
|
|
@@ -653,7 +690,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
653
690
|
if dupes:
|
|
654
691
|
rep.fail(rel, "duplicate requirement ids: " + ", ".join(f"BRD-{d}" for d in sorted(dupes, key=int)))
|
|
655
692
|
if len(seen) > REQ_CAP[size]:
|
|
656
|
-
|
|
693
|
+
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
694
|
c["brd_ids"] = seen
|
|
658
695
|
req = section_text(present, "Requirements") or ""
|
|
659
696
|
cur = None
|
|
@@ -774,6 +811,35 @@ def check_doc_rule(rule, c, rep):
|
|
|
774
811
|
if txt is not None and not has_table_with(txt, "user", "role"):
|
|
775
812
|
rep.fail(rel, 'the "Test users" section needs a table with User and Role columns')
|
|
776
813
|
|
|
814
|
+
elif rule == "appmanager-test-users":
|
|
815
|
+
# An app that signs in through AppManager has its own three test users there, registered through
|
|
816
|
+
# AppManager's register call, each password kept in the git-ignored secrets and never written in
|
|
817
|
+
# the guide; never the owner's personal account (stack answer Q4a, owner 2026-10-18).
|
|
818
|
+
txt = section_text(present, "Test users")
|
|
819
|
+
app = c.get("app")
|
|
820
|
+
if txt is None or c["kind"] != "app" or not app or app.lower() == "appmanager" or not signs_in_through_appmanager(c["root"], app):
|
|
821
|
+
return
|
|
822
|
+
users = []
|
|
823
|
+
for hdr, rows in tables_in(txt):
|
|
824
|
+
low = [h.lower() for h in hdr]
|
|
825
|
+
if not any("user" in h for h in low):
|
|
826
|
+
continue
|
|
827
|
+
pw = next((i for i, h in enumerate(low) if "password" in h), None)
|
|
828
|
+
users += [(r, r[pw] if pw is not None and pw < len(r) else "") for r in rows if "@" in " ".join(r)]
|
|
829
|
+
whole = lambda r: " ".join(r).lower()
|
|
830
|
+
want = (("owner", r"owner|account holder"), ("second user", r"second"), ("unattended-run user", r"unattended|yolo"))
|
|
831
|
+
missing = [n for n, pat in want if not any(re.search(pat, whole(r)) for r, _p in users)]
|
|
832
|
+
if missing:
|
|
833
|
+
rep.fail(rel, f'{app} signs in through AppManager: the Test users table lists no {", no ".join(missing)} '
|
|
834
|
+
f'(stack answer Q4a: owner walkthrough, second user, unattended runs, registered through AppManager\'s register call)')
|
|
835
|
+
for r, cell in users:
|
|
836
|
+
if not re.search(r"(?i)secret|\.env|credential|keychain", cell):
|
|
837
|
+
who = re.search(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)*", " ".join(r)).group(0)
|
|
838
|
+
rep.fail(rel, f'test user {who}: the Password cell names no git-ignored secret; keep the password in the project\'s '
|
|
839
|
+
f'secrets and name the key here, never the password (stack answer Q4a)')
|
|
840
|
+
if users and not re.search(r"(?i)regist", txt):
|
|
841
|
+
rep.fail(rel, "the Test users section does not say the users were registered through AppManager's register call (stack answer Q4a)")
|
|
842
|
+
|
|
777
843
|
elif rule == "next-command-blocks":
|
|
778
844
|
seg = re.search(r"(?ms)^## Next command to run\s*\n(.*?)(?=^## |\Z)", nocomment)
|
|
779
845
|
if not seg:
|
|
@@ -986,7 +1052,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
986
1052
|
rep.fail(rel, f"phase {n} BRD range runs backwards ({x} to {y})")
|
|
987
1053
|
st = r[ist].strip("`* ").lower()
|
|
988
1054
|
if st not in PHASE_STATUS:
|
|
989
|
-
rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building or
|
|
1055
|
+
rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building, done or kept-whole')
|
|
990
1056
|
rows_out.append(dict(n=n, name=r[iname].strip("`* "), screens=screens, lo=lo, hi=hi, ranges=ranges, status=st))
|
|
991
1057
|
nums = [p["n"] for p in rows_out]
|
|
992
1058
|
if nums != list(range(1, len(nums) + 1)):
|
|
@@ -1033,10 +1099,12 @@ def check_checklist(c, rep):
|
|
|
1033
1099
|
row_status[rid] = status.strip("`* ").lower()
|
|
1034
1100
|
if not re.fullmatch(r"REQ-(UI|FN|RAG|NFR)-\d{3}", rid):
|
|
1035
1101
|
rep.fail(rel, f'id "{rid}" is not REQ-UI/FN/RAG/NFR- plus three digits')
|
|
1102
|
+
removed = " A removed row reads N/A with % 0, and the date goes in Remarks"
|
|
1036
1103
|
if status.strip("`* ").lower() not in STATUS_VALUES:
|
|
1037
|
-
rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values'
|
|
1104
|
+
rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values.'
|
|
1105
|
+
+ (removed if status.strip("`* ").lower().startswith("n/a") else ""))
|
|
1038
1106
|
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')
|
|
1107
|
+
rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100.' + removed)
|
|
1040
1108
|
n = len(re.findall(r"\S+", remarks))
|
|
1041
1109
|
if n > 60:
|
|
1042
1110
|
rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
|
|
@@ -1046,6 +1114,10 @@ def check_checklist(c, rep):
|
|
|
1046
1114
|
said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
|
|
1047
1115
|
if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
|
|
1048
1116
|
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")
|
|
1117
|
+
if row_status[rid] == "n/a":
|
|
1118
|
+
# a removed or reserved row is kept for traceability; it has no screen, mockup or BRD item to
|
|
1119
|
+
# point at, so the detail-entry rules do not apply to it (Lekhak TF-028)
|
|
1120
|
+
continue
|
|
1049
1121
|
m = re.search(r"\(#([^)]+)\)", details)
|
|
1050
1122
|
if not m:
|
|
1051
1123
|
rep.fail(rel, f"{rid} Details cell has no link to its detail entry")
|
|
@@ -1054,7 +1126,7 @@ def check_checklist(c, rep):
|
|
|
1054
1126
|
if not ids:
|
|
1055
1127
|
rep.fail(rel, "the Requirements Status table has no REQ- rows")
|
|
1056
1128
|
if len(ids) > REQ_CAP[size]:
|
|
1057
|
-
|
|
1129
|
+
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
1130
|
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
1059
1131
|
if dupes:
|
|
1060
1132
|
rep.fail(rel, "duplicate rows: " + ", ".join(dupes))
|
|
@@ -1076,6 +1148,9 @@ def check_checklist(c, rep):
|
|
|
1076
1148
|
start = pos + own.end()
|
|
1077
1149
|
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
1150
|
entry = body[pos: start + nxt.start()] if nxt else body[pos:]
|
|
1151
|
+
if row_status.get(rid) == "n/a":
|
|
1152
|
+
brd_refs.update(re.findall(r"BRD-(\d+)", entry)) # a retired BRD item still has its row
|
|
1153
|
+
continue
|
|
1079
1154
|
acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
|
|
1080
1155
|
if len(acc) != 1:
|
|
1081
1156
|
rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
|
|
@@ -1100,7 +1175,10 @@ def check_checklist(c, rep):
|
|
|
1100
1175
|
brd_refs.update(refs)
|
|
1101
1176
|
if rid.startswith("REQ-UI"):
|
|
1102
1177
|
links = links_to(entry, "mockups")
|
|
1103
|
-
|
|
1178
|
+
# a library or docs project (metrics.project_type, or kind: library) has no screens and no
|
|
1179
|
+
# docs/mockups/: its UI rows map to components, so a missing link is not a finding there; a
|
|
1180
|
+
# link it does carry must still resolve (TrBlazeUI TF-003)
|
|
1181
|
+
if not links and c.get("kind") == "app" and read_core_config(root).get("project_type", "app").lower() not in ("library", "docs"):
|
|
1104
1182
|
rep.fail(rel, f"{rid} is a UI row without a mockup link")
|
|
1105
1183
|
for l in sorted(links):
|
|
1106
1184
|
if not resolve(root, path, l):
|
|
@@ -1275,14 +1353,18 @@ NAV_ITEM = re.compile(r"""<(div|li|span|button)\b[^>]*\bclass\s*=\s*["'][^"']*(?
|
|
|
1275
1353
|
|
|
1276
1354
|
|
|
1277
1355
|
def _mock_target(mock_dir: str, t: str):
|
|
1278
|
-
"""Resolve a link the way a browser opening docs/mockups/<file> would. Returns the
|
|
1279
|
-
|
|
1356
|
+
"""Resolve a link the way a browser opening docs/mockups/<file> would. Returns the path
|
|
1357
|
+
relative to the mockup folder when it lands on an existing file inside it, at any depth
|
|
1358
|
+
(Lekhak TF-008: index.html linking to admin/connection-settings.html opens, and was
|
|
1359
|
+
failed), else None. A leading slash, or a path that leaves docs/mockups/, is still None."""
|
|
1280
1360
|
if re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I) or t.startswith("/"):
|
|
1281
1361
|
return None
|
|
1362
|
+
t = re.split(r"[?#]", t, maxsplit=1)[0]
|
|
1363
|
+
base = os.path.normpath(mock_dir)
|
|
1282
1364
|
p = os.path.normpath(os.path.join(mock_dir, t))
|
|
1283
|
-
if
|
|
1365
|
+
if not (p == base or p.startswith(base + os.sep)) or not os.path.isfile(p):
|
|
1284
1366
|
return None
|
|
1285
|
-
return os.path.
|
|
1367
|
+
return os.path.relpath(p, base).replace(os.sep, "/")
|
|
1286
1368
|
|
|
1287
1369
|
|
|
1288
1370
|
def check_mockups(root: str, rep: Report):
|
|
@@ -1315,7 +1397,8 @@ def check_mockups(root: str, rep: Report):
|
|
|
1315
1397
|
continue
|
|
1316
1398
|
name = _mock_target(mock_dir, t)
|
|
1317
1399
|
if name is None:
|
|
1318
|
-
hint = "
|
|
1400
|
+
hint = ("a leading slash opens from the disk's root; write the path from docs/mockups/" if t.startswith("/")
|
|
1401
|
+
else "the file does not exist inside docs/mockups/")
|
|
1319
1402
|
rep.fail(rel, f'links to "{t}", which does not open from docs/mockups/ ({hint})')
|
|
1320
1403
|
else:
|
|
1321
1404
|
targets.append(name)
|
|
@@ -1420,6 +1503,7 @@ def main(argv=None):
|
|
|
1420
1503
|
if a.app:
|
|
1421
1504
|
root = root or os.getcwd()
|
|
1422
1505
|
files += app_files(root, a.app)
|
|
1506
|
+
files = list(dict.fromkeys(os.path.abspath(f) for f in files)) # a file given and listed by --app once
|
|
1423
1507
|
if not files:
|
|
1424
1508
|
print(f"tf-doc-check: no documents for app {a.app} under {root}", file=sys.stderr)
|
|
1425
1509
|
return 2
|
|
@@ -1452,9 +1536,16 @@ def main(argv=None):
|
|
|
1452
1536
|
base = json.load(fh) or {}
|
|
1453
1537
|
except Exception:
|
|
1454
1538
|
base = {}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1539
|
+
# every finding, under whatever it names: the mockup-folder and cross-document checks name
|
|
1540
|
+
# docs/mockups/index.html or another document, not one of the files given, and were never
|
|
1541
|
+
# recorded, so ~60 old findings read as new on every *amend-docs (Lekhak TF-023)
|
|
1542
|
+
new = {os.path.relpath(f, root): set() for f in files}
|
|
1543
|
+
for l in rep.lines:
|
|
1544
|
+
if l.startswith("FAIL ") and ": " in l:
|
|
1545
|
+
head, msg = l.split(": ", 1)
|
|
1546
|
+
new.setdefault(head[len("FAIL "):], set()).add(msg)
|
|
1547
|
+
for r, msgs in new.items():
|
|
1548
|
+
base[r] = sorted(msgs)
|
|
1458
1549
|
os.makedirs(os.path.dirname(base_path), exist_ok=True)
|
|
1459
1550
|
with open(base_path, "w", encoding="utf-8") as fh:
|
|
1460
1551
|
json.dump(base, fh, indent=1)
|
|
@@ -1468,11 +1559,23 @@ def main(argv=None):
|
|
|
1468
1559
|
with open(base_path, encoding="utf-8") as fh:
|
|
1469
1560
|
base = json.load(fh) or {}
|
|
1470
1561
|
relined = []
|
|
1562
|
+
# A size finding carries its count ("20,677 words; the Small maximum is 8,000"), so any
|
|
1563
|
+
# edit to an over-limit document, a cut included, changed the text and read as new
|
|
1564
|
+
# (Lekhak TF-023). The same finding with a count no larger than the old one is old;
|
|
1565
|
+
# a document that grew past its maximum still FAILs.
|
|
1566
|
+
def _shape(m):
|
|
1567
|
+
return re.sub(r"\d[\d,]*", "#", m)
|
|
1568
|
+
def _first(m):
|
|
1569
|
+
n = re.search(r"\d[\d,]*", m)
|
|
1570
|
+
return int(n.group(0).replace(",", "")) if n else None
|
|
1471
1571
|
for l in rep.lines:
|
|
1472
1572
|
if l.startswith("FAIL "):
|
|
1473
1573
|
head, msg = l.split(": ", 1)
|
|
1474
1574
|
r = head[len("FAIL "):]
|
|
1475
|
-
|
|
1575
|
+
was = base.get(r, [])
|
|
1576
|
+
same = msg in set(was) or (re.match(r"^\d[\d,]* (words|lines|rows|requirements|KB|screens)", msg) and any(
|
|
1577
|
+
_shape(o) == _shape(msg) and _first(o) is not None and _first(msg) <= _first(o) for o in was))
|
|
1578
|
+
if same:
|
|
1476
1579
|
relined.append("OLD " + l[5:])
|
|
1477
1580
|
rep.fails -= 1
|
|
1478
1581
|
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
|
|
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,
|
|
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
|
-
|
|
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 ;;
|
package/.tfcore/utils/tf-emit.sh
CHANGED
|
@@ -1015,16 +1015,34 @@ def _window_opencode(t0, t1):
|
|
|
1015
1015
|
return None
|
|
1016
1016
|
dbp = ptr.get("db_path")
|
|
1017
1017
|
sid = ptr.get("session_id")
|
|
1018
|
-
if not dbp or not
|
|
1018
|
+
if not dbp or not os.path.isfile(dbp):
|
|
1019
1019
|
return None
|
|
1020
1020
|
try:
|
|
1021
1021
|
import sqlite3
|
|
1022
1022
|
db = sqlite3.connect("file:%s?mode=ro" % dbp, uri=True)
|
|
1023
|
-
|
|
1023
|
+
# OpenCode 2 keeps sessions in session_v2 and messages in session_message; the old tables
|
|
1024
|
+
# stopped on 2026-09-27, so every OpenCode record since read tokens_scope "none". Both are read.
|
|
1025
|
+
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
1026
|
+
pairs = db.execute("SELECT id, parent_id FROM session").fetchall() if "session" in tables else []
|
|
1027
|
+
if "session_v2" in tables:
|
|
1028
|
+
pairs += db.execute("SELECT id, parent_id FROM session_v2").fetchall()
|
|
1024
1029
|
kids = {}
|
|
1025
1030
|
for i, p in pairs:
|
|
1026
1031
|
kids.setdefault(p, []).append(i)
|
|
1027
|
-
|
|
1032
|
+
# The roots: the pointer's session, and every root session OpenCode 2 recorded in THIS project's
|
|
1033
|
+
# folder. Each *develop-end-to-end phase runs in a new session and the plugin's pointer kept the
|
|
1034
|
+
# first one, so the later phases read "none" (2026-10-08). Messages are still taken only inside
|
|
1035
|
+
# the record's own window.
|
|
1036
|
+
roots = {sid} if sid else set()
|
|
1037
|
+
if "session_v2" in tables:
|
|
1038
|
+
here = os.path.realpath(root)
|
|
1039
|
+
for i, p_, dr in db.execute("SELECT id, parent_id, directory FROM session_v2"):
|
|
1040
|
+
if not p_ and dr and os.path.realpath(dr) == here:
|
|
1041
|
+
roots.add(i)
|
|
1042
|
+
if not roots:
|
|
1043
|
+
db.close()
|
|
1044
|
+
return None
|
|
1045
|
+
tree, queue = set(roots), list(roots)
|
|
1028
1046
|
while queue:
|
|
1029
1047
|
for c in kids.get(queue.pop(), []):
|
|
1030
1048
|
if c not in tree:
|
|
@@ -1036,15 +1054,20 @@ def _window_opencode(t0, t1):
|
|
|
1036
1054
|
# Same rule as the Claude branch: a CHILD session counts as a subagent run
|
|
1037
1055
|
# for THIS window only if it produced output inside it.
|
|
1038
1056
|
child_out = {}
|
|
1039
|
-
|
|
1040
|
-
|
|
1057
|
+
rows = []
|
|
1058
|
+
if "message" in tables:
|
|
1059
|
+
rows += db.execute("SELECT session_id, data FROM message WHERE time_created BETWEEN ? AND ?", (t0, t1)).fetchall()
|
|
1060
|
+
if "session_message" in tables:
|
|
1061
|
+
rows += [(a, b) for a, b in db.execute(
|
|
1062
|
+
"SELECT session_id, data FROM session_message WHERE type = 'assistant' AND time_created BETWEEN ? AND ?", (t0, t1))]
|
|
1063
|
+
for msid, data in rows:
|
|
1041
1064
|
if msid not in tree:
|
|
1042
1065
|
continue
|
|
1043
1066
|
try:
|
|
1044
1067
|
d = json.loads(data)
|
|
1045
1068
|
except Exception:
|
|
1046
1069
|
continue
|
|
1047
|
-
if d.get("role")
|
|
1070
|
+
if d.get("role") not in (None, "assistant"):
|
|
1048
1071
|
continue
|
|
1049
1072
|
t = d.get("tokens") or {}
|
|
1050
1073
|
cache = t.get("cache") or {}
|
|
@@ -1054,9 +1077,11 @@ def _window_opencode(t0, t1):
|
|
|
1054
1077
|
tot["cr"] += cache.get("read") or 0
|
|
1055
1078
|
tot["cw"] += cache.get("write") or 0
|
|
1056
1079
|
cost += d.get("cost") or 0
|
|
1057
|
-
if msid
|
|
1080
|
+
if msid not in roots and o:
|
|
1058
1081
|
child_out[msid] = child_out.get(msid, 0) + o
|
|
1059
|
-
|
|
1082
|
+
mo = d.get("model") if isinstance(d.get("model"), dict) else {}
|
|
1083
|
+
prov = d.get("providerID") or mo.get("providerID") or ""
|
|
1084
|
+
m = (prov + "/" if prov else "") + (d.get("modelID") or mo.get("id") or "")
|
|
1060
1085
|
if m:
|
|
1061
1086
|
models[m] = models.get(m, 0) + o
|
|
1062
1087
|
db.close()
|
|
@@ -1463,7 +1488,7 @@ _ALLOWED = {
|
|
|
1463
1488
|
"routed", "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
|
|
1464
1489
|
"cost_usd", "tokens_scope", "attempt", "subagent_runs", "tokens_out_subagents",
|
|
1465
1490
|
"model_tokens_out", "session_id", "usage_start_event_ts", "usage_end_event_ts",
|
|
1466
|
-
"billing_mode", "cost_source", "fallback_from"},
|
|
1491
|
+
"billing_mode", "cost_source", "fallback_from", "app_phase"},
|
|
1467
1492
|
# A correction, not a deletion: it names a run record that is wrong by the pair every
|
|
1468
1493
|
# run carries (`cmd` + `started`) and says why, so every figure can skip it while the
|
|
1469
1494
|
# record itself stays where it is. Written by --void-run, which refuses one that names
|
|
@@ -1474,7 +1499,7 @@ _ALLOWED = {
|
|
|
1474
1499
|
"tier", "tier_model", "model", "models", "tokens_in", "tokens_out", "tokens_cache_read",
|
|
1475
1500
|
"tokens_cache_write", "cost_usd", "tokens_scope", "model_tokens_out", "routed",
|
|
1476
1501
|
"subagent_runs", "tokens_out_subagents", "session_id",
|
|
1477
|
-
"billing_mode", "cost_source", "fallback_from"},
|
|
1502
|
+
"billing_mode", "cost_source", "fallback_from", "app_phase"},
|
|
1478
1503
|
("sessions", "session"): {"session_id", "model", "duration_s", "input_tokens", "output_tokens",
|
|
1479
1504
|
"cache_read_tokens", "cache_creation_tokens", "cost_usd", "children_sessions"},
|
|
1480
1505
|
("commits", "commit"): {"sha", "files", "insertions", "deletions", "subject_prefix", "branch"},
|
|
@@ -1519,6 +1544,11 @@ def _shape_ok(rec):
|
|
|
1519
1544
|
if kind == "run" and rec.get("build_result") not in (None, "pass", "fail", "not-run"):
|
|
1520
1545
|
sys.stdout.write("tf-emit: REFUSED — build_result must be pass, fail or not-run (SCHEMA.md §2); got %r. Put the detail in the checklist Remarks or Known blockers, not here. Nothing was appended.\n" % (rec.get("build_result"),))
|
|
1521
1546
|
return False
|
|
1547
|
+
# one REQ's verdict, never a suite note: Lekhak hand-wrote four {"gate":"verify-suite","result":…}
|
|
1548
|
+
# records with neither, and the report counted each as a failure (TF-020)
|
|
1549
|
+
if kind == "gate" and not rec.get("backfilled") and not (rec.get("req_id") and rec.get("verdict")):
|
|
1550
|
+
sys.stdout.write("tf-emit: REFUSED — a gate record is one REQ's verdict and needs both req_id and verdict (SCHEMA.md §3). A suite result (unit tests, the whole browser run) belongs in the run record's notes, not here. Nothing was appended.\n")
|
|
1551
|
+
return False
|
|
1522
1552
|
if kind == "miss" and not rec.get("miss_id"):
|
|
1523
1553
|
sys.stdout.write("tf-emit: REFUSED — a miss record needs a miss_id; get one with: bash .tfcore/utils/tf-emit.sh --next-miss-id. Nothing was appended.\n")
|
|
1524
1554
|
return False
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
#
|
|
4
4
|
# bash .tfcore/utils/tf-fix-close.sh <App> [--started <ISO>] [--reqs REQ-UI-009,REQ-FN-014]
|
|
5
5
|
# [--subagents trblazeui,builder] [--build pass|fail|not-run] [--files N]
|
|
6
|
+
# [--cmd triage-and-fix] # the command the run record is filed under
|
|
6
7
|
# bash .tfcore/utils/tf-fix-close.sh <App> --misses MISS-App-20260901-06,MISS-App-20260830-03 \
|
|
7
8
|
# --fix-cmd amend-docs [--verdict Verified]
|
|
8
9
|
#
|
|
@@ -24,13 +25,16 @@
|
|
|
24
25
|
# re-verify). A row no verify graded gets no miss-fix and is listed. A row with no open miss is listed,
|
|
25
26
|
# not invented: a fix on a defect nobody logged is a triage gap, so open it first with tf-triage.sh
|
|
26
27
|
# demote or tf-log-miss.sh, then re-run this.
|
|
28
|
+
# --cmd names the command the run record is filed under (default fix-issues): *triage-and-fix passes
|
|
29
|
+
# --cmd triage-and-fix, so its run is counted as itself and no second record over the same window is
|
|
30
|
+
# needed, which the overlap rule refused (TrBlazeUI TF-004). The miss-fix records keep fix_cmd fix-issues.
|
|
27
31
|
# --reqs defaults to every row those verifies graded. The start defaults to the fix's marker, or its
|
|
28
32
|
# "outer" entry when a chained verify replaced it. Telemetry never blocks: refusals are printed, exit 0.
|
|
29
33
|
set -u
|
|
30
34
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
31
35
|
[[ $# -ge 1 && "$1" != "-h" && "$1" != "--help" ]] || { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 3; }
|
|
32
36
|
APP="$1"; shift
|
|
33
|
-
STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""; MISSES=""; FIXCMD="fix-issues"; VERDICT=""
|
|
37
|
+
STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""; MISSES=""; FIXCMD="fix-issues"; VERDICT=""; RUNCMD="fix-issues"
|
|
34
38
|
while [[ $# -gt 0 ]]; do
|
|
35
39
|
case "$1" in
|
|
36
40
|
--started) STARTED="${2:-}"; shift 2 ;;
|
|
@@ -41,6 +45,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
41
45
|
--misses) MISSES="${2:-}"; shift 2 ;;
|
|
42
46
|
--fix-cmd) FIXCMD="${2:-}"; shift 2 ;;
|
|
43
47
|
--verdict) VERDICT="${2:-}"; shift 2 ;;
|
|
48
|
+
--cmd) RUNCMD="${2:-}"; shift 2 ;;
|
|
44
49
|
*) echo "tf-fix-close: unknown argument $1" >&2; exit 3 ;;
|
|
45
50
|
esac
|
|
46
51
|
done
|
|
@@ -51,9 +56,10 @@ except Exception: m={}
|
|
|
51
56
|
o=m.get('outer') or {}
|
|
52
57
|
print(m.get('started','') if m.get('cmd')!='verify-phase' or not o.get('started') else o['started'])" 2>/dev/null)"
|
|
53
58
|
TF_APP="$APP" TF_STARTED="$STARTED" TF_REQS="$REQS" TF_SUBS="$SUBS" TF_BUILD="$BUILD" TF_FILES="$FILES" \
|
|
54
|
-
TF_MISSES="$MISSES" TF_FIXCMD="$FIXCMD" TF_VERDICT="$VERDICT" TF_EMIT="$HERE/tf-emit.sh" python3 - <<'PY'
|
|
59
|
+
TF_MISSES="$MISSES" TF_FIXCMD="$FIXCMD" TF_RUNCMD="$RUNCMD" TF_VERDICT="$VERDICT" TF_EMIT="$HERE/tf-emit.sh" python3 - <<'PY'
|
|
55
60
|
import datetime, json, os, subprocess
|
|
56
61
|
EMIT = os.environ["TF_EMIT"]; app = os.environ["TF_APP"]; started = os.environ["TF_STARTED"]
|
|
62
|
+
cmd = os.environ.get("TF_RUNCMD") or "fix-issues"
|
|
57
63
|
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
58
64
|
def q(*a): return subprocess.run(["bash", EMIT, *a], capture_output=True, text=True).stdout.strip()
|
|
59
65
|
def emit(stream, rec):
|
|
@@ -121,7 +127,7 @@ inner, voided = [], set()
|
|
|
121
127
|
for r in jsonl("runs.jsonl"):
|
|
122
128
|
if r.get("kind") == "run-void":
|
|
123
129
|
voided.add((r.get("cmd"), r.get("started")))
|
|
124
|
-
elif r.get("kind", "run") == "run" and r.get("app") == app and r.get("cmd")
|
|
130
|
+
elif r.get("kind", "run") == "run" and r.get("app") == app and r.get("cmd") not in ("fix-issues", cmd) and not r.get("backfilled") \
|
|
125
131
|
and started and (r.get("started") or "") >= started and (r.get("ended") or "") and r["ended"] <= now:
|
|
126
132
|
inner.append((r["started"], r["ended"], r.get("cmd")))
|
|
127
133
|
inner = sorted(w for w in inner if (w[2], w[0]) not in voided)
|
|
@@ -130,11 +136,11 @@ for s, e, _c in inner:
|
|
|
130
136
|
if t and s > t: gaps.append((t, s))
|
|
131
137
|
t = max(t, e) if t else e
|
|
132
138
|
if not started or not gaps or t < now: gaps.append((t, now))
|
|
133
|
-
existing = {r.get("started") for r in jsonl("runs.jsonl") if r.get("kind", "run") == "run" and r.get("cmd") ==
|
|
139
|
+
existing = {r.get("started") for r in jsonl("runs.jsonl") if r.get("kind", "run") == "run" and r.get("cmd") == cmd}
|
|
134
140
|
wrote = had = 0
|
|
135
141
|
for i, (s, e) in enumerate(gaps):
|
|
136
142
|
first = i == 0
|
|
137
|
-
run = {"kind": "run", "app": app, "cmd":
|
|
143
|
+
run = {"kind": "run", "app": app, "cmd": cmd, "mode": "fix", "ended": e, "reqs_touched": reqs if first else [],
|
|
138
144
|
"reqs_count": len(reqs) if first else 0, "subagents": subs if first else [],
|
|
139
145
|
"files_written": (int(files) if files.isdigit() else len(reqs)) if first else 0, "build_result": os.environ["TF_BUILD"]}
|
|
140
146
|
if s: run["started"] = s
|
|
@@ -143,7 +149,7 @@ for i, (s, e) in enumerate(gaps):
|
|
|
143
149
|
had += 1; continue
|
|
144
150
|
wrote += emit("runs", run)
|
|
145
151
|
if inner:
|
|
146
|
-
print(f"
|
|
152
|
+
print(f"{cmd}: recorded around {len(inner)} chained run(s) ({', '.join(sorted({c for _s, _e, c in inner}))}) in {len(gaps)} segment(s)")
|
|
147
153
|
closed, none, unverified = 0, [], []
|
|
148
154
|
for rid in reqs:
|
|
149
155
|
if rid not in rows:
|
|
@@ -158,7 +164,7 @@ for rid in reqs:
|
|
|
158
164
|
if started: rec["fix_run_id"] = started
|
|
159
165
|
if emit("misses", rec): closed += 1
|
|
160
166
|
state = "already there" if had == len(gaps) else "written" if wrote + had == len(gaps) else "not written"
|
|
161
|
-
print(f"
|
|
167
|
+
print(f"{cmd}: run record {state}; {closed} miss-fix record(s) with the verifier's verdict"
|
|
162
168
|
+ (f"; no open miss on {', '.join(none)} (log it first, then re-run)" if none else "")
|
|
163
169
|
+ (f"; no verify graded {', '.join(unverified)} during this fix, so its miss stays open with no miss-fix" if unverified else ""))
|
|
164
170
|
PY
|