groundfast 0.7.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.
- package/README.md +67 -0
- package/extensions/index.ts +415 -0
- package/package.json +53 -0
- package/references/coverage.md +89 -0
- package/references/lenses.md +43 -0
- package/references/pr-goal.md +58 -0
- package/references/query-safety.md +50 -0
- package/scripts/cov-marker.sh +53 -0
- package/scripts/cr-comment.sh +159 -0
- package/scripts/cr-status.sh +161 -0
- package/scripts/origin-guard.sh +52 -0
- package/scripts/redact.sh +24 -0
- package/scripts/strip-shim.sh +10 -0
- package/skills/ask-groundfast/SKILL.md +64 -0
- package/skills/babysit/SKILL.md +134 -0
- package/skills/babysit/references/coderabbit.md +58 -0
- package/skills/babysit/references/loop.md +133 -0
- package/skills/babysit/scripts/checkout-pr.sh +46 -0
- package/skills/babysit/scripts/ci-cause.sh +12 -0
- package/skills/babysit/scripts/cov-comment.sh +115 -0
- package/skills/babysit/scripts/cycle.sh +226 -0
- package/skills/babysit/scripts/gh-thread.sh +164 -0
- package/skills/babysit/scripts/pr-goal.sh +226 -0
- package/skills/babysit/scripts/pr-state.sh +120 -0
- package/skills/babysit/scripts/push-pr.sh +43 -0
- package/skills/babysit/scripts/stage.sh +34 -0
- package/skills/babysit/scripts/wait-ci.sh +50 -0
- package/skills/babysit/scripts/wait-review.sh +203 -0
- package/skills/drain/SKILL.md +151 -0
- package/skills/drain/references/inner-loop.md +52 -0
- package/skills/drain/references/ordering.md +22 -0
- package/skills/drain/references/threads.md +32 -0
- package/skills/drain/scripts/babysit-cmd.sh +23 -0
- package/skills/drain/scripts/commit.sh +15 -0
- package/skills/drain/scripts/conflict-finish.sh +37 -0
- package/skills/drain/scripts/drain-queue.sh +74 -0
- package/skills/drain/scripts/integrate-base.sh +42 -0
- package/skills/drain/scripts/merge-pr.sh +98 -0
- package/skills/drain/scripts/order-queue.sh +203 -0
- package/skills/drain/scripts/review-diff.sh +46 -0
- package/skills/pipeline/SKILL.md +60 -0
- package/skills/pipeline/references/issue-contract.md +51 -0
- package/skills/pipeline/references/issue-loop.md +128 -0
- package/skills/pipeline/references/review-gate.md +17 -0
- package/skills/pipeline/scripts/claim-issue.sh +143 -0
- package/skills/pipeline/scripts/create-pr.sh +44 -0
- package/skills/pipeline/scripts/issue-context.sh +43 -0
- package/skills/pipeline/scripts/issue-note.sh +30 -0
- package/skills/pipeline/scripts/issue-queue.sh +115 -0
- package/skills/pipeline/scripts/pipeline-cmd.sh +53 -0
- package/skills/pipeline/scripts/prepare-issue.sh +87 -0
- package/skills/pipeline/scripts/repo-context.sh +17 -0
- package/skills/pr/SKILL.md +114 -0
- package/skills/pr/references/review-fanout.md +44 -0
- package/skills/pr/scripts/pre-pr-state.sh +102 -0
- package/skills/pr/scripts/push-branch.sh +17 -0
- package/skills/scaffolding-services/SKILL.md +52 -0
- package/skills/scaffolding-services/references/bun.md +69 -0
- package/skills/scaffolding-services/references/rust.md +52 -0
- package/skills/scaffolding-services/scripts/detect-stack.sh +13 -0
- package/skills/scoping-engagement/SKILL.md +77 -0
- package/skills/scoping-engagement/references/discovery-questions.md +62 -0
- package/skills/ship/SKILL.md +80 -0
- package/skills/ship/references/cloudflare.md +10 -0
- package/skills/ship/references/n8n.md +9 -0
- package/skills/ship/references/plugin.md +11 -0
- package/skills/ship/references/railway.md +10 -0
- package/skills/ship/references/vps.md +7 -0
- package/skills/ship/scripts/repo-state.sh +30 -0
- package/skills/tidy/SKILL.md +40 -0
- package/skills/tidy/scripts/orphans.sh +90 -0
- package/skills/wrap/SKILL.md +143 -0
- package/skills/wrap/references/formats.md +85 -0
- package/skills/wrap/references/selection.md +25 -0
- package/skills/wrap/references/session-coverage.md +49 -0
- package/skills/wrap/scripts/learn-file.sh +134 -0
- package/skills/wrap/scripts/repo-state.sh +110 -0
- package/skills/wrap/scripts/session-cover.sh +344 -0
- package/skills/wrap/scripts/tasks.sh +83 -0
- package/skills/wrap/scripts/verify.sh +204 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Metadados de cobertura da sessão explícita. Sem corpo de mensagem.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
abs=${1:-}
|
|
6
|
+
host=${2:-}
|
|
7
|
+
leaf=${3:-}
|
|
8
|
+
|
|
9
|
+
empty="path_len=0 human=0 assistant=0 tool=0 other=0 compaction=false branch_summary=false leaf=- session=-"
|
|
10
|
+
|
|
11
|
+
if [ -z "$abs" ]; then
|
|
12
|
+
# A env do Pi não decide por um host que o chamador já declarou: adotar o
|
|
13
|
+
# arquivo dele com o parser do Claude só produz `session_id_mismatch`.
|
|
14
|
+
if [ -n "${PI_SESSION_FILE:-}" ] && { [ -z "$host" ] || [ "$host" = pi ]; }; then
|
|
15
|
+
abs=$PI_SESSION_FILE
|
|
16
|
+
host=${host:-pi}
|
|
17
|
+
elif [ "$host" = pi ]; then
|
|
18
|
+
# Simétrico ao guard acima: host `pi` declarado e sem arquivo do Pi não
|
|
19
|
+
# herda o transcript do Claude que a env aponta. O `host` continuaria `pi`,
|
|
20
|
+
# e o parser leria `id`/`parentId` num arquivo que traz `uuid`/`parentUuid`
|
|
21
|
+
# — cobertura errada com cara de medida, em vez de ausência declarada.
|
|
22
|
+
echo "coverage=unavailable $empty reasons=pi_session_file_unset"
|
|
23
|
+
exit 0
|
|
24
|
+
else
|
|
25
|
+
sid=${CLAUDE_CODE_SESSION_ID:-${CLAUDE_SESSION_ID:-}}
|
|
26
|
+
case ${sid:-} in
|
|
27
|
+
'') ;;
|
|
28
|
+
*[!A-Za-z0-9-]*)
|
|
29
|
+
echo "coverage=unavailable $empty reasons=invalid_session_id"
|
|
30
|
+
exit 0
|
|
31
|
+
;;
|
|
32
|
+
*)
|
|
33
|
+
host=${host:-claude}
|
|
34
|
+
base=${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd -P)}
|
|
35
|
+
for slug in \
|
|
36
|
+
"$(printf '%s' "$base" | sed 's#^/##; s#/#-#g')" \
|
|
37
|
+
"$(printf '%s' "$base" | sed 's#^/##; s#[/._]#-#g')"
|
|
38
|
+
do
|
|
39
|
+
cand="$HOME/.claude/projects/-$slug/$sid.jsonl"
|
|
40
|
+
[ -f "$cand" ] || continue
|
|
41
|
+
abs=$cand
|
|
42
|
+
break
|
|
43
|
+
done
|
|
44
|
+
if [ -z "$abs" ]; then
|
|
45
|
+
echo "coverage=unavailable $empty reasons=session_file_not_found"
|
|
46
|
+
exit 0
|
|
47
|
+
fi
|
|
48
|
+
;;
|
|
49
|
+
esac
|
|
50
|
+
fi
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
if [ -z "$abs" ]; then
|
|
54
|
+
echo "coverage=unavailable $empty reasons=no_explicit_session"
|
|
55
|
+
exit 0
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# `-e` e não `-f`: um FIFO ou diretório existe, e chamá-lo de ausente mandaria
|
|
59
|
+
# procurar o arquivo errado. Quem o recusa com o motivo certo é o parser, que
|
|
60
|
+
# valida o descritor que abriu e devolve `not_regular_file`.
|
|
61
|
+
if [ ! -e "$abs" ]; then
|
|
62
|
+
echo "coverage=unavailable $empty reasons=explicit_path_missing"
|
|
63
|
+
exit 0
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
case ${host:-} in
|
|
67
|
+
pi|claude) ;;
|
|
68
|
+
'')
|
|
69
|
+
echo "coverage=unavailable $empty reasons=host_unspecified"
|
|
70
|
+
exit 0
|
|
71
|
+
;;
|
|
72
|
+
*)
|
|
73
|
+
echo "coverage=unavailable $empty reasons=unsupported_host"
|
|
74
|
+
exit 0
|
|
75
|
+
;;
|
|
76
|
+
esac
|
|
77
|
+
|
|
78
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
79
|
+
echo "coverage=unavailable $empty reasons=no_python3"
|
|
80
|
+
exit 0
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
# O stderr fica fora: o passo 1 do wrap lê esta saída como uma linha de dado,
|
|
84
|
+
# e um traceback ao lado dela é ruído que o modelo tenta interpretar. Jogar a
|
|
85
|
+
# evidência fora, porém, deixaria `python3_failed` cobrindo interpretador
|
|
86
|
+
# quebrado, stdlib faltando e bug introduzido neste parser com o mesmo token —
|
|
87
|
+
# e nenhum deles com uma linha de causa em lugar nenhum.
|
|
88
|
+
# Duplicar o fd, não reabrir `/dev/stderr`: quando o stderr do chamador é um
|
|
89
|
+
# arquivo de log, `2>/dev/stderr` o reabre com O_TRUNC e come o que já estava
|
|
90
|
+
# escrito — some justamente a evidência que o knob existe para dar.
|
|
91
|
+
exec 4>&2
|
|
92
|
+
[ -n "${GROUNDFAST_WRAP_DEBUG:-}" ] || exec 4>/dev/null
|
|
93
|
+
line=$(python3 - "$abs" "$host" "$leaf" 2>&4 <<'PY'
|
|
94
|
+
import errno, json, os, stat, sys
|
|
95
|
+
|
|
96
|
+
path, host, leaf_arg = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def tok(value):
|
|
100
|
+
"""Token que veio do transcript. O transcript é dado, não formato: um id
|
|
101
|
+
com espaço ou quebra de linha injetaria chaves numa linha que o modelo lê
|
|
102
|
+
como verdade, inclusive um segundo `coverage=`."""
|
|
103
|
+
text = "" if value is None else str(value)
|
|
104
|
+
if not text:
|
|
105
|
+
return "-"
|
|
106
|
+
ok = all(c.isalnum() or c in "._:-" for c in text)
|
|
107
|
+
return text if ok and len(text) <= 80 else "invalid"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def out(coverage, reasons, path_len=0, human=0, assistant=0, tool=0, other=0,
|
|
111
|
+
compaction=False, branch_summary=False, leaf="-", session="-"):
|
|
112
|
+
if not isinstance(reasons, str):
|
|
113
|
+
reasons = ",".join(reasons)
|
|
114
|
+
print(
|
|
115
|
+
"coverage={} path_len={} human={} assistant={} tool={} other={} "
|
|
116
|
+
"compaction={} branch_summary={} leaf={} session={} reasons={}".format(
|
|
117
|
+
coverage, path_len, human, assistant, tool, other,
|
|
118
|
+
str(compaction).lower(), str(branch_summary).lower(),
|
|
119
|
+
tok(leaf), tok(session), reasons))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def bail(reason, **k):
|
|
123
|
+
out("unavailable", reason, **k)
|
|
124
|
+
raise SystemExit
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Um descritor só, aberto antes de qualquer teste e validado por `fstat`: com
|
|
128
|
+
# `stat` seguido de `open` quem escreve no diretório troca a entrada entre os
|
|
129
|
+
# dois, e o que passou nos testes não é o que foi lido. `O_NOFOLLOW` recusa o
|
|
130
|
+
# link no próprio open (ELOOP), e `O_NONBLOCK` impede que um FIFO no lugar do
|
|
131
|
+
# transcript pendure o helper — o `fstat` abaixo o recusa como não-regular.
|
|
132
|
+
try:
|
|
133
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
134
|
+
except OSError as e:
|
|
135
|
+
# O motivo do link é o mesmo, o errno não: Linux devolve ELOOP e os BSDs
|
|
136
|
+
# (macOS entre eles) devolvem EMLINK. Só o Linux aqui deixaria o Mac
|
|
137
|
+
# chamar link de `unreadable` e mandar procurar permissão ou disco.
|
|
138
|
+
link = e.errno in (errno.ELOOP, errno.EMLINK)
|
|
139
|
+
bail("symlink_not_followed" if link else "unreadable")
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
st = os.fstat(fd)
|
|
143
|
+
except OSError:
|
|
144
|
+
bail("unreadable")
|
|
145
|
+
if not stat.S_ISREG(st.st_mode):
|
|
146
|
+
bail("not_regular_file")
|
|
147
|
+
if st.st_mode & 0o002:
|
|
148
|
+
bail("world_writable")
|
|
149
|
+
if st.st_size > 32 * 1024 * 1024:
|
|
150
|
+
bail("oversize_bytes")
|
|
151
|
+
|
|
152
|
+
by = {}
|
|
153
|
+
sessions = set()
|
|
154
|
+
session = None
|
|
155
|
+
last = None
|
|
156
|
+
last_prompt_leaf = None
|
|
157
|
+
n = 0
|
|
158
|
+
duplicate_ids = False
|
|
159
|
+
bad_line = False
|
|
160
|
+
|
|
161
|
+
# O mesmo descritor validado acima: reabrir pelo caminho reabriria a janela.
|
|
162
|
+
try:
|
|
163
|
+
fh = os.fdopen(fd, encoding="utf-8", errors="replace")
|
|
164
|
+
except OSError:
|
|
165
|
+
bail("unreadable")
|
|
166
|
+
|
|
167
|
+
with fh:
|
|
168
|
+
for line in fh:
|
|
169
|
+
n += 1
|
|
170
|
+
if n > 100_000:
|
|
171
|
+
bail("oversize_lines")
|
|
172
|
+
line = line.strip()
|
|
173
|
+
if not line:
|
|
174
|
+
continue
|
|
175
|
+
if bad_line:
|
|
176
|
+
# Linha inválida seguida de outra linha: não é cauda em escrita.
|
|
177
|
+
bail("malformed_json")
|
|
178
|
+
try:
|
|
179
|
+
e = json.loads(line)
|
|
180
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
181
|
+
bad_line = True
|
|
182
|
+
continue
|
|
183
|
+
if not isinstance(e, dict):
|
|
184
|
+
# Linha válida como JSON e inútil como registro (`"texto"`, `[1]`):
|
|
185
|
+
# é cauda estranha, não motivo para perder o arquivo inteiro.
|
|
186
|
+
bad_line = True
|
|
187
|
+
continue
|
|
188
|
+
if host == "pi":
|
|
189
|
+
if e.get("type") == "session" and session is None:
|
|
190
|
+
session = e.get("id")
|
|
191
|
+
continue
|
|
192
|
+
i = e.get("id")
|
|
193
|
+
if not i:
|
|
194
|
+
continue
|
|
195
|
+
role = (e.get("message") or {}).get("role")
|
|
196
|
+
tool_name = (e.get("message") or {}).get("toolName")
|
|
197
|
+
kind = "other"
|
|
198
|
+
t = e.get("type")
|
|
199
|
+
if t == "compaction":
|
|
200
|
+
kind = "compaction"
|
|
201
|
+
elif t == "branch_summary":
|
|
202
|
+
kind = "branch"
|
|
203
|
+
elif t == "message":
|
|
204
|
+
if role == "user" or (role == "toolResult" and tool_name == "ask_user"):
|
|
205
|
+
kind = "human"
|
|
206
|
+
elif role == "assistant":
|
|
207
|
+
kind = "assistant"
|
|
208
|
+
elif role in ("toolResult", "bashExecution"):
|
|
209
|
+
kind = "tool"
|
|
210
|
+
parent = e.get("parentId")
|
|
211
|
+
else:
|
|
212
|
+
t = e.get("type")
|
|
213
|
+
if t == "last-prompt":
|
|
214
|
+
last_prompt_leaf = e.get("leafUuid")
|
|
215
|
+
if e.get("sessionId"):
|
|
216
|
+
sessions.add(e["sessionId"])
|
|
217
|
+
continue
|
|
218
|
+
# Antes do guard de uuid: uma linha sem uuid (summary, snapshot)
|
|
219
|
+
# também carrega sessionId e também precisa bater com o arquivo.
|
|
220
|
+
if e.get("sessionId"):
|
|
221
|
+
sessions.add(e["sessionId"])
|
|
222
|
+
i = e.get("uuid")
|
|
223
|
+
if not i:
|
|
224
|
+
continue
|
|
225
|
+
kind = {"user": "human", "assistant": "assistant",
|
|
226
|
+
"attachment": "tool", "progress": "tool"}.get(t, "other")
|
|
227
|
+
parent = e.get("parentUuid")
|
|
228
|
+
if t == "system" and e.get("subtype") == "compact_boundary":
|
|
229
|
+
# O boundary zera `parentUuid` e guarda o elo em
|
|
230
|
+
# `logicalParentUuid`: sem segui-lo, o `path_len` para no
|
|
231
|
+
# boundary e perde o histórico anterior. Só vale elo para
|
|
232
|
+
# trás: às vezes ele aponta uma entrada gravada depois, que
|
|
233
|
+
# desce do próprio boundary, e segui-lo acusaria `cycle`.
|
|
234
|
+
kind = "compaction"
|
|
235
|
+
logical = e.get("logicalParentUuid")
|
|
236
|
+
if isinstance(logical, str) and logical in by:
|
|
237
|
+
parent = parent or logical
|
|
238
|
+
if i in by:
|
|
239
|
+
duplicate_ids = True
|
|
240
|
+
by[i] = {"parent": parent, "kind": kind}
|
|
241
|
+
last = i
|
|
242
|
+
|
|
243
|
+
leaf = leaf_arg
|
|
244
|
+
leaf_inferred = False
|
|
245
|
+
if not leaf:
|
|
246
|
+
# No Claude a folha é declarada pelo host em `last-prompt`; no Pi ela é
|
|
247
|
+
# deduzida da ordem do arquivo, que não é ciente de ramo.
|
|
248
|
+
if host == "pi":
|
|
249
|
+
leaf = last
|
|
250
|
+
leaf_inferred = bool(leaf)
|
|
251
|
+
else:
|
|
252
|
+
leaf = last_prompt_leaf
|
|
253
|
+
|
|
254
|
+
stem = os.path.splitext(os.path.basename(path))[0]
|
|
255
|
+
# Todo sessionId do arquivo precisa bater com o nome dele: dois transcripts
|
|
256
|
+
# concatenados passariam se só o primeiro fosse conferido.
|
|
257
|
+
if host == "claude":
|
|
258
|
+
session = sorted(sessions)[0] if sessions else None
|
|
259
|
+
if not sessions:
|
|
260
|
+
# Arquivo vazio, ou formato do host mudou e o campo saiu. Dizer
|
|
261
|
+
# "divergente" mandaria procurar adulteração onde houve drift.
|
|
262
|
+
bail("no_session_id_in_file", leaf=leaf)
|
|
263
|
+
if len(sessions) > 1 or stem not in sessions:
|
|
264
|
+
bail("session_id_mismatch", leaf=leaf, session=session)
|
|
265
|
+
if not leaf:
|
|
266
|
+
bail("no_explicit_leaf", session=session)
|
|
267
|
+
if leaf not in by:
|
|
268
|
+
bail("leaf_not_in_file", leaf=leaf, session=session)
|
|
269
|
+
|
|
270
|
+
seen = set()
|
|
271
|
+
cur = leaf
|
|
272
|
+
path_len = human = assistant = tool = other = 0
|
|
273
|
+
compaction = branch = False
|
|
274
|
+
coverage = "complete"
|
|
275
|
+
reasons = []
|
|
276
|
+
|
|
277
|
+
while cur:
|
|
278
|
+
if cur in seen:
|
|
279
|
+
coverage = "partial"
|
|
280
|
+
reasons.append("cycle")
|
|
281
|
+
break
|
|
282
|
+
seen.add(cur)
|
|
283
|
+
e = by.get(cur)
|
|
284
|
+
if e is None:
|
|
285
|
+
coverage = "partial"
|
|
286
|
+
reasons.append("missing_parent")
|
|
287
|
+
break
|
|
288
|
+
path_len += 1
|
|
289
|
+
kind = e["kind"]
|
|
290
|
+
if kind == "human":
|
|
291
|
+
human += 1
|
|
292
|
+
elif kind == "assistant":
|
|
293
|
+
assistant += 1
|
|
294
|
+
elif kind == "tool":
|
|
295
|
+
tool += 1
|
|
296
|
+
elif kind == "compaction":
|
|
297
|
+
compaction = True
|
|
298
|
+
elif kind == "branch":
|
|
299
|
+
branch = True
|
|
300
|
+
else:
|
|
301
|
+
other += 1
|
|
302
|
+
cur = e["parent"]
|
|
303
|
+
|
|
304
|
+
if bad_line:
|
|
305
|
+
coverage = "partial"
|
|
306
|
+
reasons.append("truncated_tail")
|
|
307
|
+
if duplicate_ids:
|
|
308
|
+
coverage = "partial"
|
|
309
|
+
reasons.append("duplicate_ids")
|
|
310
|
+
if compaction:
|
|
311
|
+
coverage = "partial"
|
|
312
|
+
reasons.append("compaction_present_history_still_in_file")
|
|
313
|
+
if branch:
|
|
314
|
+
reasons.append("abandoned_branch_summary_on_path")
|
|
315
|
+
if leaf_inferred:
|
|
316
|
+
reasons.append("leaf_inferred_from_file_order")
|
|
317
|
+
if branch:
|
|
318
|
+
coverage = "partial"
|
|
319
|
+
if path_len < len(by):
|
|
320
|
+
# A folha veio da ordem do arquivo, que não é ciente de ramo: o que
|
|
321
|
+
# ficou fora da cadeia é história real que ninguém mediu.
|
|
322
|
+
coverage = "partial"
|
|
323
|
+
reasons.append("entries_outside_path")
|
|
324
|
+
|
|
325
|
+
out(coverage, reasons, path_len=path_len, human=human, assistant=assistant,
|
|
326
|
+
tool=tool, other=other, compaction=compaction, branch_summary=branch,
|
|
327
|
+
leaf=leaf, session=session)
|
|
328
|
+
PY
|
|
329
|
+
) || line=
|
|
330
|
+
if [ -z "$line" ]; then
|
|
331
|
+
echo "coverage=unavailable $empty reasons=python3_failed"
|
|
332
|
+
exit 0
|
|
333
|
+
fi
|
|
334
|
+
# Um python3 que sai 0 falando outra coisa (wrapper, sitecustomize, aviso de
|
|
335
|
+
# módulo) não é medição: sem a forma do contrato, degrade como qualquer falha.
|
|
336
|
+
line=${line%%$'\n'*}
|
|
337
|
+
case $line in
|
|
338
|
+
'coverage=complete '*|'coverage=partial '*|'coverage=unavailable '*) ;;
|
|
339
|
+
*)
|
|
340
|
+
echo "coverage=unavailable $empty reasons=python3_bad_output"
|
|
341
|
+
exit 0
|
|
342
|
+
;;
|
|
343
|
+
esac
|
|
344
|
+
printf '%s\n' "$line"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Ledger de tasks append-only, last-wins por id.
|
|
3
|
+
#
|
|
4
|
+
# O arquivo é um evento por linha: mudar o estado de uma task é acrescentar uma
|
|
5
|
+
# linha nova com o mesmo id, nunca editar a antiga. A view corrente é a última
|
|
6
|
+
# linha de cada id. Tudo o que precisa de jq mora aqui: manter essas queries no
|
|
7
|
+
# corpo da skill custa contexto em toda invocação e erra sozinho.
|
|
8
|
+
#
|
|
9
|
+
# Uso:
|
|
10
|
+
# tasks.sh next-id [arquivo] próximo id livre (T<n>)
|
|
11
|
+
# tasks.sh current [arquivo] view corrente, uma linha JSON por task
|
|
12
|
+
# tasks.sh open [arquivo] só as abertas, já formatadas para o handoff
|
|
13
|
+
# tasks.sh append [arquivo] lê linhas JSON do stdin, valida e apenda
|
|
14
|
+
set -uo pipefail
|
|
15
|
+
|
|
16
|
+
STATES='open doing blocked done dropped'
|
|
17
|
+
cmd=${1:-}
|
|
18
|
+
file=${2:-tasks.jsonl}
|
|
19
|
+
|
|
20
|
+
die() { echo "erro: $*" >&2; exit 1; }
|
|
21
|
+
|
|
22
|
+
command -v jq >/dev/null 2>&1 || die "jq não encontrado no PATH"
|
|
23
|
+
[ -n "$cmd" ] || die "uso: tasks.sh {next-id|current|open|append} [arquivo]"
|
|
24
|
+
|
|
25
|
+
# next-id e append funcionam com o arquivo ainda inexistente: o primeiro wrap
|
|
26
|
+
# de um repo cria o ledger.
|
|
27
|
+
case "$cmd" in
|
|
28
|
+
next-id|append) [ -e "$file" ] || : >"$file" ;;
|
|
29
|
+
*) [ -e "$file" ] || die "$file não existe" ;;
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
view() { jq -s 'map(select(type == "object" and has("id"))) | group_by(.id) | map(last)' "$file"; }
|
|
33
|
+
|
|
34
|
+
case "$cmd" in
|
|
35
|
+
next-id)
|
|
36
|
+
# Sem o guarda, um ledger com linha corrompida faria o jq falhar em stderr,
|
|
37
|
+
# $n vir vazio e $((n + 1)) devolver T1 — a task nova sobrescreveria a T1
|
|
38
|
+
# existente, porque a view é last-wins por id.
|
|
39
|
+
n=$(jq -rs '[ .[] | .id? | select(type == "string") | ltrimstr("T") | tonumber? ] | max // 0' "$file") \
|
|
40
|
+
|| die "$file não é JSONL válido — rode: jq -c . $file"
|
|
41
|
+
[[ $n =~ ^[0-9]+$ ]] || die "não consegui derivar o maior id de $file (li '$n')"
|
|
42
|
+
echo "T$((n + 1))"
|
|
43
|
+
;;
|
|
44
|
+
|
|
45
|
+
current)
|
|
46
|
+
view | jq -c '.[]'
|
|
47
|
+
;;
|
|
48
|
+
|
|
49
|
+
open)
|
|
50
|
+
view | jq -r '.[] | select(.state != "done" and .state != "dropped")
|
|
51
|
+
| "- \(.id) [\(.state)] \(.title)"'
|
|
52
|
+
;;
|
|
53
|
+
|
|
54
|
+
append)
|
|
55
|
+
tmp=$(mktemp) || die "mktemp falhou"
|
|
56
|
+
trap 'rm -f "$tmp"' EXIT
|
|
57
|
+
cat >"$tmp"
|
|
58
|
+
[ -s "$tmp" ] || die "stdin vazio — nada para apender"
|
|
59
|
+
|
|
60
|
+
# Valida TODAS as linhas antes de escrever uma: um append parcial deixaria
|
|
61
|
+
# o ledger num estado que a próxima sessão não sabe interpretar.
|
|
62
|
+
line=0
|
|
63
|
+
while IFS= read -r l; do
|
|
64
|
+
line=$((line + 1))
|
|
65
|
+
[ -n "${l//[[:space:]]/}" ] || continue
|
|
66
|
+
printf '%s' "$l" | jq -e . >/dev/null 2>&1 || die "linha $line não é JSON válido"
|
|
67
|
+
id=$(printf '%s' "$l" | jq -r '.id // empty')
|
|
68
|
+
st=$(printf '%s' "$l" | jq -r '.state // empty')
|
|
69
|
+
ti=$(printf '%s' "$l" | jq -r '.title // empty')
|
|
70
|
+
up=$(printf '%s' "$l" | jq -r '.updated // empty')
|
|
71
|
+
[[ $id =~ ^T[0-9]+$ ]] || die "linha $line: id '$id' fora do formato T<n>"
|
|
72
|
+
[ -n "$ti" ] || die "linha $line: title vazio"
|
|
73
|
+
[[ $up =~ ^[0-9]{4}(-[0-9]{2}){2}$ ]] || die "linha $line: updated '$up' fora de YYYY-MM-DD"
|
|
74
|
+
case " $STATES " in *" $st "*) ;; *) die "linha $line: state '$st' fora de: $STATES" ;; esac
|
|
75
|
+
done <"$tmp"
|
|
76
|
+
|
|
77
|
+
# Compacta para uma linha por evento — o formato do ledger é JSONL.
|
|
78
|
+
jq -c . <"$tmp" >>"$file" || die "append falhou"
|
|
79
|
+
echo "ok: $line linha(s) apendada(s) em $file"
|
|
80
|
+
;;
|
|
81
|
+
|
|
82
|
+
*) die "subcomando desconhecido: $cmd" ;;
|
|
83
|
+
esac
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Verifica o que o wrap acabou de escrever. Evidência, não vibe.
|
|
3
|
+
#
|
|
4
|
+
# Uso: verify.sh [worktree-principal]
|
|
5
|
+
# Imprime uma linha PASS/FAIL/SKIP por check e sai 1 se houver qualquer FAIL.
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
|
|
8
|
+
root=${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}
|
|
9
|
+
mem_root="$HOME/.claude/projects"
|
|
10
|
+
here=$(cd "$(dirname "$0")" && pwd -P)
|
|
11
|
+
fails=0
|
|
12
|
+
|
|
13
|
+
pass() { echo "PASS $*"; }
|
|
14
|
+
fail() { echo "FAIL $*"; fails=$((fails + 1)); }
|
|
15
|
+
skip() { echo "SKIP $*"; }
|
|
16
|
+
|
|
17
|
+
# A regra do arquivo de instruções vive num lugar só — learn-file.sh — e o
|
|
18
|
+
# repo-state.sh nomeia o alvo pela mesma função. Os checks 4, 4b e 4c usam os
|
|
19
|
+
# predicados dele; sem o script, ou com CLAUDE.md/AGENTS.md ilegível, eles não
|
|
20
|
+
# têm como decidir e dizem isso em vez de fabricar um SKIP. O 4b só precisa do
|
|
21
|
+
# script (conta bullets no que o git guarda), então sobrevive ao arquivo ilegível.
|
|
22
|
+
rule=ausente
|
|
23
|
+
# shellcheck source=./learn-file.sh
|
|
24
|
+
if [ -f "$here/learn-file.sh" ] && [ -r "$here/learn-file.sh" ] && . "$here/learn-file.sh"; then
|
|
25
|
+
if bad=$(learn_unreadable "$root"); then
|
|
26
|
+
rule=ilegivel
|
|
27
|
+
fail "$bad existe em $root mas não é arquivo regular legível — checks 4 e 4c não têm como decidir"
|
|
28
|
+
else
|
|
29
|
+
rule=ok
|
|
30
|
+
fi
|
|
31
|
+
else
|
|
32
|
+
fail "learn-file.sh ausente em $here — checks 4, 4b e 4c não rodam (cópia instalada sem os scripts novos?)"
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# 1 — os três arquivos da tríade existem onde deviam
|
|
36
|
+
missing=
|
|
37
|
+
for f in HANDOFF.md tasks.jsonl memory.md; do
|
|
38
|
+
[ -f "$root/$f" ] || missing="$missing $f"
|
|
39
|
+
done
|
|
40
|
+
[ -z "$missing" ] && pass "tríade presente em $root" || fail "ausente em $root:$missing"
|
|
41
|
+
|
|
42
|
+
# 2 — o ledger é JSONL válido linha a linha
|
|
43
|
+
if [ -f "$root/tasks.jsonl" ]; then
|
|
44
|
+
if jq -c . "$root/tasks.jsonl" >/dev/null 2>&1; then
|
|
45
|
+
pass "tasks.jsonl é JSONL válido ($(wc -l <"$root/tasks.jsonl" | tr -d ' ') eventos)"
|
|
46
|
+
else
|
|
47
|
+
fail "tasks.jsonl tem linha inválida — rode: jq -c . $root/tasks.jsonl"
|
|
48
|
+
fi
|
|
49
|
+
else
|
|
50
|
+
fail "tasks.jsonl não existe"
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# 2b — memory.md é visão corrente: exatamente as três seções, nesta ordem, sem
|
|
54
|
+
# bloco datado apendado (o formato antigo virava 130 linhas lidas em toda sessão).
|
|
55
|
+
if [ -f "$root/memory.md" ]; then
|
|
56
|
+
hdrs=$(grep '^## ' "$root/memory.md")
|
|
57
|
+
expected=$'## Decisões negativas — NÃO reverter sem evidência nova\n## Fonte de verdade\n## Processo que funcionou'
|
|
58
|
+
undated=$(grep -nE '^- ' "$root/memory.md" | grep -vE '\([^()]*[0-9]{4}-[0-9]{2}-[0-9]{2}\)$' | head -3 | cut -d: -f1 | tr '\n' ' ')
|
|
59
|
+
if [ "$hdrs" != "$expected" ]; then
|
|
60
|
+
fail "memory.md fora do formato (3 seções fixas, sem bloco datado) — veja: grep '^## ' $root/memory.md"
|
|
61
|
+
elif [ -n "$undated" ]; then
|
|
62
|
+
fail "memory.md: bullet sem (YYYY-MM-DD) no fim — linhas $undated"
|
|
63
|
+
else
|
|
64
|
+
pass "memory.md no formato de visão corrente (3 seções fixas, bullets datados)"
|
|
65
|
+
fi
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# 3 — a última seção do handoff é a próxima ação, e ela tem conteúdo
|
|
69
|
+
if [ -f "$root/HANDOFF.md" ]; then
|
|
70
|
+
last=$(grep -n '^## ' "$root/HANDOFF.md" | tail -1)
|
|
71
|
+
if printf '%s' "$last" | grep -q 'Próxima ação física'; then
|
|
72
|
+
n=${last%%:*}
|
|
73
|
+
body=$(tail -n "+$((n + 1))" "$root/HANDOFF.md" | tr -d '[:space:]')
|
|
74
|
+
[ -n "$body" ] && pass "HANDOFF.md termina em '## Próxima ação física' preenchida" \
|
|
75
|
+
|| fail "'## Próxima ação física' está vazia"
|
|
76
|
+
else
|
|
77
|
+
fail "última seção do HANDOFF.md não é '## Próxima ação física'"
|
|
78
|
+
fi
|
|
79
|
+
else
|
|
80
|
+
fail "HANDOFF.md não existe"
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
# 4 — teto de 12 bullets por seção de aprendizado. Contamos nos dois arquivos
|
|
84
|
+
# (quem julga onde a seção deve viver é o 4c), fora de code block, como o
|
|
85
|
+
# learn-file.sh.
|
|
86
|
+
if [ "$rule" != ok ]; then
|
|
87
|
+
skip "check 4 não avaliado (ver FAIL acima)"
|
|
88
|
+
elif [ -f "$root/CLAUDE.md" ] || [ -f "$root/AGENTS.md" ]; then
|
|
89
|
+
over=
|
|
90
|
+
achou=
|
|
91
|
+
for lf in CLAUDE.md AGENTS.md; do
|
|
92
|
+
[ -f "$root/$lf" ] || continue
|
|
93
|
+
for sec in 'Learned User Preferences' 'Learned Workspace Facts'; do
|
|
94
|
+
unfenced "$root/$lf" | grep -E "^## $sec[[:space:]]*\$" >/dev/null || continue
|
|
95
|
+
achou=1
|
|
96
|
+
c=$(unfenced "$root/$lf" | awk -v h="^## $sec[[:space:]]*\$" '$0 ~ h {f=1; next} /^## /{f=0} f && /^- /{n++} END{print n+0}')
|
|
97
|
+
[ "$c" -gt 12 ] && over="$over $lf:$sec=$c"
|
|
98
|
+
done
|
|
99
|
+
done
|
|
100
|
+
if [ -z "$achou" ]; then
|
|
101
|
+
skip "nenhuma seção de aprendizado em CLAUDE.md/AGENTS.md — nada a checar"
|
|
102
|
+
else
|
|
103
|
+
[ -z "$over" ] && pass "seções de aprendizado dentro do teto de 12 bullets" \
|
|
104
|
+
|| fail "seção acima do teto:$over"
|
|
105
|
+
fi
|
|
106
|
+
else
|
|
107
|
+
skip "repo sem CLAUDE.md nem AGENTS.md — nada a checar"
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# 4b — o commit de learnings não é balde: no máximo 3 bullets líquidos novos
|
|
111
|
+
# nas seções Learned por commit, somando CLAUDE.md e AGENTS.md — mover a seção
|
|
112
|
+
# de um arquivo para o outro (o que o 4c pede) dá zero líquido, e bullet novo
|
|
113
|
+
# conta onde quer que caia. O bullet de retomada, que o passo 8 garante uma
|
|
114
|
+
# vez, fica fora da conta. Conteúdo não é verificável por script; volume é.
|
|
115
|
+
# Só o HEAD conta: o commit B é o último do ritual, e um B antigo em HEAD~1
|
|
116
|
+
# não é deste wrap.
|
|
117
|
+
count_learned() { # <conteúdo na stdin> — bullets nas duas seções canônicas, fora de code block, sem o de retomada
|
|
118
|
+
unfenced | awk -v h="$LEARNED_HEADING" '$0 ~ h {f=1; next}
|
|
119
|
+
/^## /{f=0} f && /^- / && index($0, "Início de sessão: ler HANDOFF.md") == 0 {n++} END{print n+0}'
|
|
120
|
+
}
|
|
121
|
+
if [ "$rule" = ausente ]; then
|
|
122
|
+
skip "check 4b não avaliado (ver FAIL acima)"
|
|
123
|
+
elif git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
124
|
+
if git -C "$root" log -1 --format=%s 2>/dev/null | grep -q '^chore(wrap): learnings'; then
|
|
125
|
+
net=0
|
|
126
|
+
for lf in CLAUDE.md AGENTS.md; do
|
|
127
|
+
after=$(git -C "$root" show "HEAD:$lf" 2>/dev/null | count_learned)
|
|
128
|
+
before=$(git -C "$root" show "HEAD^:$lf" 2>/dev/null | count_learned)
|
|
129
|
+
net=$((net + after - before))
|
|
130
|
+
done
|
|
131
|
+
[ "$net" -le 3 ] && pass "commit de learnings adicionou $net bullet(s) líquido(s) nas seções Learned (teto 3)" \
|
|
132
|
+
|| fail "commit de learnings adicionou $net bullets nas seções Learned (teto 3) — sinal de balde"
|
|
133
|
+
else
|
|
134
|
+
skip "HEAD não é commit de learnings — nada a checar"
|
|
135
|
+
fi
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
# 4c — Learned num arquivo só: o alvo que learn-file.sh nomeia. Seção Learned
|
|
139
|
+
# no outro arquivo é o drift da issue #12 — o Pi lê AGENTS.md antes de
|
|
140
|
+
# CLAUDE.md e o Codex só AGENTS.md, então o que fica no CLAUDE.md de uma linha
|
|
141
|
+
# nunca chega a eles.
|
|
142
|
+
if [ "$rule" != ok ]; then
|
|
143
|
+
skip "check 4c não avaliado (ver FAIL acima)"
|
|
144
|
+
elif [ ! -f "$root/CLAUDE.md" ] && [ ! -f "$root/AGENTS.md" ]; then
|
|
145
|
+
skip "repo sem CLAUDE.md nem AGENTS.md — o alvo seria $(learn_target "$root")"
|
|
146
|
+
else
|
|
147
|
+
alvo=$(learn_target "$root")
|
|
148
|
+
case "$alvo" in CLAUDE.md) outro=AGENTS.md ;; *) outro=CLAUDE.md ;; esac
|
|
149
|
+
if learned "$root/$outro"; then
|
|
150
|
+
fail "seção Learned em $outro — o alvo é $alvo, mova para lá"
|
|
151
|
+
elif learned "$root/$alvo"; then
|
|
152
|
+
pass "seções Learned num arquivo só ($alvo)"
|
|
153
|
+
else
|
|
154
|
+
skip "nenhuma seção Learned em CLAUDE.md/AGENTS.md — nada a checar (alvo: $alvo)"
|
|
155
|
+
fi
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
# 5 — a memória nativa tem ponteiro para este projeto.
|
|
159
|
+
# O diretório de memória é nomeado pelo cwd da sessão, não pelo repo, então o
|
|
160
|
+
# slug não é derivável daqui: procuramos o caminho do projeto em todos eles.
|
|
161
|
+
# No CI o runner é efêmero e não tem memória nativa, e o check mede o ritual
|
|
162
|
+
# local. Variável própria, não `CI`: uma sessão local com `CI` exportado não pula.
|
|
163
|
+
if [ "${GROUNDFAST_CI-}" = 1 ]; then
|
|
164
|
+
skip "memória nativa não se aplica no CI (GROUNDFAST_CI=1)"
|
|
165
|
+
elif [ -d "$mem_root" ] && grep -rqlF -- "$root" "$mem_root"/*/memory 2>/dev/null; then
|
|
166
|
+
pass "memória nativa tem ponteiro para $root"
|
|
167
|
+
else
|
|
168
|
+
fail "nenhum arquivo em $mem_root/*/memory menciona $root"
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
# 6 e 7 — o commit pegou a tríade, e só ela
|
|
172
|
+
if git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
173
|
+
# O wrap pula o commit de propósito durante rebase/merge/cherry-pick. Julgar o
|
|
174
|
+
# HEAD nesse caso reprovaria um commit alheio e empurraria o modelo a commitar
|
|
175
|
+
# no meio da operação — o oposto do que a skill manda.
|
|
176
|
+
subject=$(git -C "$root" log -1 --format=%s 2>/dev/null)
|
|
177
|
+
case "$subject" in
|
|
178
|
+
"chore(wrap): "*)
|
|
179
|
+
dirty=$(git -C "$root" status --porcelain -- HANDOFF.md tasks.jsonl memory.md 2>/dev/null)
|
|
180
|
+
[ -z "$dirty" ] && pass "tríade commitada (git status limpo nos três paths)" \
|
|
181
|
+
|| fail "tríade não commitada: $(printf '%s' "$dirty" | tr '\n' ' ')"
|
|
182
|
+
|
|
183
|
+
files=$(git -C "$root" show --pretty=format: --name-only HEAD 2>/dev/null | grep -v '^$')
|
|
184
|
+
# AGENTS.md entra porque é onde o commit B grava os aprendizados no repo
|
|
185
|
+
# cujo CLAUDE.md só faz `@AGENTS.md` — sem ele o passo 8 reprova sozinho.
|
|
186
|
+
# docs/ inteiro, README, CHANGELOG e CONTEXT entram porque o passo 2 manda
|
|
187
|
+
# atualizar as docs vivas que o trabalho tocou, e o commit A as leva. O que
|
|
188
|
+
# a regra continua rejeitando é o que importa: path de código no commit do
|
|
189
|
+
# wrap significa trabalho não commitado indo junto por engano.
|
|
190
|
+
unexpected=$(printf '%s\n' "$files" | grep -vE '^(HANDOFF\.md|tasks\.jsonl|memory\.md|CLAUDE\.md|AGENTS\.md|README\.md|CHANGELOG\.md|CONTEXT\.md|docs/)' || true)
|
|
191
|
+
[ -z "$unexpected" ] && pass "commit do wrap toca só os paths dele" \
|
|
192
|
+
|| fail "commit do wrap tem paths inesperados: $(printf '%s' "$unexpected" | tr '\n' ' ')"
|
|
193
|
+
;;
|
|
194
|
+
*)
|
|
195
|
+
skip "HEAD não é um commit do wrap — commit pulado (operação em curso?) ou ainda não feito"
|
|
196
|
+
;;
|
|
197
|
+
esac
|
|
198
|
+
else
|
|
199
|
+
skip "fora de repositório git — checks de commit não se aplicam"
|
|
200
|
+
fi
|
|
201
|
+
|
|
202
|
+
echo
|
|
203
|
+
[ "$fails" -eq 0 ] && echo "resultado: tudo verde" || echo "resultado: $fails FAIL"
|
|
204
|
+
exit $((fails > 0))
|