@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 +2 -2
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +12 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_taskboardlib.py +18 -1
- package/plugins/ca-pi/hooks/_taskexcludelib.py +223 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/init-codearbiter.py +34 -19
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.
|
|
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.
|
|
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
|
@@ -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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|