Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ after this row has an operational caller.
- Focused tests cover argv parsing, usage-error no-write behavior, and an
end-to-end CLI write-back against a writer-emitted divergence shard.

## Codex JSON list slice (2026-05-31)

- Added `--json` list output to `tools/hygiene/divergence-reconcile.ts` so
autonomous loops can consume the pending-divergence read as structured data.
- The payload is deterministic and side-effect free:
`{ "schemaVersion": 1, "pending": [...] }`, where each pending entry carries
the same relPath, tick, topic, and loop-agent fields as the human-readable
report.
- The flag is intentionally list-only. `--json` with `--reconcile` is rejected
before any write so machine output cannot blur into the bounded write-back
action.
- Focused tests cover argv parsing, JSON payload stability, and separation from
reconciliation writes.

## Current implementation state (2026-05-31)

Implemented slices:
Expand All @@ -179,7 +193,8 @@ Implemented slices:
perspectives, and a neutral disagreement summary, making the shard readable
without external context.
- `scanDivergenceDir` plus `tools/hygiene/divergence-reconcile.ts --list` /
`--reconcile` provide the morning read/action path for pending shards.
`--json` / `--reconcile` provide the morning read/action path for pending
shards.

Remaining slice:

Expand Down
42 changes: 41 additions & 1 deletion tools/hygiene/divergence-reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,12 @@ describe("isReconciliationDecision / RECONCILIATION_DECISIONS", () => {

describe("parseArgs", () => {
test("defaults to the pending-shard list action", () => {
expect(parseArgs([])).toEqual({ kind: "ok", command: { kind: "list" } });
expect(parseArgs([])).toEqual({ kind: "ok", command: { kind: "list", format: "text" } });
});

test("parses machine-readable list mode", () => {
expect(parseArgs(["--json"])).toEqual({ kind: "ok", command: { kind: "list", format: "json" } });
expect(parseArgs(["--list", "--json"])).toEqual({ kind: "ok", command: { kind: "list", format: "json" } });
});

test("parses a bounded reconcile action with a decision and optional note", () => {
Expand Down Expand Up @@ -263,6 +268,12 @@ describe("parseArgs", () => {
kind: "error",
message: "--list cannot be combined with reconciliation arguments",
});
expect(
parseArgs(["--reconcile", "docs/hygiene-history/divergences/x.md", "--decision", "accept-loop-a", "--json"]),
).toEqual({
kind: "error",
message: "--json can only be used with list mode",
});
});
});

Expand Down Expand Up @@ -547,6 +558,35 @@ describe("main CLI write-back action", () => {
} as Pick<typeof process.stdout, "write">;
}

test("prints a stable machine-readable pending-shard list with --json", () => {
withTempRoot((root) => {
const { relPath } = writeDivergenceShard(root, inputAt("2026-05-10T11:48:00Z", "A", "B"));
const out: string[] = [];
const err: string[] = [];

const exit = main(["--json"], {
repoRoot: () => root,
stdout: writer(out),
stderr: writer(err),
});

expect(exit).toBe(0);
expect(err).toEqual([]);
const payload = JSON.parse(out.join("")) as {
schemaVersion: number;
pending: Array<{ relPath: string; tick: string; loopAAgent: string; loopBAgent: string }>;
};
expect(payload.schemaVersion).toBe(1);
expect(payload.pending).toHaveLength(1);
expect(payload.pending[0]).toMatchObject({
relPath,
tick: "2026-05-10T11:48:00Z",
loopAAgent: "otto",
loopBAgent: "codex-loop",
});
});
});

test("lands a reconciliation decision through the repo-native CLI action", () => {
withTempRoot((root) => {
const { relPath } = writeDivergenceShard(root, inputAt("2026-05-10T11:48:00Z", "A", "B"));
Expand Down
38 changes: 33 additions & 5 deletions tools/hygiene/divergence-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,17 @@ export interface ReconcileResult {
readonly decision: ReconciliationDecision;
}

export type ListOutputFormat = "text" | "json";

/** Stable machine-readable payload for pending divergence reconciliation reads. */
export interface PendingShardListJson {
readonly schemaVersion: 1;
readonly pending: readonly PendingShard[];
}

/** CLI action parsed from argv. */
export type CliCommand =
| { readonly kind: "list" }
| { readonly kind: "list"; readonly format: ListOutputFormat }
| { readonly kind: "help" }
| {
readonly kind: "reconcile";
Expand All @@ -312,20 +320,22 @@ export function usage(): string {
return [
"Usage:",
" bun tools/hygiene/divergence-reconcile.ts",
" bun tools/hygiene/divergence-reconcile.ts --list",
" bun tools/hygiene/divergence-reconcile.ts --list [--json]",
" bun tools/hygiene/divergence-reconcile.ts --json",
" bun tools/hygiene/divergence-reconcile.ts --reconcile <relPath> --decision <decision> [--note <text>]",
"",
`Decisions: ${RECONCILIATION_DECISIONS.join(" | ")}`,
].join("\n");
}

export function parseArgs(argv: readonly string[]): ParseArgsResult {
if (argv.length === 0) return { kind: "ok", command: { kind: "list" } };
if (argv.length === 0) return { kind: "ok", command: { kind: "list", format: "text" } };

let relPath: string | undefined;
let decision: string | undefined;
let note: string | undefined;
let sawList = false;
let sawJson = false;

for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
Expand All @@ -336,6 +346,10 @@ export function parseArgs(argv: readonly string[]): ParseArgsResult {
sawList = true;
continue;
}
if (arg === "--json") {
sawJson = true;
continue;
}
if (arg === "--reconcile") {
relPath = argv[++i];
if (!relPath || relPath.startsWith("--")) {
Expand Down Expand Up @@ -363,7 +377,12 @@ export function parseArgs(argv: readonly string[]): ParseArgsResult {
if (sawList && (relPath !== undefined || decision !== undefined || note !== undefined)) {
return { kind: "error", message: "--list cannot be combined with reconciliation arguments" };
}
if (sawList) return { kind: "ok", command: { kind: "list" } };
if (sawJson && (relPath !== undefined || decision !== undefined || note !== undefined)) {
return { kind: "error", message: "--json can only be used with list mode" };
}
if (sawList || sawJson) {
return { kind: "ok", command: { kind: "list", format: sawJson ? "json" : "text" } };
}
if (relPath === undefined) {
return { kind: "error", message: "--reconcile is required when passing reconciliation arguments" };
}
Expand Down Expand Up @@ -586,6 +605,14 @@ function renderPendingReport(pending: readonly PendingShard[]): string {
return report;
}

export function pendingShardListJson(pending: readonly PendingShard[]): PendingShardListJson {
return { schemaVersion: 1, pending };
}

export function renderPendingJsonReport(pending: readonly PendingShard[]): string {
return `${JSON.stringify(pendingShardListJson(pending), null, 2)}\n`;
}

function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
Expand Down Expand Up @@ -615,7 +642,8 @@ export function main(argv: readonly string[] = process.argv.slice(2), options: M
try {
const root = (options.repoRoot ?? repoRoot)();
if (parsed.command.kind === "list") {
stdout.write(renderPendingReport(scanDivergenceDir(root)));
const pending = scanDivergenceDir(root);
stdout.write(parsed.command.format === "json" ? renderPendingJsonReport(pending) : renderPendingReport(pending));
return 0;
}
const result = reconcileDivergenceShard(root, parsed.command.relPath, parsed.command.decision, parsed.command.note);
Expand Down
Loading