bigpowers 2.90.1 → 2.90.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.90.1",
3
+ "version": "2.90.2",
4
4
  "description": "80 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "gemini-extension"
package/.pi/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.90.1",
3
+ "version": "2.90.2",
4
4
  "description": "80 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "pi-package"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [2.90.2](https://github.com/danielvm-git/bigpowers/compare/v2.90.1...v2.90.2) (2026-11-03)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **scripts:** write spec state atomically instead of in place ([#192](https://github.com/danielvm-git/bigpowers/issues/192)) ([6198138](https://github.com/danielvm-git/bigpowers/commit/61981388c6ab80e493ae7582749730b41c89fa24)), closes [#181](https://github.com/danielvm-git/bigpowers/issues/181)
7
+
1
8
  ## [2.90.1](https://github.com/danielvm-git/bigpowers/compare/v2.90.0...v2.90.1) (2026-11-03)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.90.1",
3
+ "version": "2.90.2",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -7,11 +7,15 @@ from __future__ import annotations
7
7
  import json
8
8
  import random
9
9
  import subprocess
10
+ import sys
10
11
  from datetime import date
11
12
  from pathlib import Path
12
13
 
13
14
  import yaml
14
15
 
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+ from atomic_write import atomic_write_text # noqa: E402
18
+
15
19
  REPO_ROOT = Path(__file__).resolve().parents[2]
16
20
  BOOTSTRAP_RESAMPLES = 1000
17
21
  BOOTSTRAP_SEED = 58
@@ -96,10 +100,13 @@ def write_reports(out: Path, meta: dict, result: dict, records: list[dict]) -> P
96
100
  out.mkdir(parents=True, exist_ok=True)
97
101
  today = date.today().isoformat()
98
102
  report = out / f"AB-{today}.yaml"
99
- report.write_text(yaml.safe_dump({**meta, **result}, sort_keys=False))
100
- with (out / f"ab-runs-{today}.jsonl").open("w") as f:
101
- for rec in records:
102
- f.write(json.dumps({**rec, "bigpowers_sha": meta["bigpowers_sha"]}) + "\n")
103
+ # Atomic (#181): the dashboard reads these while a run is still appending.
104
+ atomic_write_text(report, yaml.safe_dump({**meta, **result}, sort_keys=False))
105
+ records_path = out / f"ab-runs-{today}.jsonl"
106
+ atomic_write_text(
107
+ records_path,
108
+ "".join(json.dumps({**rec, "bigpowers_sha": meta["bigpowers_sha"]}) + "\n" for rec in records),
109
+ )
103
110
  return report
104
111
 
105
112
 
@@ -0,0 +1,71 @@
1
+ """Atomic file replacement for spec state (BUG-2026-11-03-atomic-yaml-writes, #181).
2
+
3
+ `Path.write_text()` truncates the destination first and then streams the new
4
+ bytes into it. Any concurrent reader — the dashboard snapshot loop, a subagent,
5
+ a background gate — can observe the file after the truncate and before the last
6
+ chunk lands, and fails with `yaml.scanner.ScannerError` on a file that a moment
7
+ later is perfectly valid. Interrupting the agent mid-write leaves the file
8
+ truncated for good.
9
+
10
+ `atomic_write_text()` never exposes a partial file: the bytes go to a temporary
11
+ file in the same directory, are flushed and fsynced, and only then replace the
12
+ destination with `os.replace()` (a single atomic rename on POSIX). Readers see
13
+ the old content or the new content, never a mix.
14
+
15
+ Same-directory temp file is required: `os.replace` is atomic only within one
16
+ filesystem, and `tempfile.mkstemp(dir=...)` guarantees that.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import stat
23
+ import tempfile
24
+ from pathlib import Path
25
+
26
+
27
+ def atomic_write_bytes(path: str | Path, data: bytes, mode: int | None = None) -> None:
28
+ """Replace `path` with `data` atomically. Preserves the target's mode."""
29
+ target = Path(path)
30
+ target.parent.mkdir(parents=True, exist_ok=True)
31
+
32
+ if mode is None and target.exists():
33
+ mode = stat.S_IMODE(target.stat().st_mode)
34
+
35
+ fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=f".{target.name}.", suffix=".tmp")
36
+ try:
37
+ with os.fdopen(fd, "wb") as handle:
38
+ handle.write(data)
39
+ handle.flush()
40
+ os.fsync(handle.fileno())
41
+ if mode is not None:
42
+ os.chmod(tmp_name, mode)
43
+ os.replace(tmp_name, target)
44
+ except BaseException:
45
+ try:
46
+ os.unlink(tmp_name)
47
+ except OSError:
48
+ pass
49
+ raise
50
+
51
+ _fsync_dir(target.parent)
52
+
53
+
54
+ def atomic_write_text(path: str | Path, text: str, encoding: str = "utf-8", mode: int | None = None) -> None:
55
+ """Atomic replacement for `Path.write_text()`."""
56
+ atomic_write_bytes(path, text.encode(encoding), mode=mode)
57
+
58
+
59
+ def _fsync_dir(directory: Path) -> None:
60
+ """Persist the rename itself. Best-effort: not every platform allows it."""
61
+ flags = getattr(os, "O_DIRECTORY", 0)
62
+ try:
63
+ dir_fd = os.open(str(directory), os.O_RDONLY | flags)
64
+ except OSError:
65
+ return
66
+ try:
67
+ os.fsync(dir_fd)
68
+ except OSError:
69
+ pass
70
+ finally:
71
+ os.close(dir_fd)
@@ -6,6 +6,8 @@
6
6
  import json, os, re, sys
