diff --git a/docs/trajectories/typescript-bun-migration/RESUME.md b/docs/trajectories/typescript-bun-migration/RESUME.md index 3e7bde38c..84e73ffe8 100644 --- a/docs/trajectories/typescript-bun-migration/RESUME.md +++ b/docs/trajectories/typescript-bun-migration/RESUME.md @@ -1,9 +1,9 @@ # Trajectory — TypeScript / Bun migration -**Status**: Active (Lane B slice 8 merged — [#880](https://github.com/Lucent-Financial-Group/Zeta/pull/880), commit `988de70`) -**Milestone**: 28 hygiene/lint/audit scripts ported (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876 + 3 from #878 + 3 from #880 + 3 in-flight in slice-9). **Cluster H complete (5/5)** in #878 + #880; slice-9 opens **agency-signature-pair cluster** (validate-agencysignature-pr-body + audit-agencysignature-main-tip — paired ferry-7 enforcement-instrument set per Amara) + capture-tick-snapshot (snapshot-pinning per Amara 4th-ferry). 16 Bucket B files remain. +**Status**: Active (Lane B slice 9 merged — [#882](https://github.com/Lucent-Financial-Group/Zeta/pull/882), commit `02266a7`) +**Milestone**: 31 hygiene/lint/audit scripts ported (2 from #849 + 3 from #866 + 3 from #868 + 3 from #870 + 2 from #872 + 3 from #874 + 3 from #876 + 3 from #878 + 3 from #880 + 3 from #882 + 2 in-flight in slice-10). **Cluster H complete** + agency-signature-pair complete; slice-10 opens **counterweight-cluster + write-side-tools** (counterweight-audit + append-tick-history-row). 14 Bucket B files remain. **Current blocker**: None. -**Next concrete action**: Pick a coherent next slice from Bucket B (16 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. +**Next concrete action**: Pick a coherent next slice from Bucket B (14 files remaining). Per Gate B: read-only scope first, then re-verify the layered baseline currency before first mutating action. **Last updated**: 2026-04-30 ## Why this trajectory exists diff --git a/docs/trajectories/typescript-bun-migration/slice-audits.md b/docs/trajectories/typescript-bun-migration/slice-audits.md index 00f52194e..2c361c835 100644 --- a/docs/trajectories/typescript-bun-migration/slice-audits.md +++ b/docs/trajectories/typescript-bun-migration/slice-audits.md @@ -411,6 +411,29 @@ Per-port pattern checklist: Slice 6 passes audit. No new patterns recorded — all reused from prior slices. +## Slice 10 — 2 ports (counterweight-cluster + first write-side) (PR pending — `lane-b/ts-bun-slice-10-counterweight-audit-2026-04-30`) + +**Slice files**: + +- `tools/hygiene/counterweight-audit.{sh→ts}` (Otto-278 cadenced re-read) +- `tools/hygiene/append-tick-history-row.{sh→ts}` (chronological-tail-append validator) + +**Comparison points**: identical to slice 6/7/8/9. Within Gate B 30-day window. + +### Code-pattern audit (per-port) + +- **`counterweight-audit.ts`** (253 → 326 lines): BSD/GNU stat-flavor probe replaced with `statSync().mtimeMs`. YAML frontmatter awk parser → manual fence-aware char walk. Arg parser refactored into `classifyArg` helper + ArgStep tagged union. `mktemp` + sort-rn pipeline replaced with in-memory array sort. +- **`append-tick-history-row.ts`** (81 → 106 lines): bash `[[ =~ ]]` regex preserved as `TS_PREFIX_RE.exec`. `grep -oE | sort | tail -1` replaced with `findLatestTimestamp` helper. ISO-8601 sort uses `localeCompare`. + +### Equivalence audit + +- **`counterweight-audit`**: byte-equivalent on default cadence + all explicit cadence levels. +- **`append-tick-history-row`**: byte-equivalent on usage error + malformed-row + out-of-order-timestamp paths modulo script self-reference (.sh vs .ts). + +### Outcome + +Slice 10 passes audit. **Counterweight-cluster opened** (Otto-278 cadenced inspect). **First write-side script ported** (append-tick-history-row) — confirms write-side equivalence-test pattern works. Bucket B 16 → 14. + ## Slice 9 — 3 ports (agency-signature-pair cluster + snapshot-pinning) (PR pending — `lane-b/ts-bun-slice-9-agencysignature-pair-2026-04-30`) **Slice files**: diff --git a/tools/hygiene/append-tick-history-row.ts b/tools/hygiene/append-tick-history-row.ts new file mode 100644 index 000000000..42e685bfe --- /dev/null +++ b/tools/hygiene/append-tick-history-row.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env bun +// append-tick-history-row.ts — appends a row to the loop-tick-history +// using simple append (avoids the Edit-tool's reverse-chronological +// bug shape Aaron flagged 2026-04-26). +// +// TypeScript+Bun port of append-tick-history-row.sh, slice 10 of the +// TS+Bun migration. See docs/best-practices/repo-scripting.md. +// +// Usage: +// bun tools/hygiene/append-tick-history-row.ts "FULL_ROW_TEXT" +// +// The argument is the entire row including leading `| ` and trailing +// `|`. Caller is responsible for row content (signal-in-signal-out); +// this script is a dumb pipe + validator. +// +// Exit codes: +// 0 appended successfully +// 1 row malformed OR timestamp out of order +// 2 wrong number of arguments + +import { appendFileSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +type ExitCode = 0 | 1 | 2; + +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; + +const TS_PREFIX_RE = /^\| (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) /; +const ROW_TS_RE = /^\| (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/; + +function repoRoot(): string { + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (result.status !== 0) return process.cwd(); + return result.stdout.trim(); +} + +function findLatestTimestamp(content: string): string { + const timestamps: string[] = []; + for (const line of content.split("\n")) { + const m = ROW_TS_RE.exec(line); + if (m !== null) timestamps.push(m[1] ?? ""); + } + // ISO-8601 is lex-sortable; sort + take last for "latest". + timestamps.sort((a, b) => a.localeCompare(b)); + return timestamps.length === 0 ? "" : (timestamps.at(-1) ?? ""); +} + +export function main(argv: readonly string[]): ExitCode { + if (argv.length !== 1) { + process.stderr.write( + "usage: append-tick-history-row.ts \"\"\n", + ); + return 2; + } + const row = argv[0]; + if (row === undefined) return 2; + + const m = TS_PREFIX_RE.exec(row); + if (m === null) { + process.stderr.write("ERROR: row must start with '| YYYY-MM-DDTHH:MM:SSZ '\n"); + process.stderr.write(`got: ${row.slice(0, 80)}...\n`); + return 1; + } + const newTs = m[1] ?? ""; + + const root = repoRoot(); + const tickFile = `${root}/docs/hygiene-history/loop-tick-history.md`; + + let existing: string; + try { + existing = readFileSync(tickFile, "utf8"); + } catch { + process.stderr.write(`ERROR: cannot read tick-history at ${tickFile}\n`); + return 1; + } + + const latestTs = findLatestTimestamp(existing); + if (latestTs !== "" && newTs < latestTs) { + process.stderr.write( + `ERROR: new row timestamp ${newTs} is BEFORE latest existing ${latestTs}\n`, + ); + process.stderr.write("\n"); + process.stderr.write( + "Tick-history is append-only with non-decreasing timestamps.\n", + ); + process.stderr.write( + "If your row is for a past tick, you have to either:\n", + ); + process.stderr.write(" (a) update the timestamp to current UTC (preferred),\n"); + process.stderr.write(" (b) file an ADR explaining the back-dated correction\n"); + process.stderr.write(" and use a correction-row pattern per Otto-229.\n"); + return 1; + } + + appendFileSync(tickFile, `${row}\n`); + process.stdout.write(`OK: appended row at ${newTs}\n`); + return 0; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} diff --git a/tools/hygiene/counterweight-audit.ts b/tools/hygiene/counterweight-audit.ts new file mode 100644 index 000000000..4c5f1afb9 --- /dev/null +++ b/tools/hygiene/counterweight-audit.ts @@ -0,0 +1,326 @@ +#!/usr/bin/env bun +// counterweight-audit.ts — cadenced counterweight-memory audit (Otto-278). +// +// TypeScript+Bun port of counterweight-audit.sh, slice 10 of the +// TS+Bun migration. See docs/best-practices/repo-scripting.md. +// +// Memory-only counterweights are leaky without a cadenced audit that +// FORCES re-reading the memories + checks for rule-drift. +// +// Quote extraction from the file body is deliberately NOT automated — +// the audit's point is forcing the agent to open each file and read +// it. Auto-extracting the quote into the audit output would let the +// agent skim the questions without opening the file. +// +// Usage: +// bun tools/hygiene/counterweight-audit.ts [--cadence quick|medium|long] [--count N] +// +// Exit codes: +// 0 normal completion +// 2 usage error (unknown flag, missing value, invalid cadence, etc.) + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +type ExitCode = 0 | 2; +type Cadence = "quick" | "medium" | "long"; + +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; + +const NON_NEG_INT_RE = /^\d+$/; +const OTTO_ID_RE = /otto_(\d+)/; +const NAME_FIELD_PREFIX = "name:"; + +interface ParsedArgs { + readonly cadence: Cadence; + readonly count: number; +} + +interface ArgParseResult { + readonly args: ParsedArgs | null; + readonly errorMessage: string; + readonly help: boolean; +} + +function emitUsageError(message: string): ExitCode { + process.stderr.write(`error: ${message}\n`); + process.stderr.write("run with --help for usage\n"); + return 2; +} + +function emitHelp(): void { + process.stdout.write("Usage:\n"); + process.stdout.write( + " bun tools/hygiene/counterweight-audit.ts [--cadence quick|medium|long] [--count N]\n", + ); + process.stdout.write("\n"); + process.stdout.write(" --cadence quick Top N most recently-modified counterweights only (default).\n"); + process.stdout.write(" --cadence medium Last 10 counterweights.\n"); + process.stdout.write(" --cadence long All counterweights, full re-read.\n"); + process.stdout.write(" --count N Override the per-cadence count (default 3 for quick,\n"); + process.stdout.write(" 10 for medium, unbounded for long).\n"); +} + +function defaultCount(cadence: Cadence): number { + if (cadence === "quick") return 3; + if (cadence === "medium") return 10; + return 0; +} + +type ArgStep = + | { readonly kind: "set-cadence"; readonly cadence: Cadence; readonly skip: 1 } + | { readonly kind: "set-count"; readonly raw: string; readonly skip: 1 } + | { readonly kind: "help" } + | { readonly kind: "error"; readonly message: string }; + +function classifyArg(arg: string, next: string | undefined): ArgStep { + if (arg === "--cadence") { + if (next === undefined) return { kind: "error", message: "--cadence requires a value" }; + if (next !== "quick" && next !== "medium" && next !== "long") { + return { + kind: "error", + message: `--cadence must be quick|medium|long (got '${next}')`, + }; + } + return { kind: "set-cadence", cadence: next, skip: 1 }; + } + if (arg === "--count") { + if (next === undefined) return { kind: "error", message: "--count requires a value" }; + return { kind: "set-count", raw: next, skip: 1 }; + } + if (arg === "-h" || arg === "--help") return { kind: "help" }; + return { kind: "error", message: `unknown argument '${arg}'` }; +} + +function parseArgs(argv: readonly string[]): ArgParseResult { + let cadence: Cadence = "quick"; + let countRaw = ""; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] ?? ""; + const step = classifyArg(arg, argv[i + 1]); + if (step.kind === "help") return { args: null, errorMessage: "", help: true }; + if (step.kind === "error") { + return { args: null, errorMessage: step.message, help: false }; + } + if (step.kind === "set-cadence") cadence = step.cadence; + else countRaw = step.raw; + i += step.skip; + } + + if (countRaw !== "" && !NON_NEG_INT_RE.test(countRaw)) { + return { + args: null, + errorMessage: `--count must be a non-negative integer (got '${countRaw}')`, + help: false, + }; + } + const count = countRaw === "" ? defaultCount(cadence) : Number.parseInt(countRaw, 10); + return { args: { cadence, count }, errorMessage: "", help: false }; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function repoRoot(): string { + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (result.status !== 0) return process.cwd(); + return result.stdout.trim(); +} + +interface Counterweight { + readonly file: string; + readonly mtimeMs: number; +} + +function listCounterweights(memoryDir: string): readonly Counterweight[] { + let entries: readonly import("node:fs").Dirent[]; + try { + entries = readdirSync(memoryDir, { withFileTypes: true }); + } catch { + return []; + } + const out: Counterweight[] = []; + for (const e of entries) { + if (!e.isFile()) continue; + if (!e.name.includes("otto_")) continue; + if (!e.name.endsWith(".md")) continue; + const file = join(memoryDir, e.name); + try { + const stats = statSync(file); + out.push({ file, mtimeMs: stats.mtimeMs }); + } catch { + continue; + } + } + return out.sort((a, b) => b.mtimeMs - a.mtimeMs); +} + +function extractOttoId(filename: string): string { + const m = OTTO_ID_RE.exec(filename); + if (m === null) return "(no Otto-ID in filename)"; + return `Otto-${m[1] ?? ""}`; +} + +function isFenceLine(line: string): boolean { + // Match `^---\s*$` without regex — manual char walk to avoid + // sonarjs slow-regex flag. + if (!line.startsWith("---")) return false; + for (let i = 3; i < line.length; i++) { + const c = line.charCodeAt(i); + if (c !== 0x20 && c !== 0x09) return false; + } + return true; +} + +function nameValueOrNull(line: string): string | null { + if (!line.startsWith(NAME_FIELD_PREFIX)) return null; + let i = NAME_FIELD_PREFIX.length; + while (i < line.length) { + const c = line.charCodeAt(i); + if (c !== 0x20 && c !== 0x09) break; + i++; + } + return line.slice(i); +} + +function extractNameField(content: string): string { + // Find the `name:` field within the FIRST YAML frontmatter fence + // (between the first two `---` lines). Mirror bash awk behaviour. + const lines = content.split("\n"); + let inFence = false; + for (const line of lines) { + if (isFenceLine(line)) { + inFence = !inFence; + continue; + } + if (!inFence) continue; + const value = nameValueOrNull(line); + if (value !== null) return value; + } + return ""; +} + +function readNameField(file: string): string { + let content: string; + try { + content = readFileSync(file, "utf8"); + } catch { + return "(no name field)"; + } + const value = extractNameField(content); + return value === "" ? "(no name field)" : value; +} + +function relativize(file: string, root: string): string { + const prefix = `${root}/`; + return file.startsWith(prefix) ? file.slice(prefix.length) : file; +} + +function emitHeader(cadence: Cadence, shown: number, total: number): void { + process.stdout.write(`# Counterweight audit — ${cadence} cadence\n`); + process.stdout.write("\n"); + process.stdout.write( + `Reading ${String(shown)} of ${String(total)} counterweight memories under\n`, + ); + process.stdout.write("`memory/*otto_*.md` (newest first). For each one, open\n"); + process.stdout.write("the file and read the rule body + maintainer quote, then\n"); + process.stdout.write("answer the per-counterweight audit questions below.\n"); + process.stdout.write("\n"); + process.stdout.write("_Tool: `tools/hygiene/counterweight-audit.sh` (Otto-278\n"); + process.stdout.write("cadenced-inspect Phase 1). Agent self-scores; no automatic\n"); + process.stdout.write("drift detection — the point is forcing the re-read._\n"); + process.stdout.write("\n"); +} + +function emitCounterweight( + ottoId: string, + rel: string, + nameLine: string, +): void { + process.stdout.write("---\n"); + process.stdout.write("\n"); + process.stdout.write(`## ${ottoId} — [\`${rel}\`](${rel})\n`); + process.stdout.write("\n"); + process.stdout.write(`**Rule (from \`name:\`):** ${nameLine}\n`); + process.stdout.write("\n"); + process.stdout.write("**Audit questions:**\n"); + process.stdout.write("\n"); + process.stdout.write("1. In the last N ticks, did I exhibit the drift\n"); + process.stdout.write(" this counter was filed for?\n"); + process.stdout.write("2. If yes: is the right move to tighten THIS\n"); + process.stdout.write(" counterweight (edit the memory), file a NEW\n"); + process.stdout.write(" tighter counterweight (like Otto-276 → Otto-277),\n"); + process.stdout.write(" or escalate to a skill / BP rule?\n"); + process.stdout.write("3. Is the counter still needed at this cadence,\n"); + process.stdout.write(" or can maintenance cadence stretch?\n"); + process.stdout.write("\n"); +} + +function emitFooter(): void { + process.stdout.write("---\n"); + process.stdout.write("\n"); + process.stdout.write("## After the re-read\n"); + process.stdout.write("\n"); + process.stdout.write("Summary to log (if any drift was found):\n"); + process.stdout.write("\n"); + process.stdout.write("- Which counterweights drifted? (list Otto IDs)\n"); + process.stdout.write("- What's the next cadence for each?\n"); + process.stdout.write("- Did any counterweight get a follow-up memory or\n"); + process.stdout.write(" BACKLOG row out of this audit?\n"); + process.stdout.write("\n"); + process.stdout.write('If nothing drifted, log a "clean tick" short note:\n'); + process.stdout.write("the audit's signal value is as much in confirming\n"); + process.stdout.write("stability as in catching drift.\n"); +} + +export function main(argv: readonly string[]): ExitCode { + const parsed = parseArgs(argv); + if (parsed.help) { + emitHelp(); + return 0; + } + if (parsed.args === null) return emitUsageError(parsed.errorMessage); + const { cadence, count } = parsed.args; + + const root = repoRoot(); + const memoryDir = join(root, "memory"); + + if (!isDirectory(memoryDir)) { + return emitUsageError( + `memory/ not found at ${memoryDir} (run from a Zeta checkout)`, + ); + } + + const all = listCounterweights(memoryDir); + const total = all.length; + const shown = count > 0 && count < total ? count : total; + + emitHeader(cadence, shown, total); + + for (let i = 0; i < shown; i++) { + const cw = all[i]; + if (cw === undefined) continue; + const ottoId = extractOttoId(cw.file); + const rel = relativize(cw.file, root); + const nameLine = readNameField(cw.file); + emitCounterweight(ottoId, rel, nameLine); + } + + emitFooter(); + return 0; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +}