@appchy/jarvis 0.1.36 → 0.1.39
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/bin/config-change.mjs +1 -1
- package/bin/jarvis.mjs +8 -12
- package/bin/pre-tool-use.mjs +2 -2
- package/bin/session-start.mjs +1 -1
- package/bin/stop.mjs +1 -1
- package/bin/user-prompt-submit.mjs +1 -1
- package/dist/bin.js +10104 -37174
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +469 -0
- package/dist/data/chunk-7REP35VA.mjs +462 -0
- package/dist/data/chunk-AKQQC5IT.mjs +1224 -0
- package/dist/data/chunk-AYOJSS2F.mjs +72 -0
- package/dist/data/chunk-RRJ6KKYL.mjs +42 -0
- package/dist/data/chunk-YWSWQEJN.mjs +138 -0
- package/dist/data/embedders.mjs +80 -0
- package/dist/data/finders.mjs +35 -0
- package/dist/data/index.mjs +137 -0
- package/dist/data/linkers.mjs +83 -0
- package/dist/data/mcp.mjs +2624 -0
- package/dist/data/persistences.mjs +8 -0
- package/dist/data/rerankers.mjs +52 -0
- package/dist/data/stores.mjs +38 -0
- package/dist/hooks/config-change.js +7 -29
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js +129 -32
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js +72 -37
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +140 -32
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js +7 -29
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/assets/architecture-template.md +53 -0
- package/harness/assets/design-template.md +89 -0
- package/harness/assets/domains/architecture.md +43 -0
- package/harness/assets/domains/commercial.md +42 -0
- package/harness/assets/domains/design.md +42 -0
- package/harness/assets/domains/legal.md +43 -0
- package/harness/assets/domains/operations.md +39 -0
- package/harness/assets/domains/product.md +43 -0
- package/harness/assets/domains/quality.md +43 -0
- package/harness/assets/domains/security.md +44 -0
- package/harness/assets/domains/support.md +45 -0
- package/harness/assets/epic-template.md +85 -0
- package/harness/assets/feature-template.md +86 -0
- package/harness/assets/handoff-template.md +26 -0
- package/harness/assets/task-template.md +38 -0
- package/harness/assets/version-template.md +43 -0
- package/harness/harness/__init__.py +7 -0
- package/harness/harness/align.py +582 -0
- package/harness/harness/architecture.py +254 -0
- package/harness/harness/autonomy.py +374 -0
- package/harness/harness/branches.py +408 -0
- package/harness/harness/config.py +1482 -0
- package/harness/harness/coverage.py +199 -0
- package/harness/harness/epic.py +220 -0
- package/harness/harness/events.py +153 -0
- package/harness/harness/extend.py +99 -0
- package/harness/harness/frontmatter.py +218 -0
- package/harness/harness/gate.py +591 -0
- package/harness/harness/generate.py +267 -0
- package/harness/harness/git.py +775 -0
- package/harness/harness/ids.py +140 -0
- package/harness/harness/kickoff.py +231 -0
- package/harness/harness/lint.py +505 -0
- package/harness/harness/model.py +364 -0
- package/harness/harness/peers.py +187 -0
- package/harness/harness/product.py +29 -0
- package/harness/harness/registry.py +382 -0
- package/harness/harness/report.py +227 -0
- package/harness/harness/safety.py +387 -0
- package/harness/harness/scaffold.py +129 -0
- package/harness/harness/shard.py +63 -0
- package/harness/harness/shift.py +348 -0
- package/harness/harness/task.py +507 -0
- package/harness/harness/tree.py +258 -0
- package/harness/harness/version.py +305 -0
- package/harness/harness/wrap.py +217 -0
- package/harness/hooks/guard.py +259 -0
- package/harness/presets/appchy/PRESET.md +717 -0
- package/harness/presets/appchy/references/artifacts.md +539 -0
- package/harness/presets/appchy/references/graph.md +154 -0
- package/harness/presets/appchy/references/operations.md +444 -0
- package/harness/presets/appchy/references/research.md +216 -0
- package/harness/schema/work.config.schema.json +401 -0
- package/harness/test_work.py +5002 -0
- package/harness/work.py +534 -0
- package/package.json +34 -40
- package/bin/config-change.dev.mjs +0 -17
- package/bin/jarvis-dev.mjs +0 -30
- package/bin/pre-tool-use.dev.mjs +0 -23
- package/bin/session-start.dev.mjs +0 -17
- package/bin/stop.dev.mjs +0 -17
- package/bin/user-prompt-submit.dev.mjs +0 -17
- package/dev/bin.js +0 -38718
- package/dev/bin.js.map +0 -1
- package/dev/env.json +0 -1
- package/dev/hooks/config-change.js +0 -110
- package/dev/hooks/config-change.js.map +0 -1
- package/dev/hooks/pre-tool-use.js +0 -120
- package/dev/hooks/pre-tool-use.js.map +0 -1
- package/dev/hooks/session-start.js +0 -115
- package/dev/hooks/session-start.js.map +0 -1
- package/dev/hooks/stop.js +0 -112
- package/dev/hooks/stop.js.map +0 -1
- package/dev/hooks/user-prompt-submit.js +0 -111
- package/dev/hooks/user-prompt-submit.js.map +0 -1
- package/dist/env.json +0 -1
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"""A board write is not done until it is in git.
|
|
2
|
+
|
|
3
|
+
**The failure this closes.** The harness wrote a file and stopped. An item created
|
|
4
|
+
on one machine sat untracked in that machine's clone until somebody noticed and
|
|
5
|
+
recovered it by hand — it existed in no git history anywhere, and nothing was going
|
|
6
|
+
to stop the next one. No sync design would have saved it, because it was never
|
|
7
|
+
committed.
|
|
8
|
+
|
|
9
|
+
So a mutation now **pulls, writes, commits and pushes**, and the commit is what
|
|
10
|
+
makes it unloseable. There is no debounce and no window: the commit happens the
|
|
11
|
+
moment the change does, because that window IS the bug. Only the push — the network
|
|
12
|
+
half — is allowed to fail, and when it does the caller is told plainly and the work
|
|
13
|
+
is still in git locally.
|
|
14
|
+
|
|
15
|
+
**Off unless a repo asks.** The harness is shared, and a consumer that never asked
|
|
16
|
+
for this must not suddenly start committing. `git.commit` is false by default; with
|
|
17
|
+
it off, nothing in this file runs and the record stays where it was.
|
|
18
|
+
|
|
19
|
+
**The trailer IS the activity record.** Every board commit carries:
|
|
20
|
+
|
|
21
|
+
Work-Item: <name>
|
|
22
|
+
Work-Event: <kind> one line per event the command caused
|
|
23
|
+
Work-Machine: <host>
|
|
24
|
+
Work-Session: <run> omitted when nothing names one
|
|
25
|
+
|
|
26
|
+
Four things read that format, so it is a rule rather than a convention — see the
|
|
27
|
+
repo's own architecture docs. Who, when and which branch are NOT trailers: git
|
|
28
|
+
already knows them, and a second copy is a second thing to drift.
|
|
29
|
+
|
|
30
|
+
**Coordination never enters git.** A `.claim` belongs to a machine and a moment, not
|
|
31
|
+
to a branch, and a `.verify` naming the pid running this checkout's gates is the same
|
|
32
|
+
kind of fact. Both are filtered out of every commit here, whatever the configured
|
|
33
|
+
paths say.
|
|
34
|
+
|
|
35
|
+
**What a commit contains, exactly.** Only the configured paths, committed with a
|
|
36
|
+
pathspec, so a session's unrelated staged code is neither swept in nor disturbed.
|
|
37
|
+
One thing does move: if a push is rejected and the retry rebases, an autostash
|
|
38
|
+
restores the session's own edits as working-tree changes rather than staged ones.
|
|
39
|
+
The content survives; the staging does not.
|
|
40
|
+
"""
|
|
41
|
+
import contextlib
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import socket
|
|
46
|
+
import subprocess
|
|
47
|
+
import time
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
#: `git.*` from config — bound by `config.apply`, same as everywhere else.
|
|
52
|
+
GIT: dict = {"commit": False, "push": False, "remote": "origin", "paths": ["work"]}
|
|
53
|
+
|
|
54
|
+
#: Trailer keys. Named here once because four other pieces of work read them, and a
|
|
55
|
+
#: format that drifts breaks the record silently rather than loudly.
|
|
56
|
+
ITEM, EVENT, MACHINE, SESSION = "Work-Item", "Work-Event", "Work-Machine", "Work-Session"
|
|
57
|
+
|
|
58
|
+
#: Never committed, whatever `git.paths` says. A claim is coordination: it lives on
|
|
59
|
+
#: the machine that granted it, for as long as it is good for, and a replicated one
|
|
60
|
+
#: is two machines holding the same item with neither knowing. A verify run is the
|
|
61
|
+
#: same kind of fact — a pid on one box, true for the minutes it takes — and a
|
|
62
|
+
#: committed one would tell every other checkout a build is running inside it.
|
|
63
|
+
CLAIM = ".claim"
|
|
64
|
+
LOCAL = frozenset({CLAIM, ".verify"})
|
|
65
|
+
|
|
66
|
+
_NET_TIMEOUT = 120
|
|
67
|
+
_ATTEMPTS = 3
|
|
68
|
+
|
|
69
|
+
#: Between an event's fields in the commit body. `_flat` keeps it out of the values
|
|
70
|
+
#: themselves, which is what lets the body be read back exactly.
|
|
71
|
+
SEP = " · "
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _flat(value) -> str:
|
|
75
|
+
"""One line, no separator. A field's own text may say anything; what it may not
|
|
76
|
+
do is look like the end of itself."""
|
|
77
|
+
return str(value).replace("\n", " ").replace("\r", " ").replace(SEP, " - ")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
#: Commands that read the origin before they act. Performance only: what gets
|
|
81
|
+
#: COMMITTED is driven by what actually changed, so a name missing from this set
|
|
82
|
+
#: costs a stale pre-pull and can never cost a write. `sync` is absent because it
|
|
83
|
+
#: refreshes on its own, and being in both places is two fetches for one command.
|
|
84
|
+
WRITES = frozenset({
|
|
85
|
+
"task-new", "epic-new", "feature-new", "version-new", "domain-new", "system-new",
|
|
86
|
+
"place", "move", "handoff", "plan", "session", "release", "archive",
|
|
87
|
+
"next", "drop", "ask", "answer", "verify", "observed", "id-new", "readme",
|
|
88
|
+
"migrate-owner",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def enabled() -> bool:
|
|
93
|
+
"""Whether this repo has asked for its board writes to land in git."""
|
|
94
|
+
return bool(GIT.get("commit"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def machine() -> str:
|
|
98
|
+
"""Which machine acted. `WORK_MACHINE` where something knows a better name for
|
|
99
|
+
this box than its hostname — a daemon that already has an identity for it —
|
|
100
|
+
else the hostname, which every machine has and nobody has to configure."""
|
|
101
|
+
return (os.environ.get("WORK_MACHINE") or socket.gethostname() or "").strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _git(repo, *argv, timeout=30):
|
|
105
|
+
"""Run one git command. Returns (code, stdout, stderr); a git that is missing or
|
|
106
|
+
hangs is a non-zero code with a reason, never an exception — a board write must
|
|
107
|
+
not die because the transport did."""
|
|
108
|
+
try:
|
|
109
|
+
p = subprocess.run(["git", *argv], cwd=str(repo), capture_output=True,
|
|
110
|
+
text=True, timeout=timeout)
|
|
111
|
+
return p.returncode, p.stdout, p.stderr
|
|
112
|
+
except subprocess.TimeoutExpired:
|
|
113
|
+
return 1, "", f"git {argv[0]} timed out after {timeout}s"
|
|
114
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
115
|
+
return 1, "", str(e)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
#: Git takes `index.lock` for the whole of an `add` or a `commit`, so two board
|
|
119
|
+
#: writes at the same instant are one commit and one loser. Measured, four at once:
|
|
120
|
+
#: one committed, three reported `could not commit the board write` — and were WRONG
|
|
121
|
+
#: to, because the winner's pathspec had already swept their files in. A write that
|
|
122
|
+
#: landed must never report as a write that failed.
|
|
123
|
+
_LOCK = re.compile(r"index\.lock|Unable to create.*lock|another git process", re.I)
|
|
124
|
+
_LOCK_TRIES = 5
|
|
125
|
+
_LOCK_WAIT = 0.2
|
|
126
|
+
|
|
127
|
+
#: Board writes take turns. Only the COMMIT is serialised, never the command — some
|
|
128
|
+
#: commands run a test suite, and a lock held across `verify` would stall every
|
|
129
|
+
#: other agent in the repo for the length of a build.
|
|
130
|
+
_HOLD = "work-board.lock"
|
|
131
|
+
_HOLD_WAIT = 0.1
|
|
132
|
+
_HOLD_TRIES = 100
|
|
133
|
+
#: A lock older than this belonged to a process that died holding it. Generous
|
|
134
|
+
#: against a commit, which takes milliseconds, and still short enough that a crash
|
|
135
|
+
#: at 3am does not strand the board until somebody notices.
|
|
136
|
+
_HOLD_STALE = 120
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _locking(repo, *argv, timeout=30):
|
|
140
|
+
"""`_git`, but a lost race waits and asks again rather than dropping the write."""
|
|
141
|
+
for attempt in range(_LOCK_TRIES):
|
|
142
|
+
code, out, err = _git(repo, *argv, timeout=timeout)
|
|
143
|
+
if code == 0 or not _LOCK.search(err or ""):
|
|
144
|
+
return code, out, err
|
|
145
|
+
if attempt < _LOCK_TRIES - 1:
|
|
146
|
+
time.sleep(_LOCK_WAIT * (attempt + 1))
|
|
147
|
+
return code, out, err
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@contextlib.contextmanager
|
|
151
|
+
def _hold(repo):
|
|
152
|
+
"""Serialise the commit against other board writes on this machine.
|
|
153
|
+
|
|
154
|
+
In the git directory rather than the work tree, because it is machine-local
|
|
155
|
+
coordination and belongs where `.claim` belongs — nowhere near a branch. A lock
|
|
156
|
+
that cannot be taken is WAITED for and then ignored: the commit is the thing
|
|
157
|
+
that matters, and blocking a board write forever to protect a lock would be the
|
|
158
|
+
lock winning over the work it exists to protect.
|
|
159
|
+
"""
|
|
160
|
+
code, out, _ = _git(repo, "rev-parse", "--absolute-git-dir")
|
|
161
|
+
lock = Path(out.strip()) / _HOLD if code == 0 and out.strip() else None
|
|
162
|
+
held = False
|
|
163
|
+
for _ in range(_HOLD_TRIES):
|
|
164
|
+
if lock is None:
|
|
165
|
+
break
|
|
166
|
+
try:
|
|
167
|
+
os.close(os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY))
|
|
168
|
+
held = True
|
|
169
|
+
break
|
|
170
|
+
except FileExistsError:
|
|
171
|
+
try:
|
|
172
|
+
if time.time() - lock.stat().st_mtime > _HOLD_STALE:
|
|
173
|
+
lock.unlink()
|
|
174
|
+
continue
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
time.sleep(_HOLD_WAIT)
|
|
178
|
+
except OSError:
|
|
179
|
+
break
|
|
180
|
+
try:
|
|
181
|
+
yield
|
|
182
|
+
finally:
|
|
183
|
+
if held:
|
|
184
|
+
try:
|
|
185
|
+
lock.unlink()
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_repo(repo) -> bool:
|
|
191
|
+
return _git(repo, "rev-parse", "--git-dir")[0] == 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ignored(repo) -> list:
|
|
195
|
+
"""Which configured paths git is ignoring — the silent version of this whole
|
|
196
|
+
failure, and the one that wears a success's clothes.
|
|
197
|
+
|
|
198
|
+
A repo that asks for the guarantee with `work/` in its `.gitignore` gets a
|
|
199
|
+
write on disk, a green `doctor`, and an item in no git history anywhere: the
|
|
200
|
+
exact failure this file exists to close. `git status` cannot report it, because
|
|
201
|
+
an ignored file is not untracked to git — it is invisible. So it is asked for
|
|
202
|
+
by name, where the answer is cheap: once at `doctor`, and once at the moment a
|
|
203
|
+
write finds nothing to commit.
|
|
204
|
+
"""
|
|
205
|
+
paths = [p for p in (GIT.get("paths") or []) if p]
|
|
206
|
+
if not paths:
|
|
207
|
+
return []
|
|
208
|
+
code, out, _ = _git(repo, "check-ignore", "--", *paths)
|
|
209
|
+
# By LINE, not by whitespace: a configured path is free to contain a space, and
|
|
210
|
+
# splitting one into two paths that do not exist would report a failure nobody
|
|
211
|
+
# can act on.
|
|
212
|
+
return sorted({p for p in out.splitlines() if p.strip()}) if code == 0 else []
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def has_remote(repo, name: str) -> bool:
|
|
216
|
+
"""Whether a remote by that name exists. `doctor` asks, so a repo pushing to a
|
|
217
|
+
name nothing answers to hears it once rather than on every board write."""
|
|
218
|
+
code, out, _ = _git(repo, "remote")
|
|
219
|
+
return code == 0 and name in out.split()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def refresh(repo) -> str:
|
|
223
|
+
"""Bring the branch up to date before acting. Returns "" when it worked, or
|
|
224
|
+
when there was nothing to do; else one line saying why not.
|
|
225
|
+
|
|
226
|
+
Fast-forward only, on purpose: this runs before ordinary commands, and a command
|
|
227
|
+
that quietly rebased somebody's branch as a side effect of listing the board
|
|
228
|
+
would be a far worse surprise than a stale read. When the branch has diverged
|
|
229
|
+
this does nothing and says so; the push path below is where a rebase is asked
|
|
230
|
+
for explicitly.
|
|
231
|
+
"""
|
|
232
|
+
if not GIT.get("push"):
|
|
233
|
+
# Not a failure, and not worth saying on every write: a repo that commits
|
|
234
|
+
# without pushing has no origin to be behind in the first place. `sync` says
|
|
235
|
+
# it once, where somebody actually asked.
|
|
236
|
+
return ""
|
|
237
|
+
code, _, err = _git(repo, "fetch", GIT["remote"], timeout=_NET_TIMEOUT)
|
|
238
|
+
if code != 0:
|
|
239
|
+
return f"could not reach {GIT['remote']}: {_tail(err)}"
|
|
240
|
+
# A branch nobody tracks has nothing to fast-forward FROM. That is an ordinary
|
|
241
|
+
# state for a branch cut this morning, not a problem, so it says nothing — a
|
|
242
|
+
# warning on every write is a warning people learn to scroll past.
|
|
243
|
+
if _git(repo, "rev-parse", "--abbrev-ref", "@{upstream}")[0] != 0:
|
|
244
|
+
return ""
|
|
245
|
+
before = _git(repo, "rev-parse", "HEAD")[1].strip()
|
|
246
|
+
code, _, err = _git(repo, "merge", "--ff-only", "@{upstream}")
|
|
247
|
+
if code != 0:
|
|
248
|
+
return f"not fast-forwarded: {_tail(err)}"
|
|
249
|
+
after = _git(repo, "rev-parse", "HEAD")[1].strip()
|
|
250
|
+
if not before or before == after:
|
|
251
|
+
return ""
|
|
252
|
+
# The ground moved. WHAT moved and WHO moved it, because that is the question a
|
|
253
|
+
# session actually has at this moment — not that a fetch happened, which it can
|
|
254
|
+
# see, but whether the plan it is holding is still the plan, and who to ask.
|
|
255
|
+
from . import peers
|
|
256
|
+
return peers.arrivals(_log(repo, f"{before}..{after}"))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
#: How often the origin is asked how far ahead it is. Each `work` run is its own
|
|
260
|
+
#: process, so the memo cannot live in memory — it is recorded inside `.git/`, which
|
|
261
|
+
#: is never committed and belongs to exactly the clone the answer is about.
|
|
262
|
+
STALENESS_TTL = 60
|
|
263
|
+
_PROBE = "work-origin-probe.json"
|
|
264
|
+
#: The probe runs on `list` and `status` — READS, and the most-run commands there
|
|
265
|
+
#: are. `_NET_TIMEOUT` is the budget for a push, where waiting is the point; a black
|
|
266
|
+
#: -holed origin held `work list` for two full minutes on it. A read that cannot get
|
|
267
|
+
#: an answer quickly is better off saying it could not.
|
|
268
|
+
_PROBE_TIMEOUT = 10
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _probe_path(repo):
|
|
272
|
+
d = _git(repo, "rev-parse", "--git-dir")[1].strip()
|
|
273
|
+
return (Path(repo) / d / _PROBE) if d else None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _last_probe(repo) -> tuple:
|
|
277
|
+
"""`(age_seconds, reachable, last_success_iso)` of the last probe.
|
|
278
|
+
|
|
279
|
+
Its OUTCOME is recorded, not merely its time. `FETCH_HEAD`'s mtime looks like the
|
|
280
|
+
obvious answer and is a trap: git touches it on a fetch that FAILED too, so a
|
|
281
|
+
clone that cannot reach its origin reads its own failed attempt back as a recent
|
|
282
|
+
successful sync — and then reports itself current, which is precisely the lie this
|
|
283
|
+
is here to end.
|
|
284
|
+
|
|
285
|
+
The third field is the last time the origin was actually REACHED, which is a
|
|
286
|
+
different fact from when it was last asked. Conflating them puts a fresh
|
|
287
|
+
timestamp next to the words "last synced" on a clone that has not synced at all —
|
|
288
|
+
the same lie, one layer further down, in the sentence instead of the cache.
|
|
289
|
+
"""
|
|
290
|
+
p = _probe_path(repo)
|
|
291
|
+
try:
|
|
292
|
+
data = json.loads(p.read_text())
|
|
293
|
+
return (time.time() - float(data["at"]), bool(data["reachable"]),
|
|
294
|
+
str(data.get("synced", "")))
|
|
295
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
296
|
+
return None, None, ""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _record_probe(repo, reachable: bool) -> str:
|
|
300
|
+
"""Remember this attempt, and carry the last SUCCESS forward across failures.
|
|
301
|
+
|
|
302
|
+
A failed probe advances the attempt clock and nothing else: the only honest
|
|
303
|
+
answer to "when did this board last see its origin" is unchanged by an attempt
|
|
304
|
+
that did not.
|
|
305
|
+
"""
|
|
306
|
+
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
307
|
+
synced = now if reachable else (_last_probe(repo)[2] or "")
|
|
308
|
+
p = _probe_path(repo)
|
|
309
|
+
try:
|
|
310
|
+
p.write_text(json.dumps({"at": time.time(), "reachable": reachable,
|
|
311
|
+
"synced": synced}))
|
|
312
|
+
except (OSError, AttributeError):
|
|
313
|
+
pass
|
|
314
|
+
return synced
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def behind(repo) -> dict:
|
|
318
|
+
"""Where this checkout stands against its upstream.
|
|
319
|
+
|
|
320
|
+
`{}` when the branch tracks nothing — a branch cut this morning has no origin to
|
|
321
|
+
be behind, and a notice every new branch earns for nothing is one people learn to
|
|
322
|
+
scroll past. Otherwise `ref` and EITHER `behind` (a fact, and only ever when the
|
|
323
|
+
origin actually answered) or `reachable: False` with when it was last really seen.
|
|
324
|
+
|
|
325
|
+
Counting against a remote-tracking ref nobody refreshed is the failure this
|
|
326
|
+
closes: it answers zero with total confidence, which is how a board nine days
|
|
327
|
+
stale reported itself current.
|
|
328
|
+
"""
|
|
329
|
+
ref = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")[1].strip()
|
|
330
|
+
if not ref:
|
|
331
|
+
return {}
|
|
332
|
+
age, was_reachable, when = _last_probe(repo)
|
|
333
|
+
# Inside the window the last answer still stands — including a negative one, which
|
|
334
|
+
# is why the outcome is stored rather than just the timestamp.
|
|
335
|
+
if age is None or age > STALENESS_TTL:
|
|
336
|
+
was_reachable = _git(repo, "fetch", "--quiet", "--no-tags",
|
|
337
|
+
GIT.get("remote") or "origin",
|
|
338
|
+
timeout=_PROBE_TIMEOUT)[0] == 0
|
|
339
|
+
when = _record_probe(repo, was_reachable)
|
|
340
|
+
if not was_reachable:
|
|
341
|
+
return {"ref": ref, "reachable": False, "synced": when}
|
|
342
|
+
count = _git(repo, "rev-list", "--count", f"HEAD..{ref}")[1].strip()
|
|
343
|
+
if not count.isdigit():
|
|
344
|
+
return {"ref": ref, "reachable": False, "synced": when}
|
|
345
|
+
return {"ref": ref, "reachable": True, "behind": int(count), "synced": when}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def staleness(repo) -> str:
|
|
349
|
+
"""One line for a reader, or "" when this checkout is level with its origin.
|
|
350
|
+
|
|
351
|
+
Silent on the happy path by design — the notice has to mean something the moment
|
|
352
|
+
it appears, and a board that says "current" on every command teaches nobody to
|
|
353
|
+
read it.
|
|
354
|
+
"""
|
|
355
|
+
# `push` as well as `commit`: a repo that commits without ever sending has no
|
|
356
|
+
# origin to be behind, `refresh` already stays silent about it for that reason,
|
|
357
|
+
# and `sync` refuses to pull in that mode — so the notice would spend a network
|
|
358
|
+
# round trip to recommend a command that declines to help.
|
|
359
|
+
if not enabled() or not GIT.get("push"):
|
|
360
|
+
return ""
|
|
361
|
+
state = behind(repo)
|
|
362
|
+
if not state:
|
|
363
|
+
return ""
|
|
364
|
+
from .tree import cli
|
|
365
|
+
|
|
366
|
+
if not state.get("reachable"):
|
|
367
|
+
seen = state.get("synced")
|
|
368
|
+
return (f"cannot reach {state['ref']} — this board may not be current"
|
|
369
|
+
+ (f", last synced {seen}" if seen
|
|
370
|
+
else " and has never reached its origin"))
|
|
371
|
+
n = state.get("behind") or 0
|
|
372
|
+
if n <= 0:
|
|
373
|
+
return ""
|
|
374
|
+
return (f"this board is {n} commit(s) behind {state['ref']} — you are reading an "
|
|
375
|
+
f"older world than the origin's. `{cli()} sync` catches up")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def changed(repo) -> tuple:
|
|
379
|
+
"""What a commit would carry: `(paths, untracked)`, claims excluded.
|
|
380
|
+
|
|
381
|
+
Both are needed because they are staged differently. An untracked file has to be
|
|
382
|
+
ADDED or the commit cannot name it — that is the lost item's own shape, a new
|
|
383
|
+
task folder git had never heard of. Everything else, including a file somebody
|
|
384
|
+
deleted, is taken from the working tree by the commit itself; running `git add`
|
|
385
|
+
over a path already staged for deletion fails outright, since it is in neither
|
|
386
|
+
the worktree nor the index for the pathspec to match.
|
|
387
|
+
"""
|
|
388
|
+
args = ["status", "--porcelain", "-z", "--untracked-files=all", "--no-renames",
|
|
389
|
+
"--"] + list(GIT.get("paths") or [])
|
|
390
|
+
code, out, _ = _git(repo, *args)
|
|
391
|
+
if code != 0:
|
|
392
|
+
return [], []
|
|
393
|
+
paths, untracked = [], []
|
|
394
|
+
for entry in out.split("\0"):
|
|
395
|
+
# `XY <path>` — two status columns, a space, then the path, unquoted
|
|
396
|
+
# because of `-z`.
|
|
397
|
+
if len(entry) > 3:
|
|
398
|
+
state, p = entry[:2], entry[3:]
|
|
399
|
+
if Path(p).name in LOCAL:
|
|
400
|
+
continue
|
|
401
|
+
paths.append(p)
|
|
402
|
+
if state == "??":
|
|
403
|
+
untracked.append(p)
|
|
404
|
+
return paths, untracked
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def only_board_moved(repo, sha: str) -> bool:
|
|
408
|
+
"""Is everything between `sha` and HEAD a board file?
|
|
409
|
+
|
|
410
|
+
The completion gate refuses when the code moved since verify passed, which is
|
|
411
|
+
right — and once board writes commit, `verify` recording its own result MOVES
|
|
412
|
+
HEAD, so the gate started refusing on evidence it had just produced. A commit
|
|
413
|
+
carrying nothing but `git.paths` cannot change what a test run proved, so it
|
|
414
|
+
does not count as the code moving.
|
|
415
|
+
"""
|
|
416
|
+
if not enabled() or not sha:
|
|
417
|
+
return False
|
|
418
|
+
code, out, _ = _git(repo, "diff", "--name-only", f"{sha}..HEAD")
|
|
419
|
+
if code != 0:
|
|
420
|
+
return False
|
|
421
|
+
roots = [r.rstrip("/") for r in (GIT.get("paths") or [])]
|
|
422
|
+
return all(any(f == r or f.startswith(f"{r}/") for r in roots)
|
|
423
|
+
for f in out.splitlines() if f.strip())
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def land(repo, item: str, rows: list) -> tuple:
|
|
427
|
+
"""Commit what changed and push it. Returns (committed, pushed, note).
|
|
428
|
+
|
|
429
|
+
`note` is what to tell the caller — empty when everything landed. A push that
|
|
430
|
+
cannot land is NOT a failure of the write: the change is committed, which is
|
|
431
|
+
what makes it unloseable, and the note says how to send it.
|
|
432
|
+
"""
|
|
433
|
+
# Asked FIRST, and not after `changed`: a directory that is not a repo makes
|
|
434
|
+
# `git status` fail, `changed` return nothing, and the write look like a no-op
|
|
435
|
+
# with nothing to say. Silence is the one answer this must never give — a repo
|
|
436
|
+
# that asked for the guarantee and cannot have it needs telling.
|
|
437
|
+
if not is_repo(repo):
|
|
438
|
+
return False, False, "not a git repository — the board write is on disk only"
|
|
439
|
+
|
|
440
|
+
# Only the commit takes the hold; the push is left outside it, because a slow
|
|
441
|
+
# network must not be able to stall every other board write on this machine.
|
|
442
|
+
with _hold(repo):
|
|
443
|
+
paths, untracked = changed(repo)
|
|
444
|
+
if not paths:
|
|
445
|
+
# Nothing to commit is usually the ordinary answer — a read, or a write
|
|
446
|
+
# that changed nothing — and says nothing. Two cases are not ordinary.
|
|
447
|
+
skipped = ignored(repo)
|
|
448
|
+
if skipped:
|
|
449
|
+
# "Nothing changed" is a lie git cannot help telling about a file it
|
|
450
|
+
# was told to ignore, and this is where a repo finds out before the
|
|
451
|
+
# item it just made goes missing.
|
|
452
|
+
return False, False, (
|
|
453
|
+
f"git is ignoring {', '.join(skipped)}, so NOTHING here reaches "
|
|
454
|
+
f"git — a board write cannot be committed while it is gitignored. "
|
|
455
|
+
f"Un-ignore it, or point git.paths at a tree that is tracked.")
|
|
456
|
+
if rows:
|
|
457
|
+
# This command DID write, and its changes are gone from the working
|
|
458
|
+
# tree: a board write that landed a moment earlier committed the
|
|
459
|
+
# whole of `git.paths` and carried them along. The work is in git.
|
|
460
|
+
# What it is not is filed under its own name — see the note below.
|
|
461
|
+
return False, False, (
|
|
462
|
+
"carried by a board write that committed a moment earlier — the "
|
|
463
|
+
"change is in git, under that commit's item rather than this one")
|
|
464
|
+
return False, False, ""
|
|
465
|
+
|
|
466
|
+
if untracked:
|
|
467
|
+
code, _, err = _locking(repo, "add", "--", *untracked)
|
|
468
|
+
if code != 0:
|
|
469
|
+
return False, False, f"could not stage the board write: {_tail(err)}"
|
|
470
|
+
|
|
471
|
+
subject, body, trailers = _message(item, rows)
|
|
472
|
+
# A command that recorded no event has no body to write, and repeating the
|
|
473
|
+
# subject there would only be a second copy of it.
|
|
474
|
+
message = ["-m", subject] + (["-m", body] if body else []) + ["-m", trailers]
|
|
475
|
+
code, _, err = _locking(repo, "commit", "--no-verify", *message, "--", *paths)
|
|
476
|
+
if code != 0:
|
|
477
|
+
return False, False, f"could not commit the board write: {_tail(err)}"
|
|
478
|
+
|
|
479
|
+
if not GIT.get("push"):
|
|
480
|
+
return True, False, ""
|
|
481
|
+
pushed, why = _push(repo)
|
|
482
|
+
return True, pushed, why
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _push(repo) -> tuple:
|
|
486
|
+
"""Push, and when the branch moved under us, rebase onto the new tip and try
|
|
487
|
+
again. Returns (pushed, why-not).
|
|
488
|
+
|
|
489
|
+
A rebase that hits a conflict is aborted rather than left half-done: a session
|
|
490
|
+
dropped into a conflicted rebase it did not ask for cannot get on with its work,
|
|
491
|
+
and the commit is already safe.
|
|
492
|
+
"""
|
|
493
|
+
remote = GIT["remote"]
|
|
494
|
+
why = ""
|
|
495
|
+
for attempt in range(_ATTEMPTS):
|
|
496
|
+
# `-u` on purpose, and it is a fix rather than a nicety: without it a branch
|
|
497
|
+
# never gets an upstream, `@{upstream}` never resolves, and `sync` — the
|
|
498
|
+
# documented recovery for a push that could not land — read that as "nothing
|
|
499
|
+
# waiting" and left the commit sitting local forever. Every branch an agent
|
|
500
|
+
# cuts starts in exactly that state.
|
|
501
|
+
code, _, err = _git(repo, "push", "-u", remote, "HEAD", timeout=_NET_TIMEOUT)
|
|
502
|
+
if code == 0:
|
|
503
|
+
return True, ""
|
|
504
|
+
why = _tail(err)
|
|
505
|
+
if not re.search(r"rejected|non-fast-forward|fetch first|behind", err, re.I):
|
|
506
|
+
break
|
|
507
|
+
if attempt == _ATTEMPTS - 1:
|
|
508
|
+
break
|
|
509
|
+
_, pre, _ = _git(repo, "rev-parse", "HEAD")
|
|
510
|
+
code, _, rebase_err = _git(repo, "-c", "rebase.autoStash=true", "pull",
|
|
511
|
+
"--rebase", remote, timeout=_NET_TIMEOUT)
|
|
512
|
+
if code != 0:
|
|
513
|
+
_git(repo, "rebase", "--abort")
|
|
514
|
+
why = f"the branch moved and the rebase onto it did not apply: {_tail(rebase_err)}"
|
|
515
|
+
break
|
|
516
|
+
stranded = _unstash(repo, pre.strip())
|
|
517
|
+
if stranded:
|
|
518
|
+
why = stranded
|
|
519
|
+
break
|
|
520
|
+
from .tree import cli
|
|
521
|
+
|
|
522
|
+
return False, (f"committed here, NOT pushed — {why}. The work is in git locally; "
|
|
523
|
+
f"`{cli()} sync` sends it when you can reach {remote}.")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _unstash(repo, pre: str) -> str:
|
|
527
|
+
"""Undo a pull whose autostash could not be put back. Returns why-not, or "".
|
|
528
|
+
|
|
529
|
+
`pull --rebase` exits **0** when the rebase itself lands and only the autostash
|
|
530
|
+
pop conflicts, so without this the caller reads success: the push goes out and
|
|
531
|
+
the session is told the write was pushed, while its working tree is left holding
|
|
532
|
+
conflict markers it did not ask for and its own uncommitted edits sit in a stash
|
|
533
|
+
nobody mentioned. Going back to where the pull started puts those edits back
|
|
534
|
+
where their author left them, which is the same promise the abort above makes.
|
|
535
|
+
|
|
536
|
+
The board commit is never at risk — it was made before any of this, and it is
|
|
537
|
+
inside `pre`. It stays committed and unpushed, which is what `sync` is for.
|
|
538
|
+
"""
|
|
539
|
+
_, unmerged, _ = _git(repo, "diff", "--name-only", "--diff-filter=U")
|
|
540
|
+
if not unmerged.strip() or not pre:
|
|
541
|
+
return ""
|
|
542
|
+
clash = "the branch moved, and your uncommitted edits clash with what was on it"
|
|
543
|
+
# Every step is checked before the next one claims it happened. Saying "your
|
|
544
|
+
# working tree is as you left it" over a reset that did not run would be the
|
|
545
|
+
# same false report this whole function exists to stop.
|
|
546
|
+
code, _, err = _locking(repo, "reset", "--hard", pre)
|
|
547
|
+
if code != 0:
|
|
548
|
+
return (f"{clash}, and the tree could not be put back ({_tail(err)}) — "
|
|
549
|
+
f"your edits are in `git stash`, and the rebase is half-applied")
|
|
550
|
+
code, _, err = _locking(repo, "stash", "pop")
|
|
551
|
+
if code != 0:
|
|
552
|
+
return f"{clash} — nothing was rebased, and your edits are in `git stash` ({_tail(err)})"
|
|
553
|
+
return f"{clash} — nothing was rebased and your working tree is as you left it"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _message(item: str, rows: list) -> tuple:
|
|
557
|
+
"""(subject, body, trailers) for one board commit.
|
|
558
|
+
|
|
559
|
+
The body is one line per event, in the order they happened, carrying that
|
|
560
|
+
event's own detail as prose. The TRAILERS stay four keys wide — that is the part
|
|
561
|
+
other work reads, and every field added to it is a field that can drift.
|
|
562
|
+
"""
|
|
563
|
+
kinds = [str(r.get("event", "")) for r in rows if r.get("event")]
|
|
564
|
+
# No event is a legal commit: some commands write the tree without recording
|
|
565
|
+
# one. It carries `Work-Item` and no `Work-Event`, so it is greppable by item
|
|
566
|
+
# and simply absent from the activity log — which is what it was already. With
|
|
567
|
+
# no item either, it is a hand edit somebody made to the tree, swept up by
|
|
568
|
+
# whatever ran next.
|
|
569
|
+
subject = (f"docs(work): {kinds[-1] if kinds else 'changed'} {item}".strip()
|
|
570
|
+
if item else "docs(work): board edits")
|
|
571
|
+
|
|
572
|
+
lines = []
|
|
573
|
+
for r in rows:
|
|
574
|
+
detail = SEP.join(f"{k}={_flat(v)}" for k, v in sorted(r.items())
|
|
575
|
+
if k not in ("ts", "event", "name", "by"))
|
|
576
|
+
lines.append(f"{r.get('event', '')} {detail}".rstrip())
|
|
577
|
+
|
|
578
|
+
trailers = [f"{ITEM}: {item}"] if item else []
|
|
579
|
+
trailers += [f"{EVENT}: {k}" for k in kinds]
|
|
580
|
+
if machine():
|
|
581
|
+
trailers.append(f"{MACHINE}: {machine()}")
|
|
582
|
+
who = _session()
|
|
583
|
+
if who:
|
|
584
|
+
trailers.append(f"{SESSION}: {who}")
|
|
585
|
+
return subject, "\n".join(lines), "\n".join(trailers)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _session() -> str:
|
|
589
|
+
return (os.environ.get("WORK_INSTANCE")
|
|
590
|
+
or os.environ.get("CLAUDE_CODE_SESSION_ID", "")).strip()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
#: One commit, rendered for parsing: NUL between records, unit separators inside.
|
|
594
|
+
_FORMAT = "%x00%H%x1f%aI%x1f%an%x1f%B"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _log(repo, *extra) -> list:
|
|
598
|
+
"""Board events out of `git log`, oldest first. One parser for every caller, so
|
|
599
|
+
what `digest` reads and what a pull reports can never be two different shapes."""
|
|
600
|
+
code, out, _ = _git(repo, "log", "--reverse", f"--format={_FORMAT}", "-E",
|
|
601
|
+
f"--grep=^{ITEM}: ", *extra, timeout=60)
|
|
602
|
+
if code != 0:
|
|
603
|
+
return []
|
|
604
|
+
rows = []
|
|
605
|
+
for record in out.split("\0"):
|
|
606
|
+
if record.strip():
|
|
607
|
+
rows.extend(_events(record))
|
|
608
|
+
return rows
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def read(repo, since: str = "", name: str = "") -> list:
|
|
612
|
+
"""The board's history, oldest first, read back out of the commits.
|
|
613
|
+
|
|
614
|
+
Local: `git log` reads this clone and nothing else, so history costs no network
|
|
615
|
+
and has no rate limit. There is no index and no cache — a fact that can be
|
|
616
|
+
derived is not stored, and this one can.
|
|
617
|
+
"""
|
|
618
|
+
rows = _log(repo, *([f"--since={_since(since)}"] if since else []))
|
|
619
|
+
return [r for r in rows if not name or r.get("name") == name]
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _events(record: str) -> list:
|
|
623
|
+
"""Every event one commit carried, back in the shape the readers expect."""
|
|
624
|
+
parts = record.split("\x1f")
|
|
625
|
+
if len(parts) < 4:
|
|
626
|
+
return []
|
|
627
|
+
sha, when, author, message = parts[0], parts[1], parts[2], parts[3]
|
|
628
|
+
trailers = _trailers(message)
|
|
629
|
+
item = (trailers.get(ITEM) or [""])[0]
|
|
630
|
+
kinds = trailers.get(EVENT) or []
|
|
631
|
+
if not item or not kinds:
|
|
632
|
+
return []
|
|
633
|
+
details = _details(message, kinds)
|
|
634
|
+
who = (trailers.get(SESSION) or [""])[0]
|
|
635
|
+
host = (trailers.get(MACHINE) or [""])[0]
|
|
636
|
+
out = []
|
|
637
|
+
for kind in kinds:
|
|
638
|
+
e = {"ts": _utc(when), "event": kind, "name": item, "sha": sha[:12],
|
|
639
|
+
"author": author}
|
|
640
|
+
if who:
|
|
641
|
+
e["by"] = who
|
|
642
|
+
if host:
|
|
643
|
+
e["machine"] = host
|
|
644
|
+
e.update(details.get(kind) or {})
|
|
645
|
+
out.append(e)
|
|
646
|
+
return out
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
#: A plain calendar day, which is the only `--since` the skill documents.
|
|
650
|
+
_DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _since(since: str) -> str:
|
|
654
|
+
"""`--since` on the same clock as the rows it selects.
|
|
655
|
+
|
|
656
|
+
Git fills the fields a date leaves out from NOW, not from midnight — so
|
|
657
|
+
`--since 2026-08-20` means *the 20th at the current time of day*, and `work
|
|
658
|
+
digest --since <today>` answered with the last few seconds of the shift and
|
|
659
|
+
nothing else. Measured: two commits on the 19th, `--since=2026-08-19` returned
|
|
660
|
+
neither; a commit made this morning, `--since=<today>` returned none.
|
|
661
|
+
|
|
662
|
+
The file backend compares the UTC timestamp against the date as text, so the day
|
|
663
|
+
starts at UTC midnight there. Anchoring the day makes both backends answer the
|
|
664
|
+
same question. Anything that is not a plain day — git's own fuzzy forms, "2
|
|
665
|
+
weeks ago" — is git's to interpret and passes through untouched.
|
|
666
|
+
"""
|
|
667
|
+
return f"{since}T00:00:00+00:00" if _DAY.match(since or "") else since
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _utc(when: str) -> str:
|
|
671
|
+
"""One clock, one format, whichever backend answered.
|
|
672
|
+
|
|
673
|
+
`%aI` is the author's own wall time and carries their offset, so the instant is
|
|
674
|
+
recoverable — but truncated to its first 19 characters it becomes a bare local
|
|
675
|
+
time that READS as UTC, which is what the file backend actually records. Two
|
|
676
|
+
records of the same board, hours apart, with nothing on either to say which.
|
|
677
|
+
Converted here rather than trimmed, into the shape `events` already writes.
|
|
678
|
+
"""
|
|
679
|
+
try:
|
|
680
|
+
return (datetime.fromisoformat(when).astimezone(timezone.utc)
|
|
681
|
+
.replace(microsecond=0).isoformat())
|
|
682
|
+
except ValueError:
|
|
683
|
+
# A git answering in some other dialect is not worth dying over — the date is
|
|
684
|
+
# the least of what this row carries. Returned WHOLE rather than trimmed to
|
|
685
|
+
# 19 characters: a trim is what produced a local time wearing UTC's clothes
|
|
686
|
+
# in the first place, and a fallback that restores the original bug quietly
|
|
687
|
+
# is worse than one that reads oddly and can be seen.
|
|
688
|
+
return when
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
_TRAILER = re.compile(r"^(Work-[A-Za-z]+):[ \t]*(.*)$")
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _trailers(message: str) -> dict:
|
|
695
|
+
out = {}
|
|
696
|
+
for line in message.splitlines():
|
|
697
|
+
m = _TRAILER.match(line.strip())
|
|
698
|
+
if m:
|
|
699
|
+
out.setdefault(m.group(1), []).append(m.group(2).strip())
|
|
700
|
+
return out
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _details(message: str, kinds) -> dict:
|
|
704
|
+
"""The body's per-event payload, matched back to its event by name.
|
|
705
|
+
|
|
706
|
+
A round trip rather than prose, so `log`, `digest` and `status` print the same
|
|
707
|
+
thing whichever backend the repo runs. `_flat` is what makes it safe: the
|
|
708
|
+
separator can never appear inside a value, so a question with an odd character
|
|
709
|
+
in it comes back whole instead of splitting into a field nobody wrote.
|
|
710
|
+
"""
|
|
711
|
+
out = {}
|
|
712
|
+
for line in message.splitlines():
|
|
713
|
+
line = line.strip()
|
|
714
|
+
if _TRAILER.match(line):
|
|
715
|
+
continue
|
|
716
|
+
head, _, rest = line.partition(" ")
|
|
717
|
+
if head not in kinds or not rest.strip() or head in out:
|
|
718
|
+
continue
|
|
719
|
+
fields = {}
|
|
720
|
+
for chunk in rest.split(SEP):
|
|
721
|
+
key, sign, value = chunk.partition("=")
|
|
722
|
+
if sign and key.strip():
|
|
723
|
+
fields[key.strip()] = value.strip()
|
|
724
|
+
out[head] = fields
|
|
725
|
+
return out
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def cmd_sync(args=None) -> int:
|
|
729
|
+
"""Pull the board fresh, and send anything committed here that never got out.
|
|
730
|
+
|
|
731
|
+
The read half is what an agent runs when it is not sure something moved under
|
|
732
|
+
it — it changes nothing, so it is safe to run before acting on anything. The
|
|
733
|
+
write half is the recovery path for a push that could not land at the time.
|
|
734
|
+
"""
|
|
735
|
+
from .tree import die, locate_work_root
|
|
736
|
+
if not enabled():
|
|
737
|
+
die("this repo does not write its board to git — set `git.commit` in "
|
|
738
|
+
".claude/work.config.json if you want it to")
|
|
739
|
+
root = locate_work_root()[0]
|
|
740
|
+
if root is None:
|
|
741
|
+
die("no work/ tree found — nothing here to sync")
|
|
742
|
+
repo = root.parent
|
|
743
|
+
if not is_repo(repo):
|
|
744
|
+
die(f"{repo} is not a git repository")
|
|
745
|
+
|
|
746
|
+
if not GIT.get("push"):
|
|
747
|
+
print("pulled git.push is off — there is no origin to refresh from")
|
|
748
|
+
print("pushed git.push is off — this repo commits but never sends")
|
|
749
|
+
return 0
|
|
750
|
+
note = refresh(repo)
|
|
751
|
+
print(f"pulled {'up to date' if not note else note}")
|
|
752
|
+
# A branch with no upstream cannot answer "how far ahead am I" — and the answer
|
|
753
|
+
# this asks for is the one that decides whether to send. Unknown therefore means
|
|
754
|
+
# SEND: a `sync` that stays silent about work it never pushed is the failure it
|
|
755
|
+
# was written to recover from.
|
|
756
|
+
code, out, _ = _git(repo, "rev-list", "--count", "@{upstream}..HEAD")
|
|
757
|
+
ahead = out.strip() if code == 0 else ""
|
|
758
|
+
if ahead == "0":
|
|
759
|
+
print("pushed nothing waiting")
|
|
760
|
+
return 0
|
|
761
|
+
pushed, why = _push(repo)
|
|
762
|
+
waiting = f"{ahead} commit(s)" if ahead else "this branch"
|
|
763
|
+
print(f"pushed {waiting} to {GIT['remote']}" if pushed else f"pushed {why}")
|
|
764
|
+
return 0 if pushed else 1
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _tail(text: str) -> str:
|
|
768
|
+
"""Git's first real complaint. The FIRST line, not the last: git follows a
|
|
769
|
+
`fatal:` with a paragraph of advice, and the closing line of that paragraph
|
|
770
|
+
("…and the repository exists.") reads as gibberish on its own."""
|
|
771
|
+
for line in (text or "").splitlines():
|
|
772
|
+
line = line.strip()
|
|
773
|
+
if line and not line.startswith("hint:"):
|
|
774
|
+
return line[:200]
|
|
775
|
+
return "no reason given"
|