Skip to content
Closed
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
34 changes: 34 additions & 0 deletions LifeOS/install/hooks/lib/isa-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from 'bun:test';
import { appendDecisionRow } from './isa-utils';

// Regression (2026-09-01 incident): the auto-rewind appended its Decisions row
// with a STRING replacement built from the existing Decisions body. That body is
// full of literal dollar amounts, and String.replace treats `$1`/`$2`/`$&` in a
// replacement string as regex backreferences — so `$14,000` expanded to
// `<capture-group-1>4,000`, duplicating the `## Decisions` header and shredding
// the section. The fix uses a function replacer, whose return value is inserted
// verbatim with no `$` interpretation.
test('appendDecisionRow preserves dollar amounts and does not duplicate the section', () => {
// Synthetic amounts chosen so their leading digits are `$1`/`$2`/`$3`/`$4` —
// exactly the prefixes String.replace would misread as backreferences.
const content =
'---\nphase: complete\n---\n' +
'## Decisions\n' +
'- 2026-01-02: inflow +$12,000; net −$23,456.78; item $3,999; total $45,678.90\n' +
'## Learning\n- note\n';

const out = appendDecisionRow(content, '2026-09-01T00:00:00Z', 3);

// Every dollar amount survives intact (the `$1`/`$2`/`$3` prefixes are the trap).
for (const amt of ['$12,000', '$23,456.78', '$3,999', '$45,678.90']) {
expect(out).toContain(amt);
}
// Exactly one Decisions heading — no backref-driven duplication.
expect(out.match(/## Decisions/g)?.length).toBe(1);
// The auto-rewind row was appended under Decisions, above Learning.
expect(out).toContain('- D-auto-2026-09-01T00:00:00Z:');
expect(out.indexOf('D-auto-')).toBeLessThan(out.indexOf('## Learning'));
// No stray fragment line beginning with the tail of a split amount ($12,000 → 2,000).
expect(out).not.toMatch(/^2,000/m);
expect(out).not.toMatch(/^## Decisions[^\n]/m);
});
8 changes: 6 additions & 2 deletions LifeOS/install/hooks/lib/isa-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,16 @@ export function hashBody(content: string): string {
/** Append one Decisions row to the body. Inserts under `## Decisions` heading,
* creating the section if missing. v6.9.0 invariant: every auto-rewind logs
* one row so the principal can audit the rewind inline. */
function appendDecisionRow(content: string, ts: string, newIteration: number): string {
export function appendDecisionRow(content: string, ts: string, newIteration: number): string {
const row = `- D-auto-${ts}: Auto-resumed from complete to learn at ${ts} — iteration ${newIteration}`;
const decisionsRe = /(\n## Decisions\n)([\s\S]*?)(\n## |\n---\n|$)/;
const match = content.match(decisionsRe);
if (match) {
return content.replace(decisionsRe, `${match[1]}${match[2].trimEnd()}\n${row}\n${match[3]}`);
// Function replacer, not a string: the existing Decisions body (match[2]) is
// full of literal dollar amounts, and a string replacement would interpret
// `$1`/`$2`/`$&` inside them as regex backreferences — a `$14,000` became
// `<group-1>4,000`, duplicating and shredding the section (2026-09-01 incident).
return content.replace(decisionsRe, () => `${match[1]}${match[2].trimEnd()}\n${row}\n${match[3]}`);
}
// No Decisions section yet — append before the learning-trail section if present
// (## Learning, or the legacy ## Changelog alias for pre-rename ISAs), else end.
Expand Down