7
7
  from pathlib import Path
8
8
  from datetime import datetime, timezone
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
10
+ from atomic_write import atomic_write_text # noqa: E402
9
11
 
10
12
  ROOT = Path(sys.argv[1])
11
13
  BLIND_SPOTS_JSON = Path(sys.argv[2])
@@ -202,7 +204,7 @@ blind_spots = {
202
204
  }
203
205
 
204
206
  BLIND_SPOTS_JSON.parent.mkdir(parents=True, exist_ok=True)
205
- BLIND_SPOTS_JSON.write_text(json.dumps(blind_spots, indent=2), encoding="utf-8")
207
+ atomic_write_text(BLIND_SPOTS_JSON, json.dumps(blind_spots, indent=2), encoding="utf-8")
206
208
 
207
209
  print(f"check-blind-spots.sh: {len(findings)} findings ({high_count} HIGH, {medium_count} MEDIUM, {low_count} LOW)")
208
210
  for f in findings:
@@ -15,6 +15,8 @@ from __future__ import annotations
15
15
  import json, os, re, sys
16
16
  from pathlib import Path
17
17
  from datetime import datetime, timezone
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ from atomic_write import atomic_write_text # noqa: E402
18
20
 
19
21
  ROOT = Path(sys.argv[1])
20
22
  BLIND_SPOTS_JSON = Path(sys.argv[2])
@@ -260,7 +262,7 @@ blind_spots = {
260
262
  }
261
263
 
262
264
  BLIND_SPOTS_JSON.parent.mkdir(parents=True, exist_ok=True)
263
- BLIND_SPOTS_JSON.write_text(json.dumps(blind_spots, indent=2), encoding="utf-8")
265
+ atomic_write_text(BLIND_SPOTS_JSON, json.dumps(blind_spots, indent=2), encoding="utf-8")
264
266
 
265
267
  print(f"check-blind-spots.sh: {len(findings)} findings ({high_count} HIGH, {medium_count} MEDIUM, {low_count} LOW)")
266
268
  for f in findings:
