simplicio-loop 3.23.0__tar.gz → 3.24.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {simplicio_loop-3.23.0/simplicio_loop.egg-info → simplicio_loop-3.24.0}/PKG-INFO +3 -3
  2. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/pyproject.toml +3 -3
  3. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/action_gate.py +43 -3
  4. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/loop_stop.py +78 -17
  5. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/simplicio_dashboard.py +19 -1
  6. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/simplicio_watch.py +20 -3
  7. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0/simplicio_loop.egg-info}/PKG-INFO +3 -3
  8. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop.egg-info/SOURCES.txt +2 -0
  9. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop.egg-info/requires.txt +2 -2
  10. simplicio_loop-3.24.0/tests/test_hbp_topics.py +299 -0
  11. simplicio_loop-3.24.0/tests/test_locked_append.py +168 -0
  12. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/LICENSE +0 -0
  13. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/MANIFEST.in +0 -0
  14. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/PYPI.md +0 -0
  15. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/README.md +0 -0
  16. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/setup.cfg +0 -0
  17. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/__init__.py +0 -0
  18. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/README.md +0 -0
  19. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/hooks.claude.json +0 -0
  20. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/hooks.json +0 -0
  21. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/loop_capture.py +0 -0
  22. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/orient_clamp.py +0 -0
  23. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/orient_rewrite.py +0 -0
  24. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/hooks/pre-commit.py +0 -0
  25. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/scripts/cross_agent_wiki.py +0 -0
  26. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/scripts/hierarchical_planner.py +0 -0
  27. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-autoresearch/SKILL.md +0 -0
  28. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-compress/SKILL.md +0 -0
  29. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-learn/SKILL.md +0 -0
  30. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/SKILL.md +0 -0
  31. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/agentsview-adapter.md +0 -0
  32. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/azure-devops-adapter.md +0 -0
  33. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/bound-operators.md +0 -0
  34. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/claims-gate.md +0 -0
  35. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/cross-agent-wiki.md +0 -0
  36. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/cross-repo-integration.md +0 -0
  37. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/extension-points.md +0 -0
  38. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/hierarchical-planner.md +0 -0
  39. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/lmcache-adapter.md +0 -0
  40. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/orchestration.md +0 -0
  41. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/quality-safety-delivery.md +0 -0
  42. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/run-journal-stall-detector.md +0 -0
  43. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/spindle-handoff.md +0 -0
  44. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/standing-loop-247.md +0 -0
  45. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/token-capture.md +0 -0
  46. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/token-economy.md +0 -0
  47. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/triage-verify-detail.md +0 -0
  48. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/understand-anything-adapter.md +0 -0
  49. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/video-evidence.md +0 -0
  50. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-loop/references/web-evidence.md +0 -0
  51. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-orient/SKILL.md +0 -0
  52. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-review/SKILL.md +0 -0
  53. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/SKILL.md +0 -0
  54. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/agentsview-adapter.md +0 -0
  55. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/azure-devops-adapter.md +0 -0
  56. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/extension-points.md +0 -0
  57. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/lmcache-adapter.md +0 -0
  58. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/orchestration.md +0 -0
  59. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/quality-safety-delivery.md +0 -0
  60. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/standing-loop-247.md +0 -0
  61. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/token-capture.md +0 -0
  62. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/token-economy.md +0 -0
  63. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/understand-anything-adapter.md +0 -0
  64. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/video-evidence.md +0 -0
  65. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/skills/simplicio-tasks/references/web-evidence.md +0 -0
  66. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/tests/_selfrun.py +0 -0
  67. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/tests/test_cross_agent_wiki.py +0 -0
  68. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/_bundle/tests/test_loop_e2e.py +0 -0
  69. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop/cli.py +0 -0
  70. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop.egg-info/dependency_links.txt +0 -0
  71. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop.egg-info/entry_points.txt +0 -0
  72. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/simplicio_loop.egg-info/top_level.txt +0 -0
  73. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_action_gate.py +0 -0
  74. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_agentsview_adapter.py +0 -0
  75. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_autoresearch.py +0 -0
  76. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_az_boards_adapter.py +0 -0
  77. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_claims_audit.py +0 -0
  78. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_claims_manifest_unit.py +0 -0
  79. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_cross_agent_wiki.py +0 -0
  80. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_dashboard_hook.py +0 -0
  81. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_doctor_smoke.py +0 -0
  82. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_evidence_chain.py +0 -0
  83. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_fan_out_flow.py +0 -0
  84. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_fan_out_unit.py +0 -0
  85. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_flow_audit.py +0 -0
  86. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_hierarchical_planner.py +0 -0
  87. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_hooks_coverage.py +0 -0
  88. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_impact_audit.py +0 -0
  89. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_install_lib.py +0 -0
  90. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_learn_pipeline_removed.py +0 -0
  91. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_loop_e2e.py +0 -0
  92. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_precommit_bundle_sync.py +0 -0
  93. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_repo_conventions_architecture.py +0 -0
  94. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_schema_verify_integration.py +0 -0
  95. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_schema_verify_unit.py +0 -0
  96. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_system_check.py +0 -0
  97. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_task_backlog.py +0 -0
  98. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_token_budget.py +0 -0
  99. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_toon_codec.py +0 -0
  100. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_verify_adapters_integration.py +0 -0
  101. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_worker_cli_contract.py +0 -0
  102. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_worker_selftests.py +0 -0
  103. {simplicio_loop-3.23.0 → simplicio_loop-3.24.0}/tests/test_worker_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simplicio-loop
3
- Version: 3.23.0
3
+ Version: 3.24.0
4
4
  Summary: The Universal Looping AI Orchestrator — a runtime-agnostic super-plugin (7 skills) that drains any queue of work end-to-end on any LLM/runtime.
5
5
  Author-email: Wesley Simplicio <wesleybob4@gmail.com>
6
6
  License: MIT
@@ -19,8 +19,8 @@ Classifier: Operating System :: OS Independent
19
19
  Requires-Python: >=3.8
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: simplicio-mapper>=0.17.0
23
- Requires-Dist: simplicio-cli>=0.9.6
22
+ Requires-Dist: simplicio-mapper>=0.19.0
23
+ Requires-Dist: simplicio-cli>=0.11.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7; extra == "dev"
26
26
  Provides-Extra: ml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simplicio-loop"
