This repository was archived by the owner on Oct 8, 2026. It is now read-only.
Repository navigation
Expand file tree
/
Copy pathcodegen-log
More file actions
executable file
·1563 lines (1492 loc) · 66.1 KB
/
Copy pathcodegen-log
File metadata and controls
executable file
·1563 lines (1492 loc) · 66.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# codegen-log — canonical cycle-log writer.
#
# Usage:
# codegen-log init --slug <slug> [--stamp <YYYYMMDD_HHMMSS>]
# codegen-log section [<role>] [--body @-] [--role <role>] [--slug <slug>]
# codegen-log section [<role>] [--body @-] --learned "<text>" [--slug <slug>]
# codegen-log append <role> [--body @-] [--slug <slug>]
# codegen-log append <role> --learned "<text>" [--slug <slug>]
# codegen-log append <role> --no-learning "<text>" [--slug <slug>]
# codegen-log append <role> --died interrupted|aborted [--cause "<text>"] [--slug <slug>]
# codegen-log append <role> --verdict clear|failed|inconclusive [--slug <slug>]
# codegen-log append <role> --files-to-touch @<json-file-or-@-> [--slug <slug>]
# codegen-log append <role> --files-modified @<json-file-or-@-> [--slug <slug>]
# codegen-log append <role> --waiver <hook-id> --waiver-slug <slug> [--slug <slug>]
# codegen-log verdict --gate <cmd> --mode <mode> --result "<text>" [--detail "<text>"] [--slug <slug>]
# codegen-log relocate --new-slug <slug> [--slug <slug>]
# codegen-log exit --status <n> [--signal <n>] [--stderr-tail "<text>"] [--slug <slug>]
# codegen-log show [<slug>] [--format table|md|html] [--role <role>] [--full] [--slug <slug>]
# codegen-log --version
# codegen-log --kinds
#
# Cycle logs are machine-local, gitignored working artifacts (see
# session-log.md § Ownership) and MUST NEVER be carried through any git
# branch, ref, note, or stash — codegen-log has no publish/sync/export
# subcommand and never will; see session-log.md for the full prohibition.
#
# show: READ-ONLY operator projection of one cycle log. Writes NOTHING — not
# the log, not .active, not a rendered file on disk. Resolves the log via the
# SAME precedence as section/append (CODEGEN_LOG_PATH > --slug/positional >
# .active > mtime). Builds a per-role spine, preferring (in order) the
# sibling <log-dir>/<ts>_<slug>/cycle-summary.jsonl (role/seq/num_turns/
# cost_usd/status/transcript), then the ev:role events themselves — each
# fallback prints ONE stderr note naming the spine used. Renders to STDOUT
# ONLY in one of three formats (--format table, default; md; html — html
# escapes every interpolated value via jq's @html filter). --role <role>
# drills into that role's body/learning/transcript path; --full prints every
# role's body in seq order. Derives a fixed, deterministic anomaly list
# (summary status != success, any ev:died, an invoked role with zero ev:role
# body, a role with neither ev:learned nor ev:no_learning, a trailing gate
# verdict != clear) — "no anomalies" prints explicitly when the list is
# empty. An event kind not declared in EVENT_SCHEMA_JQ is never dropped: it
# is promoted into the anomaly list itself ("unknown event kind: <kind>
# (xN) — not in the schema"), so a rogue write can never render as clean.
# Exits 2 (fail loud, no partial render) on: bad --format, --role naming a
# role absent from the resolved log, or no cycle log found at all.
#
# --kinds: read-only. Prints every event kind codegen-log can emit (the
# EVENT_SCHEMA_JQ table below), one per line, sorted. Exits 0. Used by
# cycle-log-schema-parity_test.sh to assert session-log.md's prose
# enumeration matches the table byte-for-byte.
#
# --version: cheap read-only presence/preflight probe. Prints
# "codegen-log root=<resolved|unresolved-root>" to stdout and exits 0.
# Always exits 0 when the binary itself runs — an unresolved CODEGEN_ROOT
# only degrades the printed status, it never fails this probe (root
# resolution failure is a version_stamp metadata concern, not a binary
# presence concern). Callers that need root resolution to be authoritative
# should grep the printed status, not just the exit code.
#
# Positional <role> (taught/default form) and --role/--body @- (accepted
# aliases, kept for existing callers) resolve to the same behavior. When
# --body is omitted, stdin is read implicitly (same as --body @-).
#
# On-disk storage: codegen/logging/<ts>_<slug>_cycle.jsonl — append-only
# JSONL, one JSON object per line, NEVER rewritten/ranked. Every event object
# carries an "ev" discriminator field. The canonical list of kinds and their
# required/optional fields lives in EVENT_SCHEMA_JQ (below, search
# "def event_schema") — the SINGLE source both write paths
# (append_event_line, and init's own truncating write) validate against
# before a line ever reaches disk, and known_kinds/`--kinds` both derive
# from. A quick reference (kind: notable fields):
# init — pitch, path, stamp{project,context,codegen,claude,at}
# role — role, body (prose). Re-running section/append for the
# same role APPENDS a new event; never overwrites.
# learned — role, text
# no_learning — role, text
# died — role, kind (interrupted|aborted), cause
# gate — role, verdict (clear|failed|inconclusive); optionally
# carries gate/mode/result/detail (the `verdict`
# subcommand's richer shape) — both shapes are legal.
# files_to_touch — role, files[<relpath>,...]. The loop's typed list.
# files_modified — role, files[<relpath>,...]. Developer's typed list.
# exit — status, signal (n|null), stderr_tail. dispatch.sh's
# record of the loop CHILD PROCESS's raw wait status —
# no "role" field, a process exit has no role.
# waiver — role, hook, slug. Written by harnesses/claude/hooks/
# _waiver.sh at the moment a
# registry-opted-in guard is granted a waiver for a
# promoted pitch's declared `waives:` list — see
# session-log.md § A guard is waived only where a pitch
# declared it.
#
# Substance filter (--learned / --no-learning / section+append role BODY):
# codegen-log is the SOLE writer of the cycle log, so it is the one place
# that can refuse a lie before it becomes permanent (append-only storage —
# nothing can retract a bad line once written). A --learned text, a
# --no-learning text, or a section/append role BODY is REFUSED (exit 2,
# writes nothing) when the ENTIRE trimmed text is a placeholder token
# ("test body", "placeholder", "dummy", "lorem ipsum", "test learning", ...)
# OR contains a compliance-echo phrase — text that describes the check
# rather than the work ("long enough", "at least forty/40 characters",
# "passes the ... bar", "for testing", "non-triviality bar"). The match is
# WHOLE-TEXT/substring-of-known-family, never a length floor: a real slug
# body like "placeholder-project-context-fix" survives because it is not,
# in its entirety, one of the deny-listed phrases.
#
# --no-learning "<text>": the legal, countable way to report a turn that
# produced no transferable learning. Appends {"ev":"no_learning",...} in
# place of {"ev":"learned",...}. Subject to the SAME substance filter as
# --learned (the honest exit is not a cheaper one) — the text must say what
# the turn did instead (e.g. "refused: handoff named no files, reviewed
# zero code"), not restate the absence of a learning.
#
# Routing tag (--learned ONLY, not --no-learning): the text MUST start with
# a leading "[local]" or "[shared]" tag (case-sensitive) — [local] = this
# project (paths, modules, conventions), [shared] = any project on this
# stack (idioms, patterns). Missing tag -> exit 2, writes nothing. This is
# a well-formedness check distinct from the substance filter above; both
# must pass. --no-learning is exempt — it routes nowhere, since the
# curator reads ev:learned only.
#
# init: create a new codegen/logging/<ts>_<slug>_cycle.jsonl containing only
# the initial "init" event line. Binds by RUN IDENTITY (the <ts>
# stamp), never by slug alone: a slug's log path is composed directly
# from --stamp (or, when omitted, `date -u +%Y%m%d_%H%M%S`) — there is
# no glob-by-slug lookup. A retry of the same pitch mints its OWN log
# because it carries its own fresh stamp; two runs of the same slug
# never collide. init IS idempotent at the exact-path level: re-running
# init with the SAME --slug AND --stamp (naming a path that already
# exists) adopts it — prints that log's path, rewrites .active, exits 0
# WITHOUT creating a second log or a duplicate init event. Passing the
# loop's own already-minted stamp (mix codegen.loop's `stamp`, which
# also names the run's cycle_id / transcript dir) is what makes this
# exact-match adoption meaningful rather than accidental.
# section: append ONE "role" event line with the given role + full body.
# --role overrides AGENT_TYPE/CLAUDE_ROLE-derived role when present.
# A second `section` call for the same role APPENDS another "role"
# event line — it does NOT replace the earlier one (append-only
# storage; there is nothing to overwrite).
# append: append ONE more event line for an EXISTING role (plain body ->
# another "role" event; marker flags -> "learned"/"died"/"gate"
# events). JSONL append is unconditional — there is no "section must
# exist first" precondition (that constraint only existed to protect
# markdown section-insertion order, which JSONL has no concept of).
# exit: append ONE "exit" event line — NO role (a process exit has no role).
# Resolution mirrors section/append (CODEGEN_LOG_PATH > --slug >
# .active > mtime) EXCEPT: when NONE of those resolve to an existing
# log, `exit` does not fail loud — it prints one stderr note and exits
# 0 without writing (the loop may have died before ever init'ing a
# log; there is nothing to pin the record to, and that absence is
# itself diagnostic — see dispatch.sh). --status is required
# (integer, the raw `wait` exit status); --signal (integer) and
# --stderr-tail (text) are optional.
#
# Marker flags — emit a structured event. Mutually exclusive with each other.
# --learned "<text>" -> {"ev":"learned",...}. Valid on BOTH
# `section` (additive: emits the "role"
# event from --body PLUS a "learned" event
# in one call — never refuses the write)
# and `append` (mutually exclusive with
# --body there; reads no stdin).
# --died interrupted [--cause] -> {"ev":"died","kind":"interrupted",...} (append only)
# --died aborted -> {"ev":"died","kind":"aborted",...} (append only)
# --verdict clear|failed|inconclusive -> {"ev":"gate","verdict":<v>,...} (append only)
# --files-to-touch @<file-or-@-> -> {"ev":"files_to_touch",...} (append only).
# Value is a JSON ARRAY of relative path
# strings, read the same way. The loop's
# typed files-to-touch list.
# --files-modified @<file-or-@-> -> {"ev":"files_modified",...} (append only).
# Same shape as --files-to-touch. Developer's
# typed files-modified list.
# --waiver <hook-id> --waiver-slug <slug> -> {"ev":"waiver",...} (append
# only). Both required together. Written
# by _waiver.sh/waiver.ts at grant time —
# never hand-called by a role.
#
# Target-log resolution (section/append), in precedence order:
# 1. CODEGEN_LOG_PATH env var, if set.
# 2. --slug <slug>, if given — resolves to the single log on disk matching
# *_<slug>_cycle.jsonl; zero or multiple matches exit 2.
# 3. codegen/logging/.active sentinel (written by init/relocate), if it
# points at a log that still exists on disk.
# 4. Otherwise, the most recently modified *_cycle.jsonl (mtime).
#
# relocate: rewrite the .active sentinel to point at a RENAMED log file
# (rename in place, no rm+init). Resolves the CURRENT log via the same
# precedence as section/append (CODEGEN_LOG_PATH > --slug > .active > mtime),
# renames it to carry --new-slug, and updates .active to the new path.
#
# verdict: dedicated writer for the Elixir loop's LoopGate deterministic gate
# verdict. Every call APPENDS a brand-new "gate" event line (role="dev-gate")
# — never replaces a prior one, matching one-event-per-gate-run semantics (a
# cycle log commonly carries more than one dev-gate verdict across retries).
# The verdict field is derived from --result text: contains "ALL CLEAR" ->
# clear, "FAILED" -> failed, "INCONCLUSIVE" -> inconclusive; the raw --result
# and --detail text are preserved alongside the classified verdict.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# Root resolution: prefer a sibling checkout next to this script (self-build
# / repo-relative invocation). When installed as a stable copy (no sibling
# codegen/ dir alongside it — e.g. ~/.local/bin/codegen-log), fall back to
# CODEGEN_DIR (exported by the Elixir orchestration loop) OR OCG_CODEGEN_DIR
# (exported by dispatch.sh/ocg), in that order. Either resolves; CODEGEN_DIR
# takes precedence when both are set. If NEITHER resolves a valid codegen
# root, leave CODEGEN_ROOT empty — this is NOT a hard failure: log writing
# uses LOG_ROOT (cwd), which is unaffected. version_stamp() detects the empty
# root and emits an honest "unresolved-root" marker instead of running git
# blind.
CODEGEN_ROOT="$SCRIPT_DIR"
if [[ ! -d "$SCRIPT_DIR/codegen" ]]; then
if [[ -n "${CODEGEN_DIR:-}" && -d "$CODEGEN_DIR/codegen" ]]; then
CODEGEN_ROOT="$CODEGEN_DIR"
elif [[ -n "${OCG_CODEGEN_DIR:-}" && -d "$OCG_CODEGEN_DIR/codegen" ]]; then
CODEGEN_ROOT="$OCG_CODEGEN_DIR"
else
CODEGEN_ROOT=""
fi
fi
LOG_ROOT="${CODEGEN_BUILD_CWD:-${CLAUDE_PROJECT_DIR:-$PWD}}"
SUBCOMMAND=""
SLUG=""
SLUG_SET=""
STAMP=""
BODY_SOURCE=""
ROLE_OVERRIDE=""
ROLE_POSITIONAL_SEEN=""
LEARNED_TEXT=""
NO_LEARNING_TEXT=""
DIED_KIND=""
DIED_CAUSE=""
VERDICT_KIND=""
FILES_TO_TOUCH_SOURCE=""
FILES_MODIFIED_SOURCE=""
WAIVER_HOOK=""
WAIVER_SLUG=""
NEW_SLUG=""
GATE_CMD=""
GATE_MODE=""
RESULT_TEXT=""
DETAIL_TEXT=""
SHOW_FORMAT="table"
SHOW_FULL=""
EXIT_STATUS=""
EXIT_SIGNAL=""
EXIT_STDERR_TAIL=""
COMMIT_SHA=""
COMMIT_SUBJECT=""
# EVENT_SCHEMA_JQ — the single source of truth for every event kind
# codegen-log emits. One jq `def` returning {kind: {required: [...],
# optional: [...]}} for all 13 kinds this binary actually writes (see the
# emit sites feeding append_event_line/init's own write, below). "ev" itself
# is implicit — every kind requires it — so it is not listed per-kind.
# known_kinds (show_build_model) and `--kinds` both derive from this table;
# neither hand-copies the kind list anymore. Defined here (before usage()/
# the --version and --kinds pre-dispatch loops) so `--kinds` can use it
# without a subcommand.
EVENT_SCHEMA_JQ='
def event_schema:
{
init: {required: ["pitch","path","stamp"], optional: []},
role: {required: ["role","body"], optional: []},
learned: {required: ["role","text"], optional: []},
no_learning: {required: ["role","text"], optional: []},
died: {required: ["role","kind","cause"], optional: []},
gate: {required: ["role","verdict"], optional: ["gate","mode","result","detail"]},
files_to_touch: {required: ["role","files"], optional: []},
files_modified: {required: ["role","files"], optional: []},
exit: {required: ["status","signal","stderr_tail"], optional: []},
committed: {required: ["role","sha","subject"], optional: []},
waiver: {required: ["role","hook","slug"], optional: []}
};
'
usage() {
printf 'Usage:\n' >&2
printf ' codegen-log init --slug <slug> [--stamp <YYYYMMDD_HHMMSS>]\n' >&2
printf ' printf "%%s" "$body" | codegen-log section <role> [--slug <slug>] # taught form: role positional, body via stdin\n' >&2
printf ' codegen-log section <role> --body "<text>" [--slug <slug>] # literal text also accepted\n' >&2
printf ' printf "%%s" "$body" | codegen-log section <role> --learned "<text>" [--slug <slug>]\n' >&2
printf ' codegen-log section --body @- [--role <role>] [--slug <slug>] # alias form\n' >&2
printf ' codegen-log append <role> --body "<text>"|@- [--slug <slug>]\n' >&2
printf ' codegen-log append <role> --learned "<text>" [--slug <slug>]\n' >&2
printf ' codegen-log append <role> --no-learning "<text>" [--slug <slug>]\n' >&2
printf ' codegen-log append <role> --died interrupted|aborted [--cause "<text>"] [--slug <slug>]\n' >&2
printf ' codegen-log append <role> --verdict clear|failed|inconclusive [--slug <slug>]\n' >&2
printf ' codegen-log append <role> --files-to-touch @<file-or-@-> [--slug <slug>]\n' >&2
printf ' codegen-log append <role> --files-modified @<file-or-@-> [--slug <slug>]\n' >&2
printf ' codegen-log append <role> --waiver <hook-id> --waiver-slug <slug> [--slug <slug>]\n' >&2
printf ' codegen-log verdict --gate <cmd> --mode <mode> --result "<text>" [--detail "<text>"] [--slug <slug>]\n' >&2
printf ' codegen-log relocate --new-slug <slug> [--slug <slug>]\n' >&2
printf ' codegen-log exit --status <n> [--signal <n>] [--stderr-tail "<text>"] [--slug <slug>]\n' >&2
printf ' codegen-log committed --role <role> --sha <sha> --subject "<text>" [--slug <slug>]\n' >&2
printf ' codegen-log show [<slug>] [--format table|md|html] [--role <role>] [--full] [--slug <slug>]\n' >&2
printf ' codegen-log --version\n' >&2
printf ' codegen-log --kinds\n' >&2
printf '\n' >&2
printf "section and append both APPEND a new event line to the JSONL cycle log — there is no overwrite/replace semantics.\n" >&2
exit 2
}
# --version: handled BEFORE subcommand dispatch — it IS the action, requires
# no subcommand. Cheap, read-only, always exits 0 (proves the binary is
# present + runnable; root-resolution status is reported, not enforced).
for _arg in "$@"; do
if [[ "$_arg" == "--version" ]]; then
if [[ -n "$CODEGEN_ROOT" ]]; then
printf 'codegen-log root=resolved\n'
else
printf 'codegen-log root=unresolved-root\n'
fi
exit 0
fi
done
# --kinds: same shape as --version — handled BEFORE subcommand dispatch,
# requires no subcommand. Read-only: prints every event kind EVENT_SCHEMA_JQ
# declares, one per line, sorted, and exits 0.
for _arg in "$@"; do
if [[ "$_arg" == "--kinds" ]]; then
jq -r "$EVENT_SCHEMA_JQ"'event_schema | keys[]' <<<'null' | sort
exit 0
fi
done
while [[ $# -gt 0 ]]; do
case "$1" in
init | section | append | relocate | verdict | exit | show | committed)
if [[ -n "$SUBCOMMAND" ]]; then
printf 'codegen-log: only one subcommand is allowed\n' >&2
usage
fi
SUBCOMMAND="$1"
shift
;;
--slug=*)
SLUG="${1#--slug=}"
SLUG_SET=1
shift
;;
--slug)
SLUG="$2"
SLUG_SET=1
shift 2
;;
--stamp=*)
STAMP="${1#--stamp=}"
shift
;;
--stamp)
STAMP="$2"
shift 2
;;
--body=*)
BODY_SOURCE="${1#--body=}"
shift
;;
--body)
BODY_SOURCE="$2"
shift 2
;;
--role=*)
ROLE_OVERRIDE="${1#--role=}"
shift
;;
--role)
ROLE_OVERRIDE="$2"
shift 2
;;
--learned=*)
LEARNED_TEXT="${1#--learned=}"
shift
;;
--learned)
LEARNED_TEXT="$2"
shift 2
;;
--no-learning=*)
NO_LEARNING_TEXT="${1#--no-learning=}"
shift
;;
--no-learning)
NO_LEARNING_TEXT="$2"
shift 2
;;
--died=*)
DIED_KIND="${1#--died=}"
shift
;;
--died)
DIED_KIND="$2"
shift 2
;;
--cause=*)
DIED_CAUSE="${1#--cause=}"
shift
;;
--cause)
DIED_CAUSE="$2"
shift 2
;;
--verdict=*)
VERDICT_KIND="${1#--verdict=}"
shift
;;
--verdict)
VERDICT_KIND="$2"
shift 2
;;
--files-to-touch=*)
FILES_TO_TOUCH_SOURCE="${1#--files-to-touch=}"
shift
;;
--files-to-touch)
FILES_TO_TOUCH_SOURCE="$2"
shift 2
;;
--files-modified=*)
FILES_MODIFIED_SOURCE="${1#--files-modified=}"
shift
;;
--files-modified)
FILES_MODIFIED_SOURCE="$2"
shift 2
;;
--waiver=*)
WAIVER_HOOK="${1#--waiver=}"
shift
;;
--waiver)
WAIVER_HOOK="$2"
shift 2
;;
--waiver-slug=*)
WAIVER_SLUG="${1#--waiver-slug=}"
shift
;;
--waiver-slug)
WAIVER_SLUG="$2"
shift 2
;;
--new-slug=*)
NEW_SLUG="${1#--new-slug=}"
shift
;;
--new-slug)
NEW_SLUG="$2"
shift 2
;;
--gate=*)
GATE_CMD="${1#--gate=}"
shift
;;
--gate)
GATE_CMD="$2"
shift 2
;;
--mode=*)
GATE_MODE="${1#--mode=}"
shift
;;
--mode)
GATE_MODE="$2"
shift 2
;;
--result=*)
RESULT_TEXT="${1#--result=}"
shift
;;
--result)
RESULT_TEXT="$2"
shift 2
;;
--detail=*)
DETAIL_TEXT="${1#--detail=}"
shift
;;
--detail)
DETAIL_TEXT="$2"
shift 2
;;
--status=*)
EXIT_STATUS="${1#--status=}"
shift
;;
--status)
EXIT_STATUS="$2"
shift 2
;;
--signal=*)
EXIT_SIGNAL="${1#--signal=}"
shift
;;
--signal)
EXIT_SIGNAL="$2"
shift 2
;;
--stderr-tail=*)
EXIT_STDERR_TAIL="${1#--stderr-tail=}"
shift
;;
--stderr-tail)
EXIT_STDERR_TAIL="$2"
shift 2
;;
--sha=*)
COMMIT_SHA="${1#--sha=}"
shift
;;
--sha)
COMMIT_SHA="$2"
shift 2
;;
--subject=*)
COMMIT_SUBJECT="${1#--subject=}"
shift
;;
--subject)
COMMIT_SUBJECT="$2"
shift 2
;;
--format=*)
SHOW_FORMAT="${1#--format=}"
shift
;;
--format)
SHOW_FORMAT="$2"
shift 2
;;
--full)
SHOW_FULL=1
shift
;;
--help | -h)
usage
;;
--*)
printf 'codegen-log: unknown flag %q\n' "$1" >&2
usage
;;
*)
# Positional role: a bare token (not starting with -) immediately
# following section/append is accepted as the role, ALIASING
# --role. Only one positional role is accepted; a second bare
# token is an error (relocate's NEW_SLUG uses --slug, never a
# second positional).
if [[ ("$SUBCOMMAND" == "section" || "$SUBCOMMAND" == "append") && -z "$ROLE_OVERRIDE" && -z "$ROLE_POSITIONAL_SEEN" ]]; then
ROLE_OVERRIDE="$1"
ROLE_POSITIONAL_SEEN=1
shift
elif [[ "$SUBCOMMAND" == "show" && -z "$SLUG_SET" ]]; then
# show's bare positional is a SLUG, never a role — show has its
# own SLUG-only positional form (distinct from section/append's
# role positional above), so it must not route through
# ROLE_OVERRIDE.
SLUG="$1"
SLUG_SET=1
shift
else
printf 'codegen-log: unexpected argument %q\n' "$1" >&2
usage
fi
;;
esac
done
if [[ -z "$SUBCOMMAND" ]]; then
printf 'codegen-log: no subcommand given (expected one of: init, section, append, verdict, relocate, exit, show)\n' >&2
usage
fi
# Marker flags (--learned/--no-learning/--died/--verdict) are mutually
# exclusive with each other. --died/--verdict are append-only. --learned is
# ALSO accepted on `section` (as an additive companion to the role body, not
# a replacement for it) — see the `section)` arm, which emits BOTH an
# "ev":"role" event and an "ev":"learned" event in one call. --learned is
# NEVER mutually exclusive with --body on `section` (that is the point: one
# call, two events); it stays mutually exclusive with --body on `append`
# (append has no body+marker combined form). --no-learning is append-only
# (the "nothing to report" exit has no section-companion form — it always
# stands alone).
MARKER_FLAG_COUNT=0
[[ -n "$LEARNED_TEXT" ]] && MARKER_FLAG_COUNT=$((MARKER_FLAG_COUNT + 1))
[[ -n "$NO_LEARNING_TEXT" ]] && MARKER_FLAG_COUNT=$((MARKER_FLAG_COUNT + 1))
[[ -n "$DIED_KIND" ]] && MARKER_FLAG_COUNT=$((MARKER_FLAG_COUNT + 1))
[[ -n "$VERDICT_KIND" ]] && MARKER_FLAG_COUNT=$((MARKER_FLAG_COUNT + 1))
[[ -n "$FILES_TO_TOUCH_SOURCE" ]] && MARKER_FLAG_COUNT=$((MARKER_FLAG_COUNT + 1))
[[ -n "$FILES_MODIFIED_SOURCE" ]] && MARKER_FLAG_COUNT=$((MARKER_FLAG_COUNT + 1))
[[ -n "$WAIVER_HOOK" ]] && MARKER_FLAG_COUNT=$((MARKER_FLAG_COUNT + 1))
if [[ "$MARKER_FLAG_COUNT" -gt 1 ]]; then
printf 'codegen-log: --learned, --no-learning, --died, --verdict, --files-to-touch, --files-modified, and --waiver are mutually exclusive\n' >&2
exit 2
fi
if [[ -n "$DIED_KIND" || -n "$VERDICT_KIND" || -n "$NO_LEARNING_TEXT" || -n "$FILES_TO_TOUCH_SOURCE" || -n "$FILES_MODIFIED_SOURCE" || -n "$WAIVER_HOOK" ]]; then
if [[ "$SUBCOMMAND" != "append" ]]; then
printf 'codegen-log: --died/--verdict/--no-learning/--files-to-touch/--files-modified/--waiver are only valid with the append subcommand\n' >&2
exit 2
fi
fi
if [[ -n "$WAIVER_HOOK" && -z "$WAIVER_SLUG" ]]; then
printf 'codegen-log: --waiver requires --waiver-slug\n' >&2
exit 2
fi
if [[ -n "$LEARNED_TEXT" ]]; then
if [[ "$SUBCOMMAND" != "append" && "$SUBCOMMAND" != "section" ]]; then
printf 'codegen-log: --learned is only valid with the section or append subcommand\n' >&2
exit 2
fi
if [[ "$SUBCOMMAND" == "append" && -n "$BODY_SOURCE" ]]; then
printf 'codegen-log: --learned is mutually exclusive with --body on append\n' >&2
exit 2
fi
fi
# validate_role — ensure ROLE_OVERRIDE matches the known role-family
# vocabulary. Exits 2 on unknown role (same families the old markdown header
# mapping recognized: loop/developer-*/reviewer-*/context-curator/dev-gate).
# `committer` left the role vocabulary entirely — commits are made by a
# deterministic script (codegen-commit), never a role, so the `{"ev":
# "committed"}` event is authored `loop` (see pitch "committing is
# deterministic, not a model call"). dev-gate is only ever set internally by
# the `verdict` subcommand, never accepted from a caller-supplied --role for
# section/append.
validate_role() {
local role="$1"
case "$role" in
loop | developer-* | reviewer-* | context-curator) ;;
*)
printf 'codegen-log: could not resolve role (got %q). Pass the role positionally (codegen-log section <role>) or via --role <loop|developer-*|reviewer-*|context-curator>.\n' "${role:-unknown}" >&2
exit 2
;;
esac
}
# is_placeholder_text — the substance filter. Returns 0 (true) when the
# ENTIRE trimmed text is a whole-text placeholder token, OR the text
# contains a compliance-echo phrase (text that describes the retrospective
# CHECK rather than the work it is supposed to record). Whole-text matching
# on the placeholder family is deliberate: a real slug/body like
# "placeholder-project-context-fix" must survive, because it is not, in its
# entirety, one of the deny-listed phrases — only a compliance-echo
# substring match (below) can catch text embedded inside a longer body.
#
# Empty/whitespace-only text is NOT a placeholder-filter concern here — an
# empty `section`/`append` body has always been legal (see codegen-log_test.sh
# case (b)); it is the Stop-hook's job to require a non-empty WORK body, not
# codegen-log's. --learned/--no-learning callers pass a non-empty string by
# construction (the CLI arg parser only sets LEARNED_TEXT/NO_LEARNING_TEXT
# when the flag carries a value).
is_placeholder_text() {
local text="$1"
local trimmed normalized
trimmed="$(printf '%s' "$text" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
normalized="$(printf '%s' "$trimmed" | tr '[:upper:]' '[:lower:]')"
if [[ -z "$trimmed" ]]; then
return 1
fi
# Whole-text placeholder tokens (the ENTIRE normalized text, not a
# substring) — the class of bare filler a role types to satisfy
# "something is present" with zero content.
case "$normalized" in
'test body' | 'test body line' | 'test section' | 'test learning' | \
'placeholder' | 'placeholder text' | 'placeholder body' | \
'placeholder-body-will-be-replaced' | 'dummy' | 'lorem ipsum' | \
'nothing notable' | 'nothing' | 'none' | 'n/a' | 'no learnings')
return 0
;;
esac
# Compliance-echo phrases — substring match is correct HERE (unlike the
# whole-text tokens above): this text describes the retrospective CHECK
# itself rather than the work done, regardless of what else surrounds
# it in the string.
case "$normalized" in
*'long enough'* | *'forty char'* | *'40 char'* | *'at least forty'* | \
*'at least 40'* | *'non-triviality bar'* | *'for testing'* | \
*'passes the'*'bar'* | *'minimum bar'* | *'char minimum'*)
return 0
;;
esac
return 1
}
# has_routing_tag — the routing-tag well-formedness check for --learned text
# ONLY (never --no-learning, which routes nowhere — the curator reads
# ev:learned exclusively). Returns 0 (true) when the trimmed text begins
# with a leading "[local]" or "[shared]" tag, case-sensitive. This is a
# well-formedness check, distinct from is_placeholder_text's substance
# check: a tagged whole-text placeholder still fails the substance filter,
# and an untagged real learning still fails this one. No length logic of
# any kind — tag presence only.
has_routing_tag() {
local text="$1"
local trimmed
trimmed="$(printf '%s' "$text" | sed -e 's/^[[:space:]]*//')"
case "$trimmed" in
'[local]'* | '[shared]'*)
return 0
;;
esac
return 1
}
repo_root() {
printf '%s' "$LOG_ROOT"
}
active_sentinel_path() {
printf '%s/codegen/logging/.active' "$(repo_root)"
}
# active_sentinel_log — read the .active sentinel and return its contents
# (an absolute log path) IFF that path still exists on disk. Empty otherwise
# (stale sentinel pointing at a relocated/deleted log falls through to the
# mtime fallback, never a hard error).
active_sentinel_log() {
local sentinel path
sentinel="$(active_sentinel_path)"
[[ -f "$sentinel" ]] || return 0
path="$(cat "$sentinel" 2>/dev/null || true)"
[[ -n "$path" && -f "$path" ]] || return 0
printf '%s' "$path"
}
write_active_sentinel() {
local log_path="$1"
local sentinel
sentinel="$(active_sentinel_path)"
mkdir -p "$(dirname "$sentinel")"
printf '%s' "$log_path" >"$sentinel"
}
# latest_cycle_log — most recently MODIFIED *_cycle.jsonl (mtime order via
# `ls -t`), not lexicographic/glob-order. This is the last-resort fallback
# when neither CODEGEN_LOG_PATH, --slug, nor the .active sentinel resolve.
latest_cycle_log() {
local root
root="$(repo_root)"
# `ls` exits non-zero when the glob matches nothing (no logs exist at
# all yet) — under `set -o pipefail` that failure propagates through the
# pipe to `head`, and under `set -e` a bare command substitution
# assignment (`log_file="$(latest_cycle_log)"` at the call site) would
# then abort the WHOLE script silently, before the caller's own
# "-z $log_file" no-log-found check ever runs. `|| true` here keeps the
# legitimate "nothing to fall back to" case a clean empty result.
ls -t "$root"/codegen/logging/*_cycle.jsonl 2>/dev/null | head -n 1 || true
}
validate_slug() {
local slug="$1"
if [[ ! "$slug" =~ ^[a-z0-9_-]+$ ]]; then
printf 'codegen-log: --slug must match [a-z0-9_-]+ (got %q)\n' "$slug" >&2
exit 2
fi
}
resolve_by_slug() {
local slug="$1"
local root matches=() candidate
root="$(repo_root)"
for candidate in "$root"/codegen/logging/*_"${slug}"_cycle.jsonl; do
[[ -e "$candidate" ]] || continue
matches+=("$candidate")
done
if [[ "${#matches[@]}" -eq 0 ]]; then
printf 'codegen-log: no cycle log matches slug %s\n' "$slug" >&2
exit 2
fi
if [[ "${#matches[@]}" -gt 1 ]]; then
printf 'codegen-log: ambiguous slug %s: %d logs match\n' "$slug" "${#matches[@]}" >&2
printf '%s\n' "${matches[@]}" >&2
exit 2
fi
printf '%s' "${matches[0]}"
}
# resolve_by_slug_newest — READ-ONLY counterpart to resolve_by_slug. A slug
# now legitimately matches more than one log (each retry mints its own run
# identity), so a read (show) takes the NEWEST match by stamp rather than
# refusing. Stamps are fixed-width YYYYMMDD_HHMMSS, so lexical sort on the
# filename equals chronological order — same assumption the queue drain
# already makes (stamp >= spawn_stamp comparisons). Zero matches still exits
# 2 (nothing to show); 2+ matches prints ONE stderr note naming the older
# logs, mirroring the explicit-ladder note `show` already prints elsewhere
# for its spine choice.
resolve_by_slug_newest() {
local slug="$1"
local root matches=() candidate
root="$(repo_root)"
for candidate in "$root"/codegen/logging/*_"${slug}"_cycle.jsonl; do
[[ -e "$candidate" ]] || continue
matches+=("$candidate")
done
if [[ "${#matches[@]}" -eq 0 ]]; then
printf 'codegen-log: no cycle log matches slug %s\n' "$slug" >&2
exit 2
fi
if [[ "${#matches[@]}" -eq 1 ]]; then
printf '%s' "${matches[0]}"
return
fi
local sorted newest last_idx
sorted=($(printf '%s\n' "${matches[@]}" | sort))
last_idx=$((${#sorted[@]} - 1))
newest="${sorted[$last_idx]}"
printf 'codegen-log show: %d logs match slug %s — showing the newest run (%s); older: %s\n' \
"${#matches[@]}" "$slug" "$newest" "$(printf '%s ' "${sorted[@]:0:$last_idx}")" >&2
printf '%s' "$newest"
}
resolve_log_file() {
local mode="${1:-write}"
if [[ -n "${CODEGEN_LOG_PATH:-}" ]]; then
printf '%s' "$CODEGEN_LOG_PATH"
return
fi
if [[ -n "$SLUG_SET" && -z "$SLUG" ]]; then
printf 'codegen-log: --slug was passed but is empty (refusing to fall back to the newest log)\n' >&2
exit 2
fi
if [[ -n "$SLUG" ]]; then
validate_slug "$SLUG"
if [[ "$mode" == "read" ]]; then
resolve_by_slug_newest "$SLUG"
else
resolve_by_slug "$SLUG"
fi
return
fi
local sentinel_log
sentinel_log="$(active_sentinel_log)"
if [[ -n "$sentinel_log" ]]; then
printf '%s' "$sentinel_log"
return
fi
latest_cycle_log
}
# version_stamp_json — emit the stamp object fields for the init event as a
# single-line JSON object via jq. Computes the same hash/version values the
# old markdown "## Version Stamp" block carried.
version_stamp_json() {
local project_hash context_hash codegen_hash claude_version stamped_at
project_hash="$(git -C "$LOG_ROOT" rev-parse --short HEAD 2>/dev/null || printf 'unknown')"
if [[ -n "$CODEGEN_ROOT" ]]; then
context_hash="$(git -C "$CODEGEN_ROOT/codegen/rules" rev-parse --short HEAD 2>/dev/null || printf 'unknown')"
codegen_hash="$(git -C "$CODEGEN_ROOT" rev-parse --short HEAD 2>/dev/null || printf 'unknown')"
else
# CODEGEN_ROOT never resolved (neither CODEGEN_DIR nor OCG_CODEGEN_DIR
# pointed at a valid codegen checkout, and no sibling codegen/ dir
# exists next to this script). An honest marker beats running
# `git -C ""` blind, which would print the misleading `unknown`.
context_hash="unresolved-root"
codegen_hash="unresolved-root"
fi
if command -v claude >/dev/null 2>&1; then
claude_version="$(claude --version 2>/dev/null || printf 'unknown')"
else
claude_version="unknown"
fi
stamped_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
jq -c -n \
--arg project "$project_hash" \
--arg context "$context_hash" \
--arg codegen "$codegen_hash" \
--arg claude "$claude_version" \
--arg at "$stamped_at" \
'{project: $project, context: $context, codegen: $codegen, claude: $claude, at: $at}'
}
# validate_event_line <compact-json-line> — checks a built event object
# against EVENT_SCHEMA_JQ before it is ever written: kind present, kind
# declared in the table, every required field present (null counts as
# present — only a MISSING key fails). On failure: exits 2, writes nothing,
# names the kind and (when known) the offending field. Called at BOTH write
# paths (append_event_line below, and init's own truncating `>`) so no emit
# site can bypass it. Every current emit is a hardcoded literal that already
# satisfies the table — this exit path is unreachable today; it exists to
# refuse a FUTURE malformed emit before it becomes a permanent line in an
# append-only log.
validate_event_line() {
local line="$1"
local verdict
verdict="$(printf '%s' "$line" | jq -r "$EVENT_SCHEMA_JQ"'
. as $obj
| ($obj.ev // null) as $kind
| if $kind == null then
"missing-ev"
elif (event_schema | has($kind) | not) then
"unknown-kind:\($kind)"
else
(event_schema[$kind].required
| map(select(. as $f | ($obj | has($f)) | not))) as $missing
| if ($missing | length) > 0 then
"missing-field:\($kind):\($missing | join(","))"
else
"ok"
end
end
')"
if [[ "$verdict" == "ok" ]]; then
return 0
fi
case "$verdict" in
missing-ev)
printf 'codegen-log: refusing to write event with no "ev" field: %s\n' "$line" >&2
;;
unknown-kind:*)
printf 'codegen-log: refusing to write unknown event kind %q (not in EVENT_SCHEMA_JQ): %s\n' "${verdict#unknown-kind:}" "$line" >&2
;;
missing-field:*)
local rest="${verdict#missing-field:}"
local kind="${rest%%:*}"
local fields="${rest#*:}"
printf 'codegen-log: refusing to write %q event missing required field(s) %s: %s\n' "$kind" "$fields" "$line" >&2
;;
*)
printf 'codegen-log: refusing to write malformed event (%s): %s\n' "$verdict" "$line" >&2
;;
esac
exit 2
}
# append_event_line — append ONE compact JSON line to the given log file.
# `$1` is the log path, `$2` is the already-built compact JSON object string.
# Validates against EVENT_SCHEMA_JQ before writing (write path 1 of 2).
append_event_line() {
local path="$1"
local line="$2"
validate_event_line "$line"
printf '%s\n' "$line" >>"$path"
}
# read_at_source <flag-name> <source> — read the raw text behind an
# @<file>/@- source argument, same convention as --body. Exits 2 (naming the
# flag) when the source does not start with @, or the named file is missing.
read_at_source() {
local flag_name="$1"
local source="$2"
if [[ "$source" != @* ]]; then
printf 'codegen-log: %s must start with @ (got %q)\n' "$flag_name" "$source" >&2
exit 2
fi
if [[ "$source" != @- ]]; then
local file_path="${source#@}"
if [[ ! -f "$file_path" ]]; then
printf 'codegen-log: %s file not found: %s\n' "$flag_name" "$file_path" >&2
exit 2
fi
cat "$file_path"
else
cat
fi
}
# show_summary_file — derive the sibling cycle-summary.jsonl path for a given
# cycle log path (log dir + basename minus the "_cycle.jsonl" suffix). Prints
# the path unconditionally; callers check -f before reading (an absent
# sibling is a legitimate, tolerated fallback rung, not an error).
show_summary_file() {
local log_file="$1"
local dir base stem
dir="$(dirname "$log_file")"
base="$(basename "$log_file")"
stem="${base%_cycle.jsonl}"
printf '%s/%s/cycle-summary.jsonl' "$dir" "$stem"
}
# show_build_model — build the single JSON model `show` renders from, as one
# compact JSON object on stdout. Two jq passes: this pass only ASSEMBLES the
# model (spine + anomalies + unknown-kind counts); show_render (below) does
# all format-specific presentation. Emits fallback notes to FD 3 (passed in
# by the caller) rather than stdout, so the model stream itself stays pure
# JSON.
#
# Spine preference order (an explicit ladder, never silent): ev:turn events
# (if present in this log) > cycle-summary.jsonl sibling rows > bare ev:role
# events. Whichever rung is used, exactly one stderr note is printed via FD 3.
show_build_model() {
local log_file="$1"
local summary_file="$2"
local notes_fd="$3"
# Fail loud on a malformed (non-JSON) log line — no partial render exiting
# 0. This validation must be a DIRECT command (not inside a process
# substitution or a `||`-guarded test), so its non-zero exit actually
# propagates under `set -e`.
local events_json
events_json="$(jq -c -s '.' "$log_file")"
local has_summary