@@ -81,6 +81,8 @@ GOLDEN_GATES=(
81
81
  "golden-gate-runner:bash scripts/test-golden-gate-runner.sh:false"
82
82
  # Pipefail SIGPIPE races: fix shapes hold, git output never feeds grep -q/head.
83
83
  "pipefail-pipe-race:bash scripts/test-pipefail-pipe-race.sh:false"
84
+ # Atomic state writes: no reader ever sees a truncated YAML/JSON file (#181).
85
+ "atomic-writes:bash scripts/test-atomic-writes.sh:false"
84
86
  # One pipeline, release needs: every gate, pinned runner and actions.
85
87
  "ci-pipeline-contract-selftest:bash scripts/test-ci-pipeline-contract.sh --self-test:false"
86
88
  "ci-pipeline-contract:bash scripts/test-ci-pipeline-contract.sh:false"
@@ -12,6 +12,9 @@ from pathlib import Path
12
12
 
13
13
  import yaml
14
14
 
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ from atomic_write import atomic_write_text # noqa: E402
17
+
15
18
  REPO_ROOT = Path(__file__).resolve().parents[2]
16
19
  BENCH_DIR = REPO_ROOT / "specs" / "benchmarks"
17
20
  REPORTS_DIR = BENCH_DIR / "reports"
@@ -169,8 +172,7 @@ def run_benchmark(skill: str, baseline: bool = False) -> dict:
169
172
  REPORTS_DIR.mkdir(parents=True, exist_ok=True)
170
173
  yaml_path = REPORTS_DIR / f"BENCHMARK-{skill}-{today}.yaml"
171
174
  json_path = REPORTS_DIR / f"benchmark-{skill}.json"
172
- with yaml_path.open("w") as f:
173
- yaml.dump(
175
+ yaml_text = yaml.dump(
174
176
  {
175
177
  "skill": skill,
176
178
  "run_date": today,
@@ -189,9 +191,9 @@ def run_benchmark(skill: str, baseline: bool = False) -> dict:
189
191
  },
190
192
  f,
191
193
  default_flow_style=False,
192
- )
193
- with json_path.open("w") as f:
194
- json.dump(report, f, separators=(",", ":"))
194
+ )
195
+ atomic_write_text(yaml_path, yaml_text)
196
+ atomic_write_text(json_path, json.dumps(report, separators=(",", ":")))
195
197
  print(f"Wrote {yaml_path}")
196
198
  print(f"Wrote {json_path}")
197
199
  if baseline:
@@ -1,7 +1,12 @@
1
1
  """OKF concept-page rendering for srp-engine (sync-skills.sh --okf)."""
2
2
  # story: e39s04
3
3
  # story: BUG-2026-10-30-compliance-akita-masked
4
+ # story: BUG-2026-11-03-atomic-yaml-writes
4
5
  import os
6
+ import sys
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+ from atomic_write import atomic_write_text # noqa: E402
5
10
 
6
11
 
7
12
  def render_okf_concept(skill_data, okf_wiki_skills):
@@ -61,5 +66,4 @@ def render_okf_concept(skill_data, okf_wiki_skills):
61
66
  f"> Auto-generated by sync-skills.sh --okf (e39s04) from skills/{name}/SKILL.md"
62
67
  ])
63
68
 
64
- with open(okf_file, 'w', encoding='utf-8') as f:
65
- f.write('\n'.join(lines) + '\n')
69
+ atomic_write_text(okf_file, '\n'.join(lines) + '\n')
@@ -15,6 +15,8 @@ import json, os, re, subprocess, sys
15
15
  from pathlib import Path
16
16
  from datetime import datetime, timezone
17
17
  import yaml
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ from atomic_write import atomic_write_text # noqa: E402
18
20
 
19
21
  ROOT = Path(sys.argv[1])
20
22
  MATRIX_JSON = Path(sys.argv[2])
@@ -189,7 +191,7 @@ matrix = {
189
191
  }
190
192
  }
191
193
  MATRIX_JSON.parent.mkdir(parents=True, exist_ok=True)
192
- MATRIX_JSON.write_text(json.dumps(matrix, indent=2), encoding="utf-8")
194
+ atomic_write_text(MATRIX_JSON, json.dumps(matrix, indent=2), encoding="utf-8")
193
195
 
194
196
  # --- 6. Emit TRACEABILITY_LATEST.md