7
- version = "3.23.0"
7
+ version = "3.24.0"
8
8
  description = "The Universal Looping AI Orchestrator — a runtime-agnostic super-plugin (7 skills) that drains any queue of work end-to-end on any LLM/runtime."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.8"
@@ -25,8 +25,8 @@ classifiers = [
25
25
  # simplicio-mapper -> the repo-survey step (binds `orient`)
26
26
  # simplicio-cli -> the operator that applies+verifies changes (binds `execute`/`deterministic_edit`)
27
27
  dependencies = [
28
- "simplicio-mapper>=0.17.0",
29
- "simplicio-cli>=0.9.6",
28
+ "simplicio-mapper>=0.19.0",
29
+ "simplicio-cli>=0.11.0",
30
30
  ]
31
31
 
32
32
  # Optional: the real embedding backend for `simplicio-cli semantic --ml` / `simplicio-cli rag --ml`.
@@ -235,8 +235,46 @@ def gate_command(cmd, staged=False):
235
235
 
236
236
 
237
237
  # ---------------------------------------------------------------------------------------------
238
- def _emit_and_exit(verdict, pretooluse=False):
238
+ def _hbp_append_gate_blocked(reason, cmd=""):
239
+ """Record a gate BLOCK into the runtime's HBP tamper-evident hash chain (#128) — fail-open,
240
+ `simplicio`-only, mirrors `hooks/loop_stop.py`'s `_call_simplicio_hbp_append*` family. A
241
+ blocked mutation is exactly the kind of decision that should be provable later (a later
242
+ `simplicio hbp verify` can show this exact command/reason was recorded in order), not just
243
+ printed to stderr and forgotten. Absent binary or any failure: silent no-op, never blocks
244
+ the gate's own allow/block decision (that decision already happened before this is called).
245
+ """
246
+ binary = shutil.which("simplicio")
247
+ if not binary:
248
+ return
249
+ try:
250
+ import hashlib
251
+ # minimal payload contract shared by the #128 topics: fingerprint + attempt id.
252
+ fp = hashlib.sha1((reason or "").encode("utf-8", "replace")).hexdigest()[:12]
253
+ attempt = None
254
+ try: # attempt id = live loop iteration from the scratchpad frontmatter, when armed
255
+ with open(os.path.join(".orchestrator", "loop", "scratchpad.md"),
256
+ encoding="utf-8") as f:
257
+ m = re.search(r"^iteration:\s*(\d+)", f.read(), re.M)
258
+ attempt = int(m.group(1)) if m else None
259
+ except Exception:
260
+ attempt = None
261
+ payload = json.dumps({"fingerprint": fp, "attempt": attempt,
262
+ "reason": (reason or "")[:200], "command": (cmd or "")[:200]})
263
+ subprocess.run(
264
+ [binary, "hbp", "append",
265
+ "--topic", "loop-gate-blocked",
266
+ "--payload", payload,
267
+ "--provenance", "simplicio-loop",
268
+ "--json"],
269
+ capture_output=True, timeout=15,
270
+ )
271
+ except Exception:
272
+ pass
273
+
274
+
275
+ def _emit_and_exit(verdict, pretooluse=False, cmd=""):
239
276
  if verdict["action"] == "block":
277
+ _hbp_append_gate_blocked(verdict.get("reason", ""), cmd)
240
278
  if pretooluse:
241
279
  # Claude PreToolUse: exit 2 blocks the call; reason on stderr is fed back to the model.
242
280
  sys.stderr.write("action_gate BLOCK — " + verdict["reason"] + "\n")
@@ -251,7 +289,7 @@ def _emit_and_exit(verdict, pretooluse=False):
251
289
 
252
290
  def cmd_check(opts):
253
291
  cmd = opts.get("command", "")
254
- _emit_and_exit(gate_command(cmd, staged=bool(opts.get("staged"))))
292
+ _emit_and_exit(gate_command(cmd, staged=bool(opts.get("staged"))), cmd=cmd)
255
293
 
256
294
 
257
295
  def cmd_scan_diff(opts):
@@ -271,6 +309,8 @@ def cmd_scan_diff(opts):
271
309
  for label, n in hits:
272
310
  print(" secret: %s (line %d)" % (label, n))
273
311
  print("block")
312
+ _hbp_append_gate_blocked("secret in diff (%s)" % ", ".join(sorted({h[0] for h in hits})),
313
+ "scan-diff %s" % (src or "-"))
274
314
  sys.exit(2)
275
315
  print("allow")
276
316
  sys.exit(0)
@@ -292,7 +332,7 @@ def from_pretooluse():
292
332
  cmd = (data.get("tool_input", {}) or {}).get("command", "")
293
333
  if not cmd:
294
334
  sys.exit(0)
295
- _emit_and_exit(gate_command(cmd), pretooluse=True)
335
+ _emit_and_exit(gate_command(cmd), pretooluse=True, cmd=cmd)
296
336
 
297
337
 
298
338
  def cmd_selftest(_opts):
@@ -326,9 +326,12 @@ def _changed_files():
326
326
  commit). A heuristic, not a precise "since loop start" diff — fail-open: {} on any error.
327
327
 
