bigpowers 2.90.0 → 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.
- package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
- package/.pi/package.json +1 -1
- package/CHANGELOG.md +14 -0
- package/extensions/omp-hooks.ts +8 -0
- package/package.json +1 -1
- package/scripts/lib/__pycache__/atomic_write.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
- package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
- package/scripts/lib/ab_report.py +11 -4
- package/scripts/lib/atomic_write.py +71 -0
- package/scripts/lib/blind-spot-checks.py +3 -1
- package/scripts/lib/blind-spots.py +3 -1
- package/scripts/lib/golden-suite-gates.sh +2 -0
- package/scripts/lib/run-benchmark.py +7 -5
- package/scripts/lib/srp_engine_okf.py +6 -2
- package/scripts/lib/trace-stories.py +6 -4
- package/scripts/lib/trace_renderer.py +6 -4
- package/scripts/okf-add-references.py +4 -1
- package/scripts/omp-smoke.ts +27 -1
- package/scripts/sync-bugs-registry.sh +5 -2
- package/scripts/test-atomic-writes.sh +129 -0
- package/scripts/validate-omp-extension.sh +22 -0
- package/scripts/yaml-tools.py +6 -3
package/.pi/package.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
## [2.90.1](https://github.com/danielvm-git/bigpowers/compare/v2.90.0...v2.90.1) (2026-11-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **omp:** keep the tool_call branch probe quiet outside a git repo ([#190](https://github.com/danielvm-git/bigpowers/issues/190)) ([bcacf33](https://github.com/danielvm-git/bigpowers/commit/bcacf33ec72a8705c6b0dbbcf91f2ea2bed4b406)), closes [#189](https://github.com/danielvm-git/bigpowers/issues/189)
|
|
14
|
+
|
|
1
15
|
# [2.90.0](https://github.com/danielvm-git/bigpowers/compare/v2.89.1...v2.90.0) (2026-11-03)
|
|
2
16
|
|
|
3
17
|
|
package/extensions/omp-hooks.ts
CHANGED
|
@@ -205,11 +205,19 @@ function inGitRepo(cwd?: string): boolean {
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
function currentBranch(cwd: string): string {
|
|
208
|
+
// Probing a directory that is not a repository makes git print
|
|
209
|
+
// "fatal: not a git repository (or any of the parent directories): .git".
|
|
210
|
+
// execSync inherits fd 2 when no stdio option is given, so that message is
|
|
211
|
+
// painted straight over the TUI input line and vanishes on the next redraw
|
|
212
|
+
// (BUG-2026-11-03-omp-hooks-stderr-leak, issue #189). Ask inGitRepo() first —
|
|
213
|
+
// it is silent — so a non-repo target spawns no git process at all.
|
|
214
|
+
if (!inGitRepo(cwd)) return "unknown";
|
|
208
215
|
try {
|
|
209
216
|
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
210
217
|
encoding: "utf-8",
|
|
211
218
|
timeout: 3000,
|
|
212
219
|
cwd,
|
|
220
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
213
221
|
}).trim();
|
|
214
222
|
} catch {
|
|
215
223
|
return "unknown";
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/lib/ab_report.py
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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"
|
|
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"
|
|
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
|
|
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
|
|
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"
|
|
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"
|
|
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
|
|
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
|
package/scripts/omp-smoke.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// fails here exactly as it does in real pi, instead of silently passing.
|
|
11
11
|
// See pi 0.85.1 src/core/extensions/loader.ts (createExtensionRuntime).
|
|
12
12
|
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
14
16
|
import bigpowers from "../extensions/omp-hooks.ts";
|
|
15
17
|
|
|
16
18
|
type AnyObj = Record<string, unknown>;
|
|
@@ -118,3 +120,27 @@ console.log(JSON.stringify({
|
|
|
118
120
|
sendCalls,
|
|
119
121
|
runDetails: runResult.details,
|
|
120
122
|
}));
|
|
123
|
+
|
|
124
|
+
// Silent-probe check (BUG-2026-11-03-omp-hooks-stderr-leak, issue #189).
|
|
125
|
+
// The tool_call handler resolves the branch of the directory the command will
|
|
126
|
+
// run in. When that directory is not a repository, a git probe that inherits
|
|
127
|
+
// fd 2 paints "fatal: not a git repository" over the TUI. The handler must be
|
|
128
|
+
// invisible: validate-omp-extension.sh runs this mode with stderr captured and
|
|
129
|
+
// fails on any leaked git noise.
|
|
130
|
+
if (process.env.OMP_SMOKE_SILENT_TOOL_CALL === "1") {
|
|
131
|
+
const toolCall = events.get("tool_call");
|
|
132
|
+
if (typeof toolCall !== "function") throw new Error("tool_call handler not registered");
|
|
133
|
+
const nonRepo = mkdtempSync(join(tmpdir(), "omp-nonrepo-"));
|
|
134
|
+
try {
|
|
135
|
+
const verdict = await toolCall({
|
|
136
|
+
toolName: "bash",
|
|
137
|
+
input: { command: `cd ${nonRepo} && ls` },
|
|
138
|
+
});
|
|
139
|
+
// Fail-closed contract: a harmless command in a non-repo dir is not blocked.
|
|
140
|
+
if (verdict && (verdict as { block?: boolean }).block) {
|
|
141
|
+
throw new Error(`harmless command in a non-repo dir was blocked: ${JSON.stringify(verdict)}`);
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(nonRepo, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -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
|
|
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
|
|
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 ]]
|
|
@@ -121,6 +121,28 @@ else
|
|
|
121
121
|
rm -f "$SMOKE_ERR"
|
|
122
122
|
fi
|
|
123
123
|
|
|
124
|
+
# ── 7. tool_call is silent outside a repo (BUG-2026-11-03, issue #189) ────────
|
|
125
|
+
# A guard that inspects the command must not paint git's stderr on the agent
|
|
126
|
+
# TTY. The probe runs the REAL tool_call handler with a command that cds into a
|
|
127
|
+
# directory that is not a repository; any leaked "fatal:" lands on this
|
|
128
|
+
# script's captured stderr because execSync inherits fd 2.
|
|
129
|
+
echo "--- [OMP] tool_call stays quiet outside a git repo ---"
|
|
130
|
+
if [[ ! -f "$SMOKE" ]] || ! command -v node >/dev/null 2>&1; then
|
|
131
|
+
echo "warn: node or $SMOKE unavailable — skipping the silent-probe check"
|
|
132
|
+
else
|
|
133
|
+
SILENT_ERR=$(mktemp)
|
|
134
|
+
if OMP_SMOKE_SILENT_TOOL_CALL=1 node "$SMOKE" >/dev/null 2>"$SILENT_ERR"; then
|
|
135
|
+
if grep -qi 'fatal: not a git repository' "$SILENT_ERR"; then
|
|
136
|
+
ta_err "tool_call leaked git stderr onto the TUI: $(grep -i -m1 'fatal: not a git repository' "$SILENT_ERR")"
|
|
137
|
+
else
|
|
138
|
+
ta_ok "tool_call writes nothing to stderr for a non-repo target"
|
|
139
|
+
fi
|
|
140
|
+
else
|
|
141
|
+
ta_err "silent tool_call probe failed — $(head -3 "$SILENT_ERR" 2>/dev/null | tr '\n' ' ')"
|
|
142
|
+
fi
|
|
143
|
+
rm -f "$SILENT_ERR"
|
|
144
|
+
fi
|
|
145
|
+
|
|
124
146
|
# ── Summary ────────────────────────────────────────────────────────────────
|
|
125
147
|
echo "---"
|
|
126
148
|
if [[ "$ERRORS" -eq 0 ]]; then
|
package/scripts/yaml-tools.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|