195
197
  lines = ["# Traceability Matrix", "",
@@ -224,7 +226,7 @@ if stale_tags:
224
226
  for st in stale_tags: lines.append(f"- `{st}`")
225
227
  lines.append("")
226
228
  TRACE_MD.parent.mkdir(parents=True, exist_ok=True)
227
- TRACE_MD.write_text("\n".join(lines), encoding="utf-8")
229
+ atomic_write_text(TRACE_MD, "\n".join(lines))
228
230
 
229
231
  # --- 7. Emit OKF bundle (specs/codebase-wiki/)
230
232
  OKF_DIR.mkdir(parents=True, exist_ok=True)
@@ -240,7 +242,7 @@ for s in matrix_stories:
240
242
  mc = "high" if "high" in confs else ("medium" if "medium" in confs else ("low" if confs else "none"))
241
243
  idx_lines.append(f"| [{s['id']}](./{s['id']}.md) | {s['title'][:60]} | {mc} | {s['link_count']} |")
242
244
  idx_lines.append("")
243
- (OKF_DIR / "index.md").write_text("\n".join(idx_lines), encoding="utf-8")
245
+ atomic_write_text(OKF_DIR / "index.md", "\n".join(idx_lines))
244
246
  for s in matrix_stories:
245
247
  concept = ["---", f"type: Story", f"id: {s['id']}", f"epic: {s['epic_id']}",
246
248
  f"bcps: {s['bcp']}", f"wsjf: {s['wsjf']}",
@@ -263,7 +265,7 @@ for s in matrix_stories:
263
265
  else:
264
266
  concept.append("*No code links found — this is a dark story.*")
265
267
  concept.append("")
266
- (OKF_DIR / f"{s['id']}.md").write_text("\n".join(concept), encoding="utf-8")
268
+ atomic_write_text(OKF_DIR / f"{s['id']}.md", "\n".join(concept))
267
269
 
268
270
  # --- 8. Strict mode
269
271
  if STRICT:
@@ -3,6 +3,8 @@
3
3
  import json
4
4
  from pathlib import Path
5
5
  from datetime import datetime, timezone
6
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
7
+ from atomic_write import atomic_write_text # noqa: E402
6
8
 
7
9
  def emit_matrix_json(matrix_json_path: Path, stories: dict, tagged_sids: set, dark_stories: list, orphan_tags: list, stale_tags: list, matrix_stories: list) -> dict:
8
10
  matrix = {
@@ -26,7 +28,7 @@ def emit_matrix_json(matrix_json_path: Path, stories: dict, tagged_sids: set, da
26
28
  }
27
29
  }
28
30
  matrix_json_path.parent.mkdir(parents=True, exist_ok=True)
29
- matrix_json_path.write_text(json.dumps(matrix, indent=2), encoding="utf-8")
31
+ atomic_write_text(matrix_json_path, json.dumps(matrix, indent=2), encoding="utf-8")
30
32
  return matrix
31
33
 
32
34
  def emit_trace_md(trace_md_path: Path, stories: dict, tagged_sids: set, dark_stories: list, orphan_tags: list, stale_tags: list, matrix_stories: list, matrix: dict, dev_status: dict) -> None:
@@ -80,7 +82,7 @@ def emit_trace_md(trace_md_path: Path, stories: dict, tagged_sids: set, dark_sto
80
82
  lines.append("")
81
83
 
82
84
  trace_md_path.parent.mkdir(parents=True, exist_ok=True)
83
- trace_md_path.write_text("\n".join(lines), encoding="utf-8")
85
+ atomic_write_text(trace_md_path, "\n".join(lines))
84
86
 
85
87
  def emit_okf_bundle(okf_dir: Path, stories: dict, matrix_stories: list) -> None:
86
88
  okf_dir.mkdir(parents=True, exist_ok=True)
@@ -104,7 +106,7 @@ def emit_okf_bundle(okf_dir: Path, stories: dict, matrix_stories: list) -> None:
104
106
  max_conf = "high" if "high" in confs else ("medium" if "medium" in confs else ("low" if confs else "none"))
105
107
  idx_lines.append(f"| [{s['id']}](./{s['id']}.md) | {s['title'][:60]} | {max_conf} | {s['link_count']} |")
106
108
  idx_lines.append("")
107
- (okf_dir / "index.md").write_text("\n".join(idx_lines), encoding="utf-8")
109
+ atomic_write_text(okf_dir / "index.md", "\n".join(idx_lines))
108
110
 
109
111
  # Generate one concept per story
110
112
  for s in matrix_stories:
@@ -144,4 +146,4 @@ def emit_okf_bundle(okf_dir: Path, stories: dict, matrix_stories: list) -> None:
144
146
  else:
145
147
  concept.append("*No code links found — this is a dark story.*")
146
148
  concept.append("")
147
- (okf_dir / f"{s['id']}.md").write_text("\n".join(concept), encoding="utf-8")
149
+ atomic_write_text(okf_dir / f"{s['id']}.md", "\n".join(concept))
@@ -8,6 +8,9 @@ import re
8
8
  import sys
9
9
  from pathlib import Path
10
10
 
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
12
+ from atomic_write import atomic_write_text # noqa: E402
13
+
11
14
 
12
15
  def main() -> int:
13
16
  graph_json = Path(sys.argv[1])
@@ -69,7 +72,7 @@ def main() -> int:
69
72
  content,
70
73
  )
71
74
  count_updated += 1
72
- path.write_text(content, encoding="utf-8")
75
+ atomic_write_text(path, content)
73
76
 
74
77
  print(f" concepts with cross-references: {count_updated}", file=sys.stderr)
75
78
  return 0
@@ -12,6 +12,9 @@ import re
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
+ sys.path.insert(0, str(Path(sys.argv[2]) / "scripts" / "lib"))
16
+ from atomic_write import atomic_write_text
17
+
15
18
  bugs_dir = Path(sys.argv[1])
16
19
  repo_root = Path(sys.argv[2])
17
20
 
@@ -61,7 +64,7 @@ for e in entries:
61
64
  val = e[opt].replace('"', "'")
62
65
  lines.append(f' {opt}: "{val}"')
63
66
  lines.append("")
64
- out.write_text("\n".join(lines), encoding="utf-8")
67
+ atomic_write_text(out, "\n".join(lines))
65
68
  print(f"sync-bugs-registry: {len(entries)} bugs -> {out}")
66
69
 
67
70
  # ---- Phase 2: Emit OKF concept bundles (e45s03) ----
@@ -96,7 +99,7 @@ references:
96
99
 
97
100
  See `specs/bugs/{bug_id}.md` for full investigation and fix details.
98
101
  """
99
- bundle_path.write_text(bundle, encoding="utf-8")
102
+ atomic_write_text(bundle_path, bundle)
100
103
  okf_count += 1
101
104
 
102
105
  print(f"sync-bugs-registry: {okf_count} OKF concept bundles -> {bugs_dir}/")
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env bash
2
+ # story: BUG-2026-11-03-atomic-yaml-writes
3
+ # test-atomic-writes.sh — regression guard for in-place YAML/JSON truncation (#181).
4
+ #
5
+ # `path.write_text(...)` truncates the destination, then streams the new bytes.
6
+ # A reader in that window sees a half file and dies with a YAML ScannerError.
7
+ # The fix writes a same-directory temp file, fsyncs it, and os.replace()s it.
8
+ #
9
+ # (a) anti-vacuity: the truncating shape IS observable by a concurrent reader
10
+ # (b) atomic_write_text is never observable half-written
11
+ # (c) static guard: the runtime state writers use the helper, not bare writes
12
+ #
13
+ # Usage: bash scripts/test-atomic-writes.sh
14
+
15
+ set -euo pipefail
16
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/skill-common.sh"
17
+ resolve_repo_root
18
+ cd "$REPO_ROOT"
19
+ source scripts/lib/python-env.sh
20
+ source scripts/lib/test-assertions.sh
21
+ resolve_python
22
+ TA_PASS=0
23
+ TA_FAIL=0
24
+
25
+ echo "=== test-atomic-writes.sh ==="
26
+
27
+ # ── (a)+(b) race harness ────────────────────────────────────────────────────
28
+ # One PROCESS writes a large YAML document N times while this process parses the
29
+ # file in a tight loop and counts parse errors. Two processes, not two threads:
30
+ # the GIL makes a thread-based control flaky, and a flaky anti-vacuity check is
31
+ # worse than none. Plain write_text must be caught (otherwise the harness is
32
+ # blind); atomic_write_text must never be caught.
33
+ RACE=$($PYTHON - <<'PY'
34
+ import os, subprocess, sys, tempfile
35
+ from pathlib import Path
36
+
37
+ KEYS = 400_000
38
+ PAYLOAD = "".join(f"key_{i}: {'x' * 40}\n" for i in range(KEYS)) # ~19 MB
39
+ EXPECTED = len(PAYLOAD.encode())
40
+ ITERATIONS = 8
41
+ ATTEMPTS = 3
42
+
43
+ PRODUCER = """
44
+ import sys
45
+ from pathlib import Path
46
+ sys.path.insert(0, "scripts/lib")
47
+ from atomic_write import atomic_write_text
48
+
49
+ mode, target, keys, iterations = sys.argv[1], Path(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])
50
+ payload = "".join(f"key_{i}: {'x' * 40}\\n" for i in range(keys))
51
+ for _ in range(iterations):
52
+ if mode == "plain":
53
+ target.write_text(payload, encoding="utf-8")
54
+ else:
55
+ atomic_write_text(target, payload)
56
+ """
57
+
58
+ def run(mode):
59
+ """Count samples where a reader saw a size that is neither empty nor final.
60
+
61
+ Sampling the size instead of parsing it is what makes the control reliable:
62
+ a 19 MB write keeps the truncate window open for milliseconds, so a poller
63
+ always lands inside it, while os.replace() swaps the inode and never shows
64
+ an intermediate size at all.
65
+ """
66
+ partial = 0
67
+ for _attempt in range(ATTEMPTS):
68
+ target = Path(tempfile.mkdtemp(prefix="atomic-race-")) / "state.yaml"
69
+ target.write_text(PAYLOAD, encoding="utf-8")
70
+ proc = subprocess.Popen([sys.executable, "-c", PRODUCER, mode, str(target), str(KEYS), str(ITERATIONS)])
71
+ seen = 0
72
+ while proc.poll() is None:
73
+ try:
74
+ size = os.path.getsize(target)
75
+ except FileNotFoundError:
76
+ size = -1
77
+ if size not in (EXPECTED, -1):
78
+ seen += 1
79
+ proc.wait()
80
+ partial += seen
81
+ if partial:
82
+ break
83
+ return partial
84
+
85
+ print(f"{run('plain')} {run('atomic')}")
86
+ PY
87
+ )
88
+
89
+ TRUNCATING_PARTIALS=$(echo "$RACE" | awk '{print $1}')
90
+ ATOMIC_PARTIALS=$(echo "$RACE" | awk '{print $2}')
91
+
92
+ [[ "${TRUNCATING_PARTIALS:-0}" -gt 0 ]] \
93
+ && ta_pass "(a) plain write_text exposes a partial file ($TRUNCATING_PARTIALS samples)" \
94
+ || ta_fail "(a) plain write_text was never caught — the harness cannot see the race"
95
+
96
+ [[ "${ATOMIC_PARTIALS:-1}" -eq 0 ]] \
97
+ && ta_pass "(b) atomic_write_text: 0 partial samples" \
98
+ || ta_fail "(b) atomic_write_text exposed $ATOMIC_PARTIALS partial samples"
99
+
100
+ # ── (c) static guard: runtime state writers go through the helper ───────────
101
+ # Fixtures, one-off migration patchers and test harnesses may write freely; the
102
+ # files that hold live spec state may not.
103
+ WATCHED=(
104
+ "scripts/yaml-tools.py"
105
+ "scripts/sync-bugs-registry.sh"
106
+ "scripts/lib/trace-stories.py"
107
+ "scripts/lib/trace_renderer.py"
108
+ "scripts/lib/blind-spots.py"
109
+ "scripts/lib/blind-spot-checks.py"
110
+ "scripts/lib/ab_report.py"
111
+ "scripts/lib/run-benchmark.py"
112
+ "scripts/okf-add-references.py"
113
+ "scripts/lib/srp_engine_okf.py"
114
+ )
115
+ HITS=""
116
+ for f in "${WATCHED[@]}"; do
117
+ [ -f "$f" ] || continue
118
+ # bare truncating writes: .write_text(...) or open(..., "w"), minus tmp targets
119
+ found=$(grep -nE '\.write_text\(|\.open\(["'"'"']w["'"'"']|open\([^)]*["'"'"']w["'"'"']' "$f" \
120
+ | grep -vE 'atomic_write|\.tmp|tmp_name' || true)
121
+ [ -n "$found" ] && HITS+="$f:"$'\n'"$found"$'\n'
122
+ done
123
+
124
+ [[ -z "$HITS" ]] \
125
+ && ta_pass "(c) all ${#WATCHED[@]} watched state writers use atomic_write" \
126
+ || ta_fail "(c) truncating writes still in state writers:"$'\n'"$HITS"
127
+
128
+ echo "$TA_PASS passed, $TA_FAIL failed"
129
+ [[ "$TA_FAIL" -eq 0 ]]
@@ -17,6 +17,9 @@ from typing import Any
17
17
 
18
18
  import yaml
19
19
 
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
21
+ from atomic_write import atomic_write_text # noqa: E402
22
+
20
23
 
21
24
  def set_path(path: Path, dotted_key: str, value: str) -> None:
22
25
  """Surgically set one dotted-path key, preserving everything else in the file."""
@@ -43,12 +46,12 @@ def set_path(path: Path, dotted_key: str, value: str) -> None:
43
46
  except ValueError:
44
47
  cur[last] = value.strip('"').strip("'")
45
48
 
46
- path.parent.mkdir(parents=True, exist_ok=True)
47
49
  # sort_keys=False preserves author-intended ordering (e.g. epics[]
48
50
  # sequencing by WSJF priority) instead of alphabetizing every key.
49
- path.write_text(
51
+ # atomic_write_text: a reader must never see this file truncated (#181).
52
+ atomic_write_text(
53
+ path,
50
54
  yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=100),
51
- encoding="utf-8",
52
55
  )
53
56
 
54
57