328
328
  Excludes `.orchestrator/` (the loop's own state files — never source, and would otherwise
329
- make the receipt's own write, or a sibling state write, look like a "later" source change)
330
- and build/cache noise (`__pycache__`, `.pyc`) that this very check's own module import can
331
- createthat self-inflicted false-positive is exactly why both are filtered here.
329
+ make the receipt's own write, or a sibling state write, look like a "later" source change),
330
+ `.simplicio/` (the simplicio runtime/dev-cli's own state checkpoints, events.jsonl, survey
331
+ artifactswritten by this very hook's fire-and-forget CLI callouts mid-turn, the same
332
+ self-inflicted false-positive class), and build/cache noise (`__pycache__`, `.pyc`) that this
333
+ very check's own module import can create — those false-positives are exactly why all three
334
+ are filtered here.
332
335
  """
333
336
  out = set()
334
337
  for args in (
@@ -344,7 +347,8 @@ def _changed_files():
344
347
  continue
345
348
  return {
346
349
  f for f in out
347
- if not f.startswith(".orchestrator/") and "__pycache__" not in f and not f.endswith((".pyc", ".pyo"))
350
+ if not f.startswith((".orchestrator/", ".simplicio/"))
351
+ and "__pycache__" not in f and not f.endswith((".pyc", ".pyo"))
348
352
  }
349
353
 
350
354
 
@@ -518,6 +522,31 @@ def _call_simplicio_checkpoint(iteration):
518
522
  pass
519
523
 
520
524
 
525
+ def _call_simplicio_hbp_append_topic(topic, payload_dict):
526
+ """Generic fail-open append into the runtime's HBP tamper-evident hash chain
527
+ (`simplicio hbp append`), when the native `simplicio` binary is on PATH (#128).
528
+
529
+ Same contract as the promise-verified append below: `simplicio`-only, best-effort — absent
530
+ binary or any failure is a silent no-op that never blocks the caller's own decision. Every
531
+ new HBP point in this hook family (stall detected, gate blocked, run blocked) goes through
532
+ here so the topics all carry the same provenance and failure discipline.
533
+ """
534
+ binary = shutil.which("simplicio")
535
+ if not binary:
536
+ return
537
+ try:
538
+ subprocess.run(
539
+ [binary, "hbp", "append",
540
+ "--topic", topic,
541
+ "--payload", json.dumps(payload_dict),
542
+ "--provenance", "simplicio-loop",
543
+ "--json"],
544
+ capture_output=True, timeout=15,
545
+ )
546
+ except Exception:
547
+ pass
548
+
549
+
521
550
  def _call_simplicio_hbp_append(iteration, promise, watcher_tag):
522
551
  """Record the promise-verification decision into the runtime's HBP
523
552
  tamper-evident hash chain (`simplicio hbp append`), when the native
@@ -531,29 +560,44 @@ def _call_simplicio_hbp_append(iteration, promise, watcher_tag):
531
560
  recorded in order, without trusting this process's own say-so after the
532
561
  fact. Absent binary or any failure: silent no-op, never blocks the stop.
533
562
  """
534
- binary = shutil.which("simplicio")
535
- if not binary:
536
- return
537
563
  try:
538
564
  anchor = read_anchor() or {}
539
- payload = json.dumps({
565
+ _call_simplicio_hbp_append_topic("loop-promise-verified", {
540
566
  "iteration": iteration,
541
567
  "promise": promise,
542
568
  "watcher_tag": watcher_tag,
543
569
  "goal_fp": anchor.get("goal_fp", ""),
544
570
  })
545
- subprocess.run(
546
- [binary, "hbp", "append",
547
- "--topic", "loop-promise-verified",
548
- "--payload", payload,
549
- "--provenance", "simplicio-loop",
550
- "--json"],
551
- capture_output=True, timeout=15,
552
- )
553
571
  except Exception:
554
572
  pass
555
573
 
556
574
 
575
+ def _journal_stall(k=3):
576
+ """Trailing same-fingerprint failure streak from the journal — (fingerprint, streak).
577
+
578
+ Mirrors `scripts/loop_journal.py`'s `analyze()` trailing-streak math over the tail of the
579
+ journal, locally and fail-open (("", 0) on any error) — the hook must never import the
580
+ worker to decide whether to emit the `loop-stall-detected` HBP record (#128).
581
+ """
582
+ try:
583
+ rows = tail_journal(n=max(k * 3, 12))
584
+ if not rows:
585
+ return "", 0
586
+ last = rows[-1]
587
+ fp = last.get("fingerprint", "")
588
+ if last.get("gate") == "pass" or not fp:
589
+ return "", 0
590
+ streak = 0
591
+ for r in reversed(rows):
592
+ if r.get("gate") != "pass" and r.get("fingerprint") == fp:
593
+ streak += 1
594
+ else:
595
+ break
596
+ return fp, streak
597
+ except Exception:
598
+ return "", 0
599
+
600
+
557
601
  def read_watcher_challenge():
558
602
  """Return the current per-iteration watcher challenge dict, or None. Fail-open."""
559
603
  try:
@@ -781,7 +825,11 @@ def main():
781
825
  # hand-survey/hand-edit.
782
826
  missing_ops = missing_bound_operators()
783
827
  if missing_ops:
784
- write_handoff("bound operator missing: %s" % ", ".join(missing_ops), meta, body)
828
+ reason = "bound operator missing: %s" % ", ".join(missing_ops)
829
+ _call_simplicio_hbp_append_topic("loop-run-blocked", {
830
+ "fingerprint": _journal_stall()[0], "attempt": iteration, "reason": reason,
831
+ })
832
+ write_handoff(reason, meta, body)
785
833
  cleanup_and_stop()
786
834
 
787
835
  stdin = read_stdin_json()
@@ -792,6 +840,15 @@ def main():
792
840
  # this turn even if the agent forgot the manual `loop_journal.py record` call.
793
841
  auto_record_journal(iteration, has_evidence)
794
842
 
843
+ # HBP point: stall detected (#128) — when the trailing journal streak crosses the same
844
+ # K=3 threshold the stall detector/planner use, record it into the tamper-evident chain
845
+ # so a later `simplicio hbp verify` can prove WHEN the loop knew it was stuck. Fail-open.
846
+ stall_fp, stall_streak = _journal_stall()
847
+ if stall_streak >= 3:
848
+ _call_simplicio_hbp_append_topic("loop-stall-detected", {
849
+ "fingerprint": stall_fp, "attempt": iteration, "streak": stall_streak,
850
+ })
851
+
795
852
  # HRM-style hierarchical planner: re-assess phase on stall or every N iterations.
796
853
  # Runs BEFORE the promise gate so the phase context is available.
797
854
  _call_hierarchical_planner()
@@ -824,6 +881,10 @@ def main():
824
881
  cleanup_and_stop()
825
882
  # (4) Iteration cap — incomplete stop, hand off.
826
883
  if max_iter > 0 and iteration >= max_iter:
884
+ _call_simplicio_hbp_append_topic("loop-run-blocked", {
885
+ "fingerprint": _journal_stall()[0], "attempt": iteration,
886
+ "reason": "max_iterations cap reached",
887
+ })
827
888
  write_handoff("max_iterations cap reached", meta, body)
828
889
  cleanup_and_stop()
829
890
  # (5) Spindle handoff — latched handoff overrides re-feed.
@@ -27,6 +27,15 @@ from pathlib import Path
27
27
 
28
28
  HOME = os.path.expanduser("~")
29
29
  REPO_ROOT = Path(__file__).resolve().parents[1]
30
+ _SCRIPTS_DIR = str(REPO_ROOT / "scripts")
31
+ if _SCRIPTS_DIR not in sys.path:
32
+ sys.path.insert(0, _SCRIPTS_DIR)
33
+ try:
34
+ # tolerant JSONL reader (#127) — see hooks/simplicio_watch.py for the same rationale: the
35
+ # savings ledger is written by the external `simplicio` runtime, never by this repo.
36
+ from _locked_append import count_jsonl_lines
37
+ except Exception:
38
+ count_jsonl_lines = None
30
39
  # Structured savings file written by the capture engine — the primary data source.
31
40
  SAVINGS_JSON_CANDIDATES = [
32
41
  Path(HOME) / ".simplicio" / "proxy_savings.json",
@@ -706,8 +715,16 @@ def get_status():
706
715
  except (IndexError, ValueError):
707
716
  pass
708
717
 
718
+ # Tolerant count (#127): a truncated/illegible line is counted separately, not folded into
719
+ # the event total as if it were real data.
709
720
  ledger = REPO_ROOT / ".simplicio" / "ledger" / "savings-events.jsonl"
710
- lc = sum(1 for _ in ledger.open(errors="replace")) if ledger.exists() else 0
721
+ if ledger.exists() and count_jsonl_lines is not None:
722
+ lc, ledger_corrupt = count_jsonl_lines(str(ledger))
723
+ elif ledger.exists():
724
+ lc = sum(1 for _ in ledger.open(errors="replace"))
725
+ ledger_corrupt = 0
726
+ else:
727
+ lc = ledger_corrupt = 0
711
728
 
712
729
  savings_pct = round((tok_saved / max(tok_before, 1)) * 100, 1)
713
730
 
@@ -751,6 +768,7 @@ def get_status():
751
768
  "cache_hit_pct": cache_hit,
752
769
  "memories": mem,
753
770
  "ledger_events": lc,
771
+ "ledger_corrupt_lines": ledger_corrupt,
754
772
  "series": series,
755
773
  "models_seen": models_seen[:8],
756
774
  "active_model": active_model,
@@ -18,6 +18,17 @@ from pathlib import Path
18
18
  HOME = os.path.expanduser("~")
19
19
  LOGS = os.path.join(HOME, ".simplicio", "logs")
20
20
  REPO_ROOT = Path(__file__).resolve().parents[1]
21
+ _SCRIPTS_DIR = str(REPO_ROOT / "scripts")
22
+ if _SCRIPTS_DIR not in sys.path:
23
+ sys.path.insert(0, _SCRIPTS_DIR)
24
+ try:
25
+ # tolerant JSONL reader (#127) — the savings ledger is written by the external `simplicio`
26
+ # runtime, never by this repo; we only ever READ it, so hardening here means counting valid
27
+ # vs. corrupt/truncated lines instead of a naive `sum(1 for _ in file)` that treats a torn
28
+ # line the same as a real event. Fail-open: missing helper -> fall back to the raw count.
29
+ from _locked_append import count_jsonl_lines
30
+ except Exception:
31
+ count_jsonl_lines = None
21
32
  # Native engine: invoked cross-platform via this interpreter (no external binary).
22
33
  ENGINE_CMD = [sys.executable or "python3", str(REPO_ROOT / "engine" / "simplicio_engine.py")]
23
34
  PROXY_PORT = os.environ.get("SIMPLICIO_PROXY_PORT", "8788")
@@ -50,11 +61,17 @@ def status():
50
61
  for line in out2.split("\n"):
51
62
  if "Total" in line or "Database" in line:
52
63
  log(f" {line.strip()}")
53
- # Savings ledger
64
+ # Savings ledger — tolerant count (#127): a truncated/illegible line is counted, not silently
65
+ # folded into the total as if it were a real event.
54
66
  ledger = REPO_ROOT / ".simplicio" / "ledger" / "savings-events.jsonl"
55
67
  if ledger.is_file():
56
- total = sum(1 for _ in ledger.open(errors="replace"))
57
- log(f" 💰 Savings ledger: {total} events")
68
+ if count_jsonl_lines is not None:
69
+ valid, corrupt = count_jsonl_lines(str(ledger))
70
+ suffix = f" ({corrupt} corrupt line(s) skipped)" if corrupt else ""
71
+ log(f" 💰 Savings ledger: {valid} events{suffix}")
72
+ else:
73
+ total = sum(1 for _ in ledger.open(errors="replace"))
74
+ log(f" 💰 Savings ledger: {total} events")
58
75
  log(f" 🪵 Logs: {LOGS}/proxy.log")
59
76
  return 0 if running else 1
60
77
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simplicio-loop
3
- Version: 3.23.0
3
+ Version: 3.24.0
4
4
  Summary: The Universal Looping AI Orchestrator — a runtime-agnostic super-plugin (7 skills) that drains any queue of work end-to-end on any LLM/runtime.
5
5
  Author-email: Wesley Simplicio <wesleybob4@gmail.com>
6
6
  License: MIT
@@ -19,8 +19,8 @@ Classifier: Operating System :: OS Independent
19
19
  Requires-Python: >=3.8
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
- Requires-Dist: simplicio-mapper>=0.17.0
23
- Requires-Dist: simplicio-cli>=0.9.6
22
+ Requires-Dist: simplicio-mapper>=0.19.0
23
+ Requires-Dist: simplicio-cli>=0.11.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: pytest>=7; extra == "dev"
26
26
  Provides-Extra: ml
@@ -79,11 +79,13 @@ tests/test_evidence_chain.py
79
79
  tests/test_fan_out_flow.py
80
80
  tests/test_fan_out_unit.py
81
81
  tests/test_flow_audit.py
82
+ tests/test_hbp_topics.py
82
83
  tests/test_hierarchical_planner.py
83
84
  tests/test_hooks_coverage.py
84
85
  tests/test_impact_audit.py
85
86
  tests/test_install_lib.py
86
87
  tests/test_learn_pipeline_removed.py
88
+ tests/test_locked_append.py
87
89
  tests/test_loop_e2e.py
88
90
  tests/test_precommit_bundle_sync.py
89
91
  tests/test_repo_conventions_architecture.py
@@ -1,5 +1,5 @@
1
- simplicio-mapper>=0.17.0
2
- simplicio-cli>=0.9.6
1
+ simplicio-mapper>=0.19.0
2
+ simplicio-cli>=0.11.0
3
3
 
4
4
  [dev]
5
5
  pytest>=7
@@ -0,0 +1,299 @@
1
+ """#128: journal consumes dev-cli events + the new HBP evidence-chain points.
2
+
3
+ Two halves, both deterministic:
4
+
5
+ 1. `scripts/loop_journal.py` optionally folds the dev-cli's OWN event log
6
+ (`<repo>/.simplicio/events.jsonl`, schema `simplicio.dev-cli-event/v1` — the documented
7
+ contract from simplicio-dev-cli's `observability.emit_event`, read by shape, never imported)
8
+ into the attempt fingerprint + stall detector: a repeated `validation_fail` on the same
9
+ target streaks to STALLED exactly like a repeated journal failure; `edit_applied`/
10
+ `task_complete` reset the streak; a missing/corrupt file changes nothing (fail-open).
11
+
12
+ 2. The three new HBP producers append to the runtime's tamper-evident chain via
13
+ `simplicio hbp append` and are FAIL-OPEN like the pre-existing promise-verified append
14
+ (`hooks/loop_stop.py::_call_simplicio_hbp_append`): with a FAKE `simplicio` binary on PATH the
15
+ exact topic + payload go through; with the binary ABSENT the caller degrades silently — never
16
+ a crash, never a changed decision. (Producer discipline per tests/test_evidence_chain.py:
17
+ prove the positive chain with fixtures shaped like the real producer's output, and prove the
18
+ absent-toolchain path degrades rather than fakes.)
19
+ """
20
+ import json
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+
26
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
+ sys.path.insert(0, os.path.join(REPO, "scripts"))
28
+ import loop_journal as lj # noqa: E402
29
+
30
+ HOOK = os.path.join(REPO, "hooks", "loop_stop.py")
31
+ GATE = os.path.join(REPO, "hooks", "action_gate.py")
32
+
33
+ SCHEMA = "simplicio.dev-cli-event/v1"
34
+
35
+
36
+ def _event(event, target="src/app.py", ts="2026-07-09T00:00:00Z", warnings=None, attempt=1):
37
+ """A record shaped exactly like observability.emit_event()'s documented on-disk contract."""
38
+ return {"schema": SCHEMA, "ts": ts, "event": event, "level": "info",
39
+ "payload": {"target": target, "attempt": attempt, "warnings": warnings or []}}
40
+
41
+
42
+ def _write_events(root, records, extra_lines=()):
43
+ d = os.path.join(root, ".simplicio")
44
+ os.makedirs(d, exist_ok=True)
45
+ with open(os.path.join(d, "events.jsonl"), "w", encoding="utf-8") as f:
46
+ for rec in records:
47
+ f.write(json.dumps(rec) + "\n")
48
+ for ln in extra_lines:
49
+ f.write(ln + "\n")
50
+
51
+
52
+ # ── half 1: dev-cli events -> stall detector ────────────────────────────────────────────────
53
+
54
+
55
+ def test_missing_events_file_changes_nothing(tmp_path):
56
+ events, corrupt = lj.load_dev_cli_events(str(tmp_path))
57
+ assert events == [] and corrupt == 0
58
+ rows = [{"gate": "fail", "fingerprint": "aaa", "ts": "t", "action": "x"}]
59
+ assert lj.merge_dev_cli(rows, events) == rows # identical behavior to today
60
+
61
+
62
+ def test_repeated_validation_fail_same_target_streaks_to_stalled(tmp_path):
63
+ root = str(tmp_path)
64
+ _write_events(root, [
65
+ _event("validation_fail", ts="2026-07-09T00:00:0%dZ" % i, warnings=["pytest failed"])
66
+ for i in (1, 2, 3)
67
+ ])
68
+ events, corrupt = lj.load_dev_cli_events(root)
69
+ assert len(events) == 3 and corrupt == 0
70
+ verdict = lj.analyze(lj.merge_dev_cli([], events), 3)
71
+ assert verdict["verdict"] == "STALLED", verdict
72
+ assert verdict["stall_count"] == 3
73
+
74
+
75
+ def test_different_targets_do_not_streak(tmp_path):
76
+ root = str(tmp_path)
77
+ _write_events(root, [
78
+ _event("validation_fail", target="a.py", ts="2026-07-09T00:00:01Z", warnings=["boom"]),
79
+ _event("validation_fail", target="b.py", ts="2026-07-09T00:00:02Z", warnings=["boom"]),
80
+ _event("validation_fail", target="c.py", ts="2026-07-09T00:00:03Z", warnings=["boom"]),
81
+ ])
82
+ events, _ = lj.load_dev_cli_events(root)
83
+ assert lj.analyze(lj.merge_dev_cli([], events), 3)["verdict"] == "PROGRESS"
84
+
85
+
86
+ def test_task_complete_resets_the_streak(tmp_path):
87
+ root = str(tmp_path)
88
+ _write_events(root, [
89
+ _event("validation_fail", ts="2026-07-09T00:00:01Z", warnings=["boom"]),
90
+ _event("validation_fail", ts="2026-07-09T00:00:02Z", warnings=["boom"]),
91
+ _event("validation_fail", ts="2026-07-09T00:00:03Z", warnings=["boom"]),
92
+ _event("task_complete", ts="2026-07-09T00:00:04Z"),
93
+ ])
94
+ events, _ = lj.load_dev_cli_events(root)
95
+ assert lj.analyze(lj.merge_dev_cli([], events), 3)["verdict"] == "PROGRESS"
96
+
97
+
98
+ def test_events_merge_chronologically_with_journal_rows(tmp_path):
99
+ # A journal fail + two dev-cli fails on the same signature must form ONE trailing streak.
100
+ root = str(tmp_path)
101
+ ev = [_event("validation_fail", ts="2026-07-09T00:00:0%dZ" % i, warnings=["boom"])
102
+ for i in (2, 3)]
103
+ _write_events(root, ev)
104
+ events, _ = lj.load_dev_cli_events(root)
105
+ fp = lj._dev_cli_fingerprint(ev[0])
106
+ rows = [{"gate": "fail", "fingerprint": fp, "ts": "2026-07-09T00:00:01Z", "action": "manual"}]
107
+ verdict = lj.analyze(lj.merge_dev_cli(rows, events), 3)
108
+ assert verdict["verdict"] == "STALLED", verdict
109
+ assert verdict["stall_count"] == 3
110
+
111
+
112
+ def test_corrupt_and_foreign_lines_are_tolerated(tmp_path):
113
+ root = str(tmp_path)
114
+ _write_events(root, [_event("validation_fail", warnings=["boom"])],
115
+ extra_lines=['{"torn json', json.dumps({"schema": "other/v9", "event": "x"}),
116
+ json.dumps(["not", "a", "dict"])])
117
+ events, corrupt = lj.load_dev_cli_events(root)
118
+ assert len(events) == 1 # only the real dev-cli event
119
+ assert corrupt == 1 # only the torn line counts as corrupt; foreign schema is skipped
120
+
121
+
122
+ def test_stall_cli_consumes_events_via_events_root(tmp_path):
123
+ # End-to-end through the real CLI: copy the worker (+ its sibling imports) into an isolated
124
+ # repo dir, journal EMPTY, only dev-cli events present -> `stall --format json` = STALLED.
125
+ root = str(tmp_path)
126
+ scripts = os.path.join(root, "scripts")
127
+ os.makedirs(scripts)
128
+ for mod in ("loop_journal.py", "toon_codec.py", "_locked_append.py"):
129
+ shutil.copy2(os.path.join(REPO, "scripts", mod), os.path.join(scripts, mod))
130
+ _write_events(root, [
131
+ _event("validation_fail", ts="2026-07-09T00:00:0%dZ" % i, warnings=["pytest failed"])
132
+ for i in (1, 2, 3)
133
+ ])
134
+ r = subprocess.run([sys.executable, os.path.join(scripts, "loop_journal.py"),
135
+ "stall", "--format", "json"],
136
+ capture_output=True, text=True, cwd=root)
137
+ assert r.returncode == 0, r.stdout + r.stderr
138
+ verdict = json.loads(r.stdout)
139
+ assert verdict["verdict"] == "STALLED", verdict
140
+ # and --events-root pointing somewhere empty restores today's behavior
141
+ empty = os.path.join(root, "elsewhere")
142
+ os.makedirs(empty)
143
+ r2 = subprocess.run([sys.executable, os.path.join(scripts, "loop_journal.py"),
144
+ "stall", "--format", "json", "--events-root", empty],
145
+ capture_output=True, text=True, cwd=root)
146
+ assert json.loads(r2.stdout)["verdict"] == "PROGRESS", r2.stdout
147
+
148
+
149
+ # ── half 2: HBP producers (fake / absent binary) ────────────────────────────────────────────
150
+
151
+
152
+ def _fake_simplicio(bin_dir, log_path):
153
+ """A fake `simplicio` binary that appends its argv (one call per line) to log_path."""
154
+ os.makedirs(bin_dir, exist_ok=True)
155
+ if os.name == "nt": # pragma: no cover — POSIX CI
156
+ path = os.path.join(bin_dir, "simplicio.bat")
157
+ with open(path, "w", encoding="utf-8") as f:
158
+ f.write('@echo off\r\necho %* >> "%s"\r\n' % log_path)
159
+ else:
160
+ path = os.path.join(bin_dir, "simplicio")
161
+ with open(path, "w", encoding="utf-8") as f:
162
+ f.write('#!/bin/sh\nprintf \'%s\\n\' "$*" >> "{}"\n'.format(log_path))
163
+ os.chmod(path, 0o755)
164
+ return path
165
+
166
+
167
+ def _calls(log_path):
168
+ if not os.path.exists(log_path):
169
+ return []
170
+ with open(log_path, encoding="utf-8") as f:
171
+ return [ln.rstrip("\n") for ln in f if ln.strip()]
172
+
173
+
174
+ SCRATCHPAD = """---
175
+ iteration: {iteration}
176
+ max_iterations: {max_iter}
177
+ completion_promise: "SIMPLICIO_DONE"
178
+ evidence_required: true
179
+ started_at: "2026-06-24T00:00:00Z"
180
+ ---
181
+ Implement the thing and prove it works.
182
+ """
183
+
184
+
185
+ def _arm(root, iteration=1, max_iter=5):
186
+ loop = os.path.join(root, ".orchestrator", "loop")
187
+ os.makedirs(loop, exist_ok=True)
188
+ with open(os.path.join(loop, "scratchpad.md"), "w", encoding="utf-8") as f:
189
+ f.write(SCRATCHPAD.format(iteration=iteration, max_iter=max_iter))
190
+ return loop
191
+
192
+
193
+ def _write_stalled_journal(root, fp="deadbeef0001", n=3):
194
+ loop = os.path.join(root, ".orchestrator", "loop")
195
+ os.makedirs(loop, exist_ok=True)
196
+ with open(os.path.join(loop, "journal.jsonl"), "w", encoding="utf-8") as f:
197
+ for i in range(1, n + 1):
198
+ f.write(json.dumps({"iteration": i, "action": "retry fetch", "gate": "fail",
199
+ "fingerprint": fp, "ts": "2026-07-09T00:00:0%dZ" % i}) + "\n")
200
+
201
+
202
+ def _tick(root, text, env):
203
+ return subprocess.run([sys.executable, HOOK], input=json.dumps({"text": text}),
204
+ capture_output=True, text=True, cwd=root, env=env)
205
+
206
+
207
+ def test_stall_detected_appends_hbp_topic(tmp_path):
208
+ root = str(tmp_path)
209
+ _arm(root, iteration=3, max_iter=10)
210
+ _write_stalled_journal(root)
211
+ log = os.path.join(root, "hbp_calls.log")
212
+ bin_dir = os.path.join(root, "bin")
213
+ _fake_simplicio(bin_dir, log)
214
+ env = dict(os.environ, PATH=bin_dir + os.pathsep + os.environ.get("PATH", ""))
215
+ _tick(root, "still failing, no promise", env)
216
+ calls = _calls(log)
217
+ stall = [c for c in calls if "loop-stall-detected" in c]
218
+ assert stall, "a 3-deep same-fingerprint streak must append loop-stall-detected: %s" % calls
219
+ assert "deadbeef0001" in stall[0], stall[0]
220
+ assert "hbp append" in stall[0]
221
+
222
+
223
+ def test_run_blocked_on_cap_appends_hbp_topic(tmp_path):
224
+ root = str(tmp_path)
225
+ _arm(root, iteration=5, max_iter=5) # at the cap -> run blocked
226
+ log = os.path.join(root, "hbp_calls.log")
227
+ bin_dir = os.path.join(root, "bin")
228
+ _fake_simplicio(bin_dir, log)
229
+ env = dict(os.environ, PATH=bin_dir + os.pathsep + os.environ.get("PATH", ""))
230
+ r = _tick(root, "still working", env)
231
+ assert r.stdout.strip() == "", "cap must stop the loop:\n%s" % r.stdout
232
+ blocked = [c for c in _calls(log) if "loop-run-blocked" in c]
233
+ assert blocked, "cap-reached stop must append loop-run-blocked: %s" % _calls(log)
234
+ assert "max_iterations cap reached" in blocked[0]
235
+
236
+
237
+ def test_run_blocked_on_missing_operator_appends_hbp_topic(tmp_path):
238
+ root = str(tmp_path)
239
+ _arm(root, iteration=1, max_iter=9)
240
+ skill_dir = os.path.join(root, ".claude", "skills", "simplicio-loop")
241
+ os.makedirs(skill_dir, exist_ok=True)
242
+ open(os.path.join(skill_dir, "SKILL.md"), "w").close()
243
+ log = os.path.join(root, "hbp_calls.log")
244
+ bin_dir = os.path.join(root, "bin")
245
+ _fake_simplicio(bin_dir, log) # simplicio IS on PATH; mapper/dev-cli are NOT
246
+ env = dict(os.environ, PATH=bin_dir)
247
+ r = _tick(root, "no promise yet", env)
248
+ assert r.stdout.strip() == "", r.stdout
249
+ blocked = [c for c in _calls(log) if "loop-run-blocked" in c]
250
+ assert blocked, "operator-missing block must append loop-run-blocked: %s" % _calls(log)
251
+ assert "bound operator missing" in blocked[0]
252
+
253
+
254
+ def test_gate_blocked_appends_hbp_topic(tmp_path):
255
+ root = str(tmp_path)
256
+ os.makedirs(os.path.join(root, ".orchestrator"), exist_ok=True) # project-relevance marker
257
+ log = os.path.join(root, "hbp_calls.log")
258
+ bin_dir = os.path.join(root, "bin")
259
+ _fake_simplicio(bin_dir, log)
260
+ env = dict(os.environ, PATH=bin_dir + os.pathsep + os.environ.get("PATH", ""))
261
+ r = subprocess.run([sys.executable, GATE, "check", "--command",
262
+ "git push --force origin main"],
263
+ capture_output=True, text=True, cwd=root, env=env)
264
+ assert r.returncode == 2, "force-push must still be blocked (exit 2)"
265
+ blocked = [c for c in _calls(log) if "loop-gate-blocked" in c]
266
+ assert blocked, "a gate BLOCK must append loop-gate-blocked: %s" % _calls(log)
267
+ assert "fingerprint" in blocked[0]
268
+
269
+
270
+ def test_all_producers_degrade_silently_without_binary(tmp_path):
271
+ # Absent `simplicio`: every path still reaches its own decision — never a crash, no append.
272
+ root = str(tmp_path)
273
+ empty = os.path.join(root, "empty-bin")
274
+ os.makedirs(empty)
275
+ env = dict(os.environ, PATH=empty)
276
+
277
+ # stall path (journal stalled, no binary): hook still re-feeds normally
278
+ _arm(root, iteration=3, max_iter=10)
279
+ _write_stalled_journal(root)
280
+ r = _tick(root, "still failing, no promise", env)
281
+ assert r.returncode == 0
282
+ assert "followup_message" in r.stdout, "stall append absent-binary must not break the re-feed"
283
+
284
+ # cap path: still a clean stop
285
+ _arm(root, iteration=10, max_iter=10)
286
+ r = _tick(root, "still working", env)
287
+ assert r.returncode == 0 and r.stdout.strip() == ""
288
+
289
+ # gate path: still blocks with exit 2 (PATH needs git for the classify-only path — none used)
290
+ r = subprocess.run([sys.executable, GATE, "check", "--command",
291
+ "git push --force origin main"],
292
+ capture_output=True, text=True, cwd=root, env=env)
293
+ assert r.returncode == 2, "gate must still block without the runtime binary"
294
+
295
+
296
+ if __name__ == "__main__":
297
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
298
+ from _selfrun import run_module
299
+ run_module(globals(), "test_hbp_topics")
@@ -0,0 +1,168 @@
1
+ """scripts/_locked_append.py — locked JSONL append + tolerant-count helpers (issue #127).
2
+
3
+ Two layers of proof:
4
+ 1. Unit-level: the helper appends valid JSON, creates parent dirs, and — critically — FAILS
5
+ OPEN (skips the write, never writes unlocked/partial) when the lock can't be acquired within
6
+ its timeout.
7
+ 2. Concurrency: K REAL OS processes (`multiprocessing.Process`, not threads — threads never
8
+ exercise actual cross-process file locking) each append M lines to the SAME journal-shaped
9
+ file at once. Post-condition: exactly K*M lines, every single one valid JSON. This is the
10
+ regression guard for the exact bug class #127 fixes: two writers racing an unlocked
11
+ `open(path, "a").write()` interleaving into a torn/unparseable line.
12
+ """
13
+ import json
14
+ import multiprocessing
15
+ import os
16
+ import sys
17
+ import time
18
+
19
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20
+ sys.path.insert(0, os.path.join(REPO, "scripts"))
21
+ import _locked_append as la # noqa: E402
22
+
23
+ K_WORKERS = 8
24
+ M_LINES = 40
25
+
26
+
27
+ def test_appends_valid_json_line(tmp_path):
28
+ path = str(tmp_path / "journal.jsonl")
29
+ assert la.locked_append_line(path, json.dumps({"a": 1})) is True
30
+ assert la.locked_append_line(path, json.dumps({"a": 2})) is True
31
+ with open(path, encoding="utf-8") as f:
32
+ lines = [ln for ln in f if ln.strip()]
33
+ assert len(lines) == 2
34
+ assert json.loads(lines[0]) == {"a": 1}
35
+ assert json.loads(lines[1]) == {"a": 2}
36
+
37
+
38
+ def test_adds_trailing_newline_when_missing(tmp_path):
39
+ path = str(tmp_path / "j.jsonl")
40
+ la.locked_append_line(path, '{"x":1}') # no trailing \n supplied
41
+ la.locked_append_line(path, '{"x":2}')
42
+ with open(path, encoding="utf-8") as f:
43
+ text = f.read()
44
+ assert text == '{"x":1}\n{"x":2}\n'
45
+
46
+
47
+ def test_creates_parent_dirs(tmp_path):
48
+ path = str(tmp_path / "nested" / "deep" / "journal.jsonl")
49
+ assert la.locked_append_line(path, json.dumps({"ok": True})) is True
50
+ assert os.path.exists(path)
51
+
52
+
53
+ def test_flush_and_fsync_survive_before_lock_release(tmp_path):
54
+ # Not directly observable from the outside, but a write that returns True must be durably on
55
+ # disk (readable) immediately after the call returns — this is the contract fsync buys us.
56
+ path = str(tmp_path / "durable.jsonl")
57
+ la.locked_append_line(path, json.dumps({"durable": True}))
58
+ with open(path, encoding="utf-8") as f:
59
+ assert json.loads(f.read().strip()) == {"durable": True}
60
+
61
+
62
+ def test_timeout_skips_write_never_writes_unlocked(tmp_path):
63
+ """Fail-open contract: if the lock is already held (by THIS process, a different fd — POSIX
64
+ flock is per-open-file-description, so a second fd contends genuinely), a short-timeout call
65
+ must return False and must NOT append anything — never a partial/unlocked write."""
66
+ if la.fcntl is None:
67
+ return # POSIX-only path exercised here; Windows CI (if any) skips this specific check
68
+ path = str(tmp_path / "contended.jsonl")
69
+ lock_path = la._lock_path(path)
70
+ la._ensure_lock_file(lock_path)
71
+ held_fh = open(lock_path, "a+b")
72
+ la.fcntl.flock(held_fh.fileno(), la.fcntl.LOCK_EX)
73
+ try:
74
+ ok = la.locked_append_line(path, json.dumps({"should": "not-write"}), timeout_ms=80)
75
+ assert ok is False
76
+ assert not os.path.exists(path), "a timed-out lock must never write, not even partially"
77
+ finally:
78
+ la.fcntl.flock(held_fh.fileno(), la.fcntl.LOCK_UN)
79
+ held_fh.close()
80
+ # once the lock is free again, a normal call succeeds
81
+ assert la.locked_append_line(path, json.dumps({"now": "ok"})) is True
82
+
83
+
84
+ def test_count_jsonl_lines_valid_only(tmp_path):
85
+ path = tmp_path / "clean.jsonl"
86
+ path.write_text('{"a":1}\n{"a":2}\n{"a":3}\n', encoding="utf-8")
87
+ valid, corrupt = la.count_jsonl_lines(str(path))
88
+ assert (valid, corrupt) == (3, 0)
89
+
90
+
91
+ def test_count_jsonl_lines_counts_corrupt_without_raising(tmp_path):
92
+ path = tmp_path / "dirty.jsonl"
93
+ path.write_text('{"a":1}\n{"a":2\nnot json at all\n{"a":3}\n', encoding="utf-8")
94
+ valid, corrupt = la.count_jsonl_lines(str(path))
95
+ assert valid == 2
96
+ assert corrupt == 2
97
+
98
+
99
+ def test_count_jsonl_lines_missing_file_is_zero_zero(tmp_path):
100
+ assert la.count_jsonl_lines(str(tmp_path / "nope.jsonl")) == (0, 0)
101
+
102
+
103
+ def test_count_jsonl_lines_ignores_blank_lines(tmp_path):
104
+ path = tmp_path / "blanks.jsonl"
105
+ path.write_text('{"a":1}\n\n\n{"a":2}\n', encoding="utf-8")
106
+ assert la.count_jsonl_lines(str(path)) == (2, 0)
107
+
108
+
109
+ # ── concurrency (K real processes) ──────────────────────────────────────────────────────────
110
+
111
+
112
+ def _worker(path, worker_id, m):
113
+ """Top-level, picklable worker body — runs in its OWN OS process."""
114
+ scripts_dir = os.path.join(REPO, "scripts")
115
+ if scripts_dir not in sys.path:
116
+ sys.path.insert(0, scripts_dir)
117
+ from _locked_append import locked_append_line
118
+
119
+ for i in range(m):
120
+ rec = {
121
+ "worker": worker_id,
122
+ "seq": i,
123
+ "iteration": worker_id * 1000 + i,
124
+ "action": "concurrent-write",
125
+ "gate": "pass",
126
+ "ts": time.time(),
127
+ }
128
+ ok = locked_append_line(path, json.dumps(rec))
129
+ if not ok:
130
+ raise RuntimeError("worker %d: lock acquisition failed on write %d" % (worker_id, i))
131
+
132
+
133
+ def test_k_processes_m_lines_all_valid_json_no_loss(tmp_path):
134
+ journal = str(tmp_path / "journal.jsonl")
135
+ procs = []
136
+ for w in range(K_WORKERS):
137
+ p = multiprocessing.Process(target=_worker, args=(journal, w, M_LINES))
138
+ p.start()
139
+ procs.append(p)
140
+ for p in procs:
141
+ p.join(timeout=90)
142
+ assert p.exitcode == 0, (
143
+ "worker process %s failed or timed out (exitcode=%s)" % (p.pid, p.exitcode)
144
+ )
145
+
146
+ with open(journal, encoding="utf-8") as f:
147
+ lines = [ln for ln in f if ln.strip()]
148
+
149
+ expected = K_WORKERS * M_LINES
150
+ assert len(lines) == expected, (
151
+ "expected %d lines, got %d — a write was lost under concurrency" % (expected, len(lines))
152
+ )
153
+
154
+ seen = set()
155
+ for ln in lines:
156
+ rec = json.loads(ln) # raises ValueError if this line is torn/interleaved — that IS the
157
+ # regression this test guards against (#127): an unlocked writer can corrupt a line.
158
+ seen.add((rec["worker"], rec["seq"]))
159
+ assert len(seen) == expected, "duplicate or garbled records survived under concurrency"
160
+
161
+ valid, corrupt = la.count_jsonl_lines(journal)
162
+ assert (valid, corrupt) == (expected, 0)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
167
+ from _selfrun import run_module
168
+ run_module(globals(), "test_locked_append")
File without changes
File without changes