@arbiterforge/ca-pi 0.10.13 → 0.14.7

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 (58) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/plugins/ca-pi/CHANGELOG.md +167 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/SPRINT.md +40 -10
  6. package/plugins/ca-pi/agents/backend-author.md +1 -1
  7. package/plugins/ca-pi/agents/frontend-author.md +1 -1
  8. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  15. package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
  16. package/plugins/ca-pi/hooks/_approvallib.py +355 -0
  17. package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
  18. package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
  19. package/plugins/ca-pi/hooks/_hooklib.py +37 -13
  20. package/plugins/ca-pi/hooks/_host.py +1 -1
  21. package/plugins/ca-pi/hooks/_intentlib.py +31 -0
  22. package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
  23. package/plugins/ca-pi/hooks/_prerequisitelib.py +971 -0
  24. package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
  25. package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
  26. package/plugins/ca-pi/hooks/_releaselib.py +933 -85
  27. package/plugins/ca-pi/hooks/_standuplib.py +38 -10
  28. package/plugins/ca-pi/hooks/doctor.py +103 -0
  29. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  30. package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
  31. package/plugins/ca-pi/hooks/prompt-submit.py +47 -10
  32. package/plugins/ca-pi/hooks/releasehash.py +46 -32
  33. package/plugins/ca-pi/includes/artifacts.md +210 -0
  34. package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
  35. package/plugins/ca-pi/includes/farm.md +27 -4
  36. package/plugins/ca-pi/includes/routing-table.md +2 -2
  37. package/plugins/ca-pi/includes/verification-boundary.md +27 -0
  38. package/plugins/ca-pi/package.json +18 -0
  39. package/plugins/ca-pi/routines/INDEX.md +4 -4
  40. package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
  41. package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
  42. package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
  43. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
  44. package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
  45. package/plugins/ca-pi/routines/release/SKILL.md +150 -44
  46. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
  47. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
  48. package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
  49. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
  50. package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
  51. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
  52. package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
  53. package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
  54. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
  55. package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
  56. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
  57. package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
  58. package/plugins/ca-pi/skills/ca-standup/SKILL.md +36 -8
@@ -92,7 +92,8 @@
92
92
  # restating the literal.
93
93
  # marker_fresh(path, minutes) -> bool True iff marker file exists and is recent
94
94
  # write_text_atomic(path, text) -> None crash-safe write (temp + os.replace)
95
- # acquire_lock(path) -> handle|None OS-owned cross-process file lock (#271 C-2);
95
+ # acquire_lock(path, wait_seconds=None) -> handle|None
96
+ # OS-owned cross-process file lock (#271 C-2);
96
97
  # non-blocking + bounded LOCK_WAIT retry spin,
97
98
  # fail-soft None on contention/timeout/OSError
98
99
  # release_lock(handle) -> None release + close; None handle is a no-op
@@ -222,6 +223,7 @@ from _sensitivelib import ( # noqa: F401
222
223
  # POSIX flock and Windows byte-range locks are process-scoped, so sibling
223
224
  # threads can otherwise burn the shared wait budget contending with each other.
224
225
  _GATE_EVENTS_PROCESS_LOCK = threading.Lock()
226
+ GATE_EVENT_LOCK_WAIT_SECONDS = 5.0
225
227
  _WINDOWS_LOCK_TIMEOUT_SECONDS = 5.0
226
228
  _WINDOWS_LOCK_RETRY_SECONDS = 0.01
227
229
  _WINDOWS_CRT_EDEADLK = 36
@@ -345,7 +347,7 @@ def write_text_atomic(path, text, newline=None):
345
347
  raise
346
348
 
347
349
 
348
- def acquire_lock(path):
350
+ def acquire_lock(path, wait_seconds=None):
349
351
  """Acquire an OS-owned cross-process file lock keyed on `path`; process
350
352
  death releases it automatically (#271 C-2 — hoisted from the
351
353
  statusline-ledger-only `_ledgerlib._acquire_lock`, now shared with
@@ -355,7 +357,8 @@ def acquire_lock(path):
355
357
  with one byte so the OS byte-range lock has a byte to lock (an empty file
356
358
  has no range to range-lock). Non-blocking (`msvcrt.locking(..., LK_NBLCK,
357
359
  1)` on Windows, `fcntl.flock(..., LOCK_EX | LOCK_NB)` elsewhere) with a
358
- bounded `LOCK_WAIT`-second retry spin; any `OSError` opening the lock file,
360
+ bounded `LOCK_WAIT`-second retry spin (or an explicit non-negative
361
+ `wait_seconds` budget); any `OSError` opening the lock file,
359
362
  or exhausting the deadline still contended, is FAIL-SOFT: returns `None`
360
363
  rather than raising or blocking indefinitely. Callers decide what
361
364
  "fail-soft" means for them — `_ledgerlib.ledger_update`/`persist_sess_start`
@@ -373,7 +376,8 @@ def acquire_lock(path):
373
376
  handle.flush()
374
377
  except OSError:
375
378
  return None
376
- deadline = time.monotonic() + LOCK_WAIT
379
+ wait_seconds = LOCK_WAIT if wait_seconds is None else max(0.0, wait_seconds)
380
+ deadline = time.monotonic() + wait_seconds
377
381
  while True:
378
382
  try:
379
383
  handle.seek(0)
@@ -493,9 +497,22 @@ def marker_fresh(path, minutes):
493
497
  def _log_gate_event(kind, tag, msg):
494
498
  """Best-effort durable append of one gate decision to
495
499
  .codearbiter/gate-events.log (observability-001, issue #186) — the durable
496
- sink block()/remind()/warn() funnel every BLOCK/REMIND/WARN through, so a
497
- decision is no longer visible ONLY in the ephemeral per-turn stderr
498
- transcript.
500
+ sink block() funnels every BLOCK through, so a decision is no longer
501
+ visible ONLY in the ephemeral per-turn stderr transcript.
502
+
503
+ remind()/warn() do NOT call this (2026-09-17 scope-down): measured on a
504
+ live repo, REMIND/WARN accounted for 76% of the file's lines and 60% were
505
+ one repeated boilerplate reminder carrying no information past its first
506
+ occurrence, while the file's git-tracked + H-05 append-only status gave
507
+ that noise the full cost of a protected audit artifact — two branches
508
+ that both merely touched it, independently, produced a real merge
509
+ conflict; _releaselib.py's clean-tree probe carries its own comment about
510
+ working around a mid-window REMIND/WARN append poisoning a release gate.
511
+ BLOCK is the one kind with real forensic value (evidence for diagnosing a
512
+ hook that misfired); REMIND/WARN remain full-strength on stderr, just not
513
+ persisted. Not ADR-governed — issue #186 shipped this as an implementation
514
+ detail of `_hooklib`, not an architectural decision, so scoping it back
515
+ down is the same kind of call.
499
516
 
500
517
  One line per event: `[ISO-8601Z] KIND [tag] host=<host> hook=<script> | msg`.
501
518
  `tag` may be None (warn() carries no tag) — the bracket is simply omitted
@@ -538,7 +555,10 @@ def _log_gate_event(kind, tag, msg):
538
555
  try:
539
556
  _GATE_EVENTS_PROCESS_LOCK.acquire()
540
557
  process_lock_acquired = True
541
- audit_lock = acquire_lock(audit_lock_key(root, log_path))
558
+ audit_lock = acquire_lock(
559
+ audit_lock_key(root, log_path),
560
+ wait_seconds=GATE_EVENT_LOCK_WAIT_SECONDS,
561
+ )
542
562
  except Exception:
543
563
  if process_lock_acquired:
544
564
  _GATE_EVENTS_PROCESS_LOCK.release()
@@ -551,7 +571,10 @@ def _log_gate_event(kind, tag, msg):
551
571
  os_lock_acquired = False
552
572
  try:
553
573
  fd = os.open(log_path, flags, 0o600)
554
- if os.name == "nt":
574
+ # The trusted sidecar already serializes current writers. Keep the
575
+ # legacy descriptor lock for compatibility once the log has data,
576
+ # but Windows cannot reliably lock byte zero of a new empty file.
577
+ if os.name == "nt" and os.fstat(fd).st_size:
555
578
  import msvcrt
556
579
  os.lseek(fd, 0, os.SEEK_SET)
557
580
  lock_mode = getattr(msvcrt, "LK_NBLCK", msvcrt.LK_LOCK)
@@ -596,14 +619,15 @@ def block(tag, msg):
596
619
 
597
620
 
598
621
  def remind(tag, msg):
599
- """Non-blocking nudge to stderr."""
600
- _log_gate_event("REMIND", tag, msg)
622
+ """Non-blocking nudge to stderr. Deliberately NOT persisted to
623
+ gate-events.log (see _log_gate_event's 2026-09-17 scope-down note)."""
601
624
  print(f"REMINDER [{tag}]: {msg}", file=sys.stderr)
602
625
 
603
626
 
604
627
  def warn(msg):
605
- """Loud degradation/diagnostic breadcrumb — never silent."""
606
- _log_gate_event("WARN", None, msg)
628
+ """Loud degradation/diagnostic breadcrumb — never silent. Deliberately
629
+ NOT persisted to gate-events.log (see _log_gate_event's 2026-09-17
630
+ scope-down note)."""
607
631
  print(f"codeArbiter hook: {msg}", file=sys.stderr)
608
632
 
609
633
 
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
11
11
  class PiHost(hostapi.Host):
12
12
  name = "pi"
13
13
  adapter_name = "@arbiterforge/ca-pi"
14
- adapter_version = "0.10.13"
14
+ adapter_version = "0.14.7"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -168,6 +168,8 @@ def uncovered_intent(spec_text, issue_body=None):
168
168
  docstring for what it cannot catch."""
169
169
  if not isinstance(spec_text, str):
170
170
  spec_text = ""
171
+ if spec_text.lstrip().lower().startswith(("<!doctype html", "<html")):
172
+ return ["[HTML-REQUIRES-VALIDATION] Use the path-based artifact validator; HTML is not empty Markdown."]
171
173
  criteria_tokens = set()
172
174
  for criterion in _criteria_texts(spec_text):
173
175
  criteria_tokens.update(_tokens(criterion))
@@ -232,6 +234,35 @@ def main(argv):
232
234
  sys.stderr.write(f"_intentlib.py: cannot read {issue_body_path}: {exc}\n")
233
235
  return 2
234
236
 
237
+ if positional[0].lower().endswith(".html"):
238
+ try:
239
+ from _artifactlib import resolve_spec_file, helper_installation
240
+ client, entry = resolve_spec_file(positional[0], helper_installation(__file__))
241
+ result = client.call("validate", {"artifact_id": entry["artifact_id"], "gate": "ready"}, permit_invalid=True)
242
+ findings = ["[INVALID-HTML-SPEC] " + item["code"] + ": " + item["message"]
243
+ for item in result.get("diagnostics", [])]
244
+ # Explicit source->criterion mappings replace fuzzy scope matching.
245
+ # The legacy issue-checkbox heuristic is retained ONLY for external
246
+ # issue text until issue-source records receive their own schema.
247
+ if issue_body:
248
+ criteria_tokens = set()
249
+ for record in client.outline(entry["artifact_id"]):
250
+ if record["kind"] == "criteria" and not record["retired"]:
251
+ data = client.call("read", {"artifact_id": entry["artifact_id"], "symbol": record["id"], "mode": "exact"})
252
+ criteria_tokens.update(_tokens(data["record"].get("statement", "")))
253
+ for box in _checkboxes(issue_body):
254
+ if not _cited(box, criteria_tokens):
255
+ findings.append("[UNCOVERED-CHECKBOX] " + box)
256
+ fresh = client.call("identity", {"artifact_id": entry["artifact_id"]})
257
+ if fresh["model_sha256"] != result["model_sha256"]:
258
+ raise RuntimeError("HTML spec changed during intent verification; retry a fresh read")
259
+ for finding in findings:
260
+ print(finding)
261
+ return 1 if findings or not result["valid"] else 0
262
+ except Exception as error:
263
+ sys.stderr.write("_intentlib.py: HTML validation failed: " + str(error)[:512] + "\n")
264
+ return 2
265
+
235
266
  findings = uncovered_intent(spec_text, issue_body)
236
267
  for finding in findings:
237
268
  print(finding)
@@ -70,6 +70,30 @@ def _request_path(root, slug, kind):
70
70
  return os.path.join(root, ".codearbiter", KINDS[kind], f"{slug}.md")
71
71
 
72
72
 
73
+ def _require_legacy_pair(root, slug):
74
+ """Refuse Pi's Markdown-only plan surface unless one exact legacy pair owns the slug."""
75
+ _request_path(root, slug, "spec")
76
+ root_info = os.lstat(root)
77
+ if not _directory(root_info):
78
+ raise PlanFileError("root")
79
+ state_path, _ = _exact_absolute_child(root, ".codearbiter")
80
+ for kind in KINDS:
81
+ parent_path, _ = _exact_absolute_child(state_path, KINDS[kind])
82
+ html_leaf = f"{slug}.html"
83
+ if _aliases(parent_path, html_leaf):
84
+ raise PlanFileError("html_authority")
85
+ markdown_leaf = f"{slug}.md"
86
+ aliases = _aliases(parent_path, markdown_leaf)
87
+ if not aliases:
88
+ raise PlanFileError("legacy_pair_required")
89
+ if aliases != [markdown_leaf]:
90
+ raise PlanFileError("path_alias")
91
+ path = os.path.join(parent_path, markdown_leaf)
92
+ info = os.lstat(path)
93
+ if not _regular_single(info) or os.path.realpath(path) != os.path.abspath(path):
94
+ raise PlanFileError("path_type")
95
+
96
+
73
97
  def _windows_handle_identity(handle):
74
98
  from ctypes import wintypes
75
99
 
@@ -580,7 +604,8 @@ def _committed_result(path, data, content, fault=None, initial_diagnostic=None):
580
604
  return result
581
605
 
582
606
 
583
- def plan_file_operation(root, request, *, lock_root=None, fault=None):
607
+ def plan_file_operation(root, request, *, lock_root=None, fault=None,
608
+ require_legacy_pair=False):
584
609
  """Run a bounded CAS. The lock serializes cooperating codeArbiter Pi writers only."""
585
610
  try:
586
611
  if not isinstance(request, dict) or set(request) - {"slug", "kind", "action", "expectedHash", "content"}:
@@ -593,7 +618,13 @@ def plan_file_operation(root, request, *, lock_root=None, fault=None):
593
618
  if action not in ("read", "replace"):
594
619
  raise PlanFileError("request")
595
620
  target = _request_path(root, request.get("slug"), request.get("kind"))
621
+ if require_legacy_pair is not False:
622
+ if require_legacy_pair is not True:
623
+ raise PlanFileError("request")
624
+ _require_legacy_pair(root, request["slug"])
596
625
  with _lock_for(target, lock_root), _held_path(root, request["slug"], request["kind"], fault) as path:
626
+ if require_legacy_pair:
627
+ _require_legacy_pair(root, request["slug"])
597
628
  chosen = _read_target(path, fault)
598
629
  if action == "read":
599
630
  if chosen is None:
@@ -625,6 +656,8 @@ def plan_file_operation(root, request, *, lock_root=None, fault=None):
625
656
  for phase in ("before_publish", "publish"):
626
657
  if fault:
627
658
  fault(phase, path)
659
+ if require_legacy_pair:
660
+ _require_legacy_pair(root, request["slug"])
628
661
  try:
629
662
  latest = _read_target(path)
630
663
  except Exception: