@arbiterforge/ca-pi 0.10.8 → 0.10.10

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/README.md CHANGED
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
10
10
  <img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
11
11
  <img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
12
12
  <img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
13
- <img alt="version 2.17.7" src="https://img.shields.io/badge/version-2.17.7-2b7489">
13
+ <img alt="version 2.17.9" src="https://img.shields.io/badge/version-2.17.9-2b7489">
14
14
  <img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
15
15
  <img alt="skills" src="https://img.shields.io/badge/skills-23-555">
16
16
  <img alt="agents" src="https://img.shields.io/badge/agents-19-555">
@@ -117,7 +117,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue
117
117
 
118
118
  ### Codex CLI
119
119
 
120
- The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.7`;
120
+ The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.9`;
121
121
  the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`.
122
122
  Current packaging and shared-core parity are continuously verified, while that dated live-install
123
123
  record stays labeled rather than being silently promoted to evidence for a newer adapter:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,18 @@ All notable changes to `ca-pi` are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.10] - 2026-09-07
8
+
9
+ ### Fixed
10
+
11
+ - Use the same documented done-tasks template for initialization and first-use archiving, with byte-identical UTF-8 headers on Windows and POSIX.
12
+
13
+ ## [0.10.9] - 2026-09-06
14
+
15
+ ### Fixed
16
+
17
+ - Keep task-board lifecycles Git-clean by locally excluding the retained OS-lock sidecar during initialization, with an explicit repair option for existing repositories.
18
+
7
19
  ## [0.10.8] - 2026-09-06
8
20
 
9
21
  ### Fixed
@@ -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.8"
14
+ adapter_version = "0.10.10"
15
15
  update_target = "ca-pi"
16
16
  update_tag_prefix = "ca-pi-v"
17
17
  update_command = "pi update npm:@arbiterforge/ca-pi"
@@ -704,6 +704,23 @@ ARCHIVE_CUTOFF_DAYS = 14
704
704
 
705
705
  DONE_TASKS_HEADING = "# Done tasks"
706
706
 
707
+ # The canonical documented archive template, shared by init and first use.
708
+ DONE_TASKS = """\
709
+ # Done tasks
710
+
711
+ Completed work swept off the board by `taskwrite archive`, newest last.
712
+ APPEND-ONLY: entries are added here and never edited or removed, so a
713
+ finished task has exactly one permanent record.
714
+
715
+ Each archived task preserves its original lifecycle line and indented task block verbatim
716
+ from `open-tasks.md`. The lifecycle line keeps its `(done YYYY-MM-DD)` stamp
717
+ intact — the stamp is what makes an entry ageable, and what `archive` refuses
718
+ to invent.
719
+
720
+ Written only by `taskwrite archive`. `/ca:standup` proposes the sweep with
721
+ per-item confirmation; nothing sweeps automatically.
722
+ """
723
+
707
724
 
708
725
  def archive_candidates(text, *, today, cutoff_days=ARCHIVE_CUTOFF_DAYS):
709
726
  """`(aged, undated)` — done tasks eligible for the archival sweep.
@@ -862,7 +879,7 @@ def archive_transform(open_text, done_text, task):
862
879
  if already_archived(done_text, task):
863
880
  new_done = done_text
864
881
  else:
865
- body = done_text if done_text.strip() else DONE_TASKS_HEADING + "\n"
882
+ body = done_text if done_text.strip() else DONE_TASKS
866
883
  if not body.endswith("\n"):
867
884
  body += "\n"
868
885
  new_done = body + "\n".join(block) + "\n"
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — locally exclude the retained task-board OS-lock sidecar.
3
+ #
4
+ # The board lock is intentionally never removed: unlinking a locked inode lets
5
+ # another process acquire a different lock. Only Git's common info/exclude is
6
+ # changed, through its own exclusive create/replace lockfile protocol. Existing
7
+ # bytes and modes survive; no tracked file or Git index is written.
8
+ # Static Git-metadata link/reparse aliases and observed concurrent edits fail closed. This
9
+ # cooperates with writers honoring exclude.lock; it is not a sandbox against a
10
+ # hostile same-user process swapping paths between filesystem operations.
11
+ #
12
+ # Public API:
13
+ # ensure_task_lock_excluded(root, required=False) -> str | None
14
+ # "added" / "already present", or None for optional non-Git scaffolding.
15
+ # Raises TaskExclusionError with a surfaced diagnostic on unsafe/failing IO.
16
+
17
+ import os
18
+ from pathlib import Path
19
+ import stat
20
+ import subprocess
21
+
22
+ from _gitexec import git_executable, root_bound_git_env
23
+
24
+
25
+ LOCK_PATH = ".codearbiter/open-tasks.md.lock"
26
+ EXCLUDE_RULE = b"/" + LOCK_PATH.encode("ascii")
27
+ MAX_EXCLUDE_BYTES = 2 * 1024 * 1024
28
+ _REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
29
+
30
+
31
+ class TaskExclusionError(RuntimeError):
32
+ pass
33
+
34
+
35
+ def _version(info):
36
+ # Windows Python can expose creation time via lstat().st_ctime but change
37
+ # time via fstat().st_ctime. Compare the common birth-time field there;
38
+ # identity, size, mtime, mode/link count and byte rereads still bind edits.
39
+ change = getattr(info, "st_birthtime_ns", 0) if os.name == "nt" else info.st_ctime_ns
40
+ return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns,
41
+ change, info.st_mode, info.st_nlink)
42
+
43
+
44
+ def _regular(info):
45
+ return (stat.S_ISREG(info.st_mode) and info.st_nlink == 1
46
+ and not getattr(info, "st_file_attributes", 0) & _REPARSE)
47
+
48
+
49
+ def _directories(path):
50
+ for directory in (*reversed(path.parents), path):
51
+ info = directory.lstat()
52
+ if not stat.S_ISDIR(info.st_mode) or getattr(info, "st_file_attributes", 0) & _REPARSE:
53
+ raise TaskExclusionError("unsafe directory in task lock exclusion path")
54
+
55
+
56
+ def _git(root, *args, input_bytes=None):
57
+ return subprocess.run([git_executable(), "-C", str(root), *args],
58
+ env=root_bound_git_env(), capture_output=True, timeout=5,
59
+ input=input_bytes)
60
+
61
+
62
+ def _git_path(root, *args):
63
+ result = _git(root, "rev-parse", *args)
64
+ value = os.fsdecode(result.stdout).rstrip("\r\n")
65
+ if result.returncode or not value or not os.path.isabs(value) or "\n" in value:
66
+ raise TaskExclusionError("cannot bind task lock exclusion to selected Git")
67
+ return Path(value)
68
+
69
+
70
+ def _same_path(left, right):
71
+ return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right))
72
+
73
+
74
+ def _binding(root, required):
75
+ # A failed Git probe in an actual checkout is never mistaken for non-Git.
76
+ markers = [item / ".git" for item in (root, *root.parents)]
77
+ has_marker = any(os.path.lexists(path) for path in markers)
78
+ try:
79
+ top = _git_path(root, "--show-toplevel")
80
+ except (OSError, RuntimeError, subprocess.SubprocessError):
81
+ if not required and not has_marker:
82
+ return None
83
+ raise TaskExclusionError("cannot resolve repository root for task lock exclusion") from None
84
+ # Git reports physical roots: /var aliases and Windows 8.3 names can differ
85
+ # from the caller. Resolve only this identity, never metadata paths whose
86
+ # unresolved link/reparse attributes the checks below must inspect.
87
+ root = Path(os.path.realpath(root))
88
+ if not _same_path(os.path.realpath(top), root):
89
+ raise TaskExclusionError("task lock exclusion requires the exact repository root")
90
+ _directories(root)
91
+ marker = root / ".git"
92
+ info = marker.lstat()
93
+ if (not (stat.S_ISDIR(info.st_mode) or _regular(info))
94
+ or getattr(info, "st_file_attributes", 0) & _REPARSE):
95
+ raise TaskExclusionError("unsafe Git marker for task lock exclusion")
96
+ common = _git_path(root, "--path-format=absolute", "--git-common-dir")
97
+ exclude = _git_path(root, "--path-format=absolute", "--git-path", "info/exclude")
98
+ if not _same_path(exclude, common / "info/exclude"):
99
+ raise TaskExclusionError("task lock exclusion common-directory mismatch")
100
+ _directories(common)
101
+ return exclude
102
+
103
+
104
+ def _read_exclude(path):
105
+ try:
106
+ before = path.lstat()
107
+ except FileNotFoundError:
108
+ return b"", None
109
+ if not _regular(before):
110
+ raise TaskExclusionError("task lock exclusion is not a regular single-link file")
111
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
112
+ with os.fdopen(fd, "rb") as handle:
113
+ opened = os.fstat(handle.fileno())
114
+ if not _regular(opened) or _version(opened) != _version(before):
115
+ raise TaskExclusionError("task lock exclusion changed while opening")
116
+ data = handle.read(MAX_EXCLUDE_BYTES + 1)
117
+ if len(data) > MAX_EXCLUDE_BYTES:
118
+ raise TaskExclusionError("task lock exclusion exceeds size limit")
119
+ if _version(os.fstat(handle.fileno())) != _version(before):
120
+ raise TaskExclusionError("task lock exclusion changed while reading")
121
+ if _version(path.lstat()) != _version(before):
122
+ raise TaskExclusionError("task lock exclusion path changed while reading")
123
+ return data, before
124
+
125
+
126
+ def _check_conflicts(root):
127
+ tracked = _git(root, "ls-files", "--error-unmatch", "--", LOCK_PATH)
128
+ if tracked.returncode == 0:
129
+ raise TaskExclusionError("task lock sidecar is tracked; local exclusion cannot untrack it")
130
+ if tracked.returncode != 1:
131
+ raise TaskExclusionError("cannot check tracked task lock sidecar")
132
+ # A file can serve both global and per-directory roles. For this conflict
133
+ # probe only, disable the lower-priority global source without changing
134
+ # repository configuration. Effectiveness checks below use actual config.
135
+ ignored = _git(root, "-c", "core.excludesFile=", "check-ignore", "--no-index", "-z", "-v", "--stdin",
136
+ input_bytes=LOCK_PATH.encode("ascii") + b"\0")
137
+ if ignored.returncode not in (0, 1):
138
+ raise TaskExclusionError("cannot check task lock exclusion precedence")
139
+ fields = ignored.stdout.split(b"\0")
140
+ # Git reports the target's per-directory sources relative to the -C root.
141
+ if len(fields) == 5 and fields[0].replace(b"\\", b"/") in (
142
+ b".gitignore", b".codearbiter/.gitignore") \
143
+ and fields[2].startswith(b"!"):
144
+ raise TaskExclusionError("a .gitignore negation overrides local task lock exclusion")
145
+
146
+
147
+ def _ensure(root, required):
148
+ # Retain the caller spelling so publication-time binding detects retargeting.
149
+ root = Path(os.path.abspath(root))
150
+ exclude = _binding(root, required)
151
+ if exclude is None:
152
+ return None
153
+ # mkdir is exclusive and never follows a preexisting info alias.
154
+ try:
155
+ exclude.parent.mkdir()
156
+ except FileExistsError:
157
+ pass
158
+ _directories(exclude.parent)
159
+ lock = exclude.with_name("exclude.lock")
160
+ fd = None
161
+ owned = None
162
+ try:
163
+ try:
164
+ fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL
165
+ | getattr(os, "O_NOFOLLOW", 0), 0o666)
166
+ except FileExistsError:
167
+ raise TaskExclusionError("exclude.lock already exists; another writer may own it") from None
168
+ info = os.fstat(fd)
169
+ owned = info.st_dev, info.st_ino
170
+ before, snapshot = _read_exclude(exclude)
171
+ _check_conflicts(root)
172
+ if EXCLUDE_RULE in before.splitlines():
173
+ ignored = _git(root, "check-ignore", "-q", "--", LOCK_PATH)
174
+ if ignored.returncode == 0:
175
+ return "already present"
176
+ if ignored.returncode != 1:
177
+ raise TaskExclusionError("cannot verify task lock exclusion")
178
+ newline = b"\r\n" if b"\r\n" in before else b"\n"
179
+ data = before + (newline if before and not before.endswith(b"\n") else b"")
180
+ data += EXCLUDE_RULE + newline
181
+ if len(data) > MAX_EXCLUDE_BYTES:
182
+ raise TaskExclusionError("task lock exclusion output exceeds size limit")
183
+ with os.fdopen(fd, "wb") as handle:
184
+ fd = None
185
+ handle.write(data)
186
+ handle.flush()
187
+ os.fsync(handle.fileno())
188
+ if snapshot is not None:
189
+ os.chmod(lock, stat.S_IMODE(snapshot.st_mode))
190
+ latest, current = _read_exclude(exclude)
191
+ if latest != before or ((snapshot is None) != (current is None)) \
192
+ or snapshot is not None and _version(snapshot) != _version(current):
193
+ raise TaskExclusionError("task lock exclusion changed before publication")
194
+ if _binding(root, True) != exclude:
195
+ raise TaskExclusionError("task lock exclusion binding changed before publication")
196
+ _directories(exclude.parent)
197
+ info = lock.lstat()
198
+ if not _regular(info) or (info.st_dev, info.st_ino) != owned:
199
+ raise TaskExclusionError("exclude.lock ownership changed before publication")
200
+ os.replace(lock, exclude)
201
+ owned = None
202
+ if _git(root, "check-ignore", "-q", "--", LOCK_PATH).returncode != 0:
203
+ raise TaskExclusionError("local rule added but task lock exclusion is not effective")
204
+ return "added"
205
+ finally:
206
+ if fd is not None:
207
+ os.close(fd)
208
+ if owned is not None:
209
+ try:
210
+ info = lock.lstat()
211
+ if (info.st_dev, info.st_ino) == owned:
212
+ lock.unlink() # Only OUR Git transaction file, NEVER the board OS lock.
213
+ except FileNotFoundError:
214
+ pass
215
+
216
+
217
+ def ensure_task_lock_excluded(root, required=False):
218
+ try:
219
+ return _ensure(root, required)
220
+ except TaskExclusionError:
221
+ raise
222
+ except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
223
+ raise TaskExclusionError(f"task lock exclusion failed ({type(exc).__name__})") from None
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.17.7"
316
+ adapter_version = "2.17.9"
317
317
 
318
318
  # Update-notifier descriptor. Each independently versioned host overrides
319
319
  # these three values in its per-plugin _host.py. Keeping the target,
@@ -12,6 +12,7 @@
12
12
  # Usage:
13
13
  # python init-codearbiter.py [--root PATH] [--stage N]
14
14
  # python init-codearbiter.py --check # report state, create nothing
15
+ # python init-codearbiter.py --repair-lock-exclusion [--root PATH]
15
16
 
16
17
  import argparse
17
18
  import subprocess
@@ -19,7 +20,8 @@ import os
19
20
  import sys
20
21
 
21
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
- from _gitexec import git_executable # noqa: E402
23
+ from _gitexec import git_executable, root_bound_git_env # noqa: E402
24
+ from _taskboardlib import DONE_TASKS # noqa: E402 — shared archive template (#625)
23
25
  import hostapi # noqa: E402 — host seam (ADR-0011)
24
26
  import _hooklib # noqa: E402 — set_host DI seam (#257)
25
27
  import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
@@ -92,21 +94,6 @@ OVERRIDES = """\
92
94
  # last-checkpoint marker as "overrides since last checkpoint."
93
95
  """
94
96
 
95
- DONE_TASKS = """\
96
- # Done tasks
97
-
98
- Completed work swept off the board by `taskwrite archive`, newest last.
99
- APPEND-ONLY: entries are added here and never edited or removed, so a
100
- finished task has exactly one permanent record.
101
-
102
- Each line is the task's original lifecycle line, moved verbatim from
103
- `open-tasks.md` with its `(done YYYY-MM-DD)` stamp intact — the stamp is
104
- what makes an entry ageable, and what `archive` refuses to invent.
105
-
106
- Written only by `taskwrite archive`. `/ca:standup` proposes the sweep with
107
- per-item confirmation; nothing sweeps automatically.
108
- """
109
-
110
97
  FILES = {
111
98
  "open-tasks.md": OPEN_TASKS,
112
99
  # B-23: scaffolded at init rather than created on first archive. An
@@ -126,7 +113,7 @@ def project_root(opt):
126
113
  # prefer git toplevel; fall back to cwd
127
114
  try:
128
115
  out = subprocess.run([git_executable(), "rev-parse", "--show-toplevel"],
129
- capture_output=True, text=True, timeout=2)
116
+ env=root_bound_git_env(), capture_output=True, text=True, timeout=2)
130
117
  top = out.stdout.strip()
131
118
  if out.returncode == 0 and top:
132
119
  return os.path.abspath(top)
@@ -138,9 +125,16 @@ def project_root(opt):
138
125
  def main(argv=None):
139
126
  ap = argparse.ArgumentParser(add_help=True)
140
127
  ap.add_argument("--root")
141
- ap.add_argument("--stage", type=int, default=1)
128
+ ap.add_argument("--stage", type=int)
142
129
  ap.add_argument("--check", action="store_true")
130
+ ap.add_argument("--repair-lock-exclusion", action="store_true", help=(
131
+ "repair only Git-local info/exclude for an existing scaffold; retain the "
132
+ "task-board OS lock, never rewrite project state (cannot combine with --check/--stage)"))
143
133
  args = ap.parse_args(argv)
134
+ if args.repair_lock_exclusion and (args.check or args.stage is not None):
135
+ ap.error("--repair-lock-exclusion cannot combine with --check or --stage")
136
+ if args.stage is None:
137
+ args.stage = 1
144
138
 
145
139
  root = project_root(args.root)
146
140
  cad = os.path.join(root, ".codearbiter")
@@ -168,11 +162,29 @@ def main(argv=None):
168
162
  print(f"NOT SCAFFOLDED: {cad} would be created. Run without --check to scaffold.")
169
163
  return
170
164
 
165
+ from _taskexcludelib import ensure_task_lock_excluded, TaskExclusionError
166
+ if args.repair_lock_exclusion:
167
+ if not os.path.isfile(ctx) or os.path.islink(ctx):
168
+ raise SystemExit("REFUSING: task lock exclusion repair requires an existing scaffold.")
169
+ try:
170
+ result = ensure_task_lock_excluded(root, required=True)
171
+ except TaskExclusionError as exc:
172
+ raise SystemExit(f"REFUSING: {exc}") from None
173
+ print(f"task lock exclusion: {result}")
174
+ return
175
+
171
176
  if os.path.exists(ctx):
172
177
  raise SystemExit(
173
178
  f"REFUSING: {ctx} already exists. .codearbiter/ is already scaffolded here. "
174
179
  f"To populate it, run {_cc} or {_dc}; to repair, edit by hand.")
175
180
 
181
+ try:
182
+ result = ensure_task_lock_excluded(root)
183
+ except TaskExclusionError as exc:
184
+ raise SystemExit(f"REFUSING: {exc}") from None
185
+ if result:
186
+ print(f"task lock exclusion: {result}")
187
+
176
188
  os.makedirs(cad, exist_ok=True)
177
189
  created = []
178
190
  if not os.path.exists(ctx):
@@ -183,7 +195,10 @@ def main(argv=None):
183
195
  for fname, content in FILES.items():
184
196
  fp = os.path.join(cad, fname)
185
197
  if not os.path.exists(fp):
186
- with open(fp, "w", encoding="utf-8") as f:
198
+ # Match taskwrite's LF output for this shared append-only template
199
+ # on Windows too; other scaffold files retain their existing EOLs.
200
+ newline = "\n" if fname == "done-tasks.md" else None
201
+ with open(fp, "w", encoding="utf-8", newline=newline) as f:
187
202
  f.write(content)
188
203
  created.append(fname)
189
204