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.
Files changed (80) hide show
  1. package/README.md +67 -0
  2. package/extensions/index.ts +415 -0
  3. package/package.json +53 -0
  4. package/references/coverage.md +89 -0
  5. package/references/lenses.md +43 -0
  6. package/references/pr-goal.md +58 -0
  7. package/references/query-safety.md +50 -0
  8. package/scripts/cov-marker.sh +53 -0
  9. package/scripts/cr-comment.sh +159 -0
  10. package/scripts/cr-status.sh +161 -0
  11. package/scripts/origin-guard.sh +52 -0
  12. package/scripts/redact.sh +24 -0
  13. package/scripts/strip-shim.sh +10 -0
  14. package/skills/ask-groundfast/SKILL.md +64 -0
  15. package/skills/babysit/SKILL.md +134 -0
  16. package/skills/babysit/references/coderabbit.md +58 -0
  17. package/skills/babysit/references/loop.md +133 -0
  18. package/skills/babysit/scripts/checkout-pr.sh +46 -0
  19. package/skills/babysit/scripts/ci-cause.sh +12 -0
  20. package/skills/babysit/scripts/cov-comment.sh +115 -0
  21. package/skills/babysit/scripts/cycle.sh +226 -0
  22. package/skills/babysit/scripts/gh-thread.sh +164 -0
  23. package/skills/babysit/scripts/pr-goal.sh +226 -0
  24. package/skills/babysit/scripts/pr-state.sh +120 -0
  25. package/skills/babysit/scripts/push-pr.sh +43 -0
  26. package/skills/babysit/scripts/stage.sh +34 -0
  27. package/skills/babysit/scripts/wait-ci.sh +50 -0
  28. package/skills/babysit/scripts/wait-review.sh +203 -0
  29. package/skills/drain/SKILL.md +151 -0
  30. package/skills/drain/references/inner-loop.md +52 -0
  31. package/skills/drain/references/ordering.md +22 -0
  32. package/skills/drain/references/threads.md +32 -0
  33. package/skills/drain/scripts/babysit-cmd.sh +23 -0
  34. package/skills/drain/scripts/commit.sh +15 -0
  35. package/skills/drain/scripts/conflict-finish.sh +37 -0
  36. package/skills/drain/scripts/drain-queue.sh +74 -0
  37. package/skills/drain/scripts/integrate-base.sh +42 -0
  38. package/skills/drain/scripts/merge-pr.sh +98 -0
  39. package/skills/drain/scripts/order-queue.sh +203 -0
  40. package/skills/drain/scripts/review-diff.sh +46 -0
  41. package/skills/pipeline/SKILL.md +60 -0
  42. package/skills/pipeline/references/issue-contract.md +51 -0
  43. package/skills/pipeline/references/issue-loop.md +128 -0
  44. package/skills/pipeline/references/review-gate.md +17 -0
  45. package/skills/pipeline/scripts/claim-issue.sh +143 -0
  46. package/skills/pipeline/scripts/create-pr.sh +44 -0
  47. package/skills/pipeline/scripts/issue-context.sh +43 -0
  48. package/skills/pipeline/scripts/issue-note.sh +30 -0
  49. package/skills/pipeline/scripts/issue-queue.sh +115 -0
  50. package/skills/pipeline/scripts/pipeline-cmd.sh +53 -0
  51. package/skills/pipeline/scripts/prepare-issue.sh +87 -0
  52. package/skills/pipeline/scripts/repo-context.sh +17 -0
  53. package/skills/pr/SKILL.md +114 -0
  54. package/skills/pr/references/review-fanout.md +44 -0
  55. package/skills/pr/scripts/pre-pr-state.sh +102 -0
  56. package/skills/pr/scripts/push-branch.sh +17 -0
  57. package/skills/scaffolding-services/SKILL.md +52 -0
  58. package/skills/scaffolding-services/references/bun.md +69 -0
  59. package/skills/scaffolding-services/references/rust.md +52 -0
  60. package/skills/scaffolding-services/scripts/detect-stack.sh +13 -0
  61. package/skills/scoping-engagement/SKILL.md +77 -0
  62. package/skills/scoping-engagement/references/discovery-questions.md +62 -0
  63. package/skills/ship/SKILL.md +80 -0
  64. package/skills/ship/references/cloudflare.md +10 -0
  65. package/skills/ship/references/n8n.md +9 -0
  66. package/skills/ship/references/plugin.md +11 -0
  67. package/skills/ship/references/railway.md +10 -0
  68. package/skills/ship/references/vps.md +7 -0
  69. package/skills/ship/scripts/repo-state.sh +30 -0
  70. package/skills/tidy/SKILL.md +40 -0
  71. package/skills/tidy/scripts/orphans.sh +90 -0
  72. package/skills/wrap/SKILL.md +143 -0
  73. package/skills/wrap/references/formats.md +85 -0
  74. package/skills/wrap/references/selection.md +25 -0
  75. package/skills/wrap/references/session-coverage.md +49 -0
  76. package/skills/wrap/scripts/learn-file.sh +134 -0
  77. package/skills/wrap/scripts/repo-state.sh +110 -0
  78. package/skills/wrap/scripts/session-cover.sh +344 -0
  79. package/skills/wrap/scripts/tasks.sh +83 -0
  80. 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))