From 088bc0a96e454ffa63d036a376054c1698d5a454 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 08:34:35 -0700 Subject: [PATCH 01/11] chore(porch): bugfix-1836 init bugfix --- .../status.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml diff --git a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml new file mode 100644 index 000000000..38d1a0e4c --- /dev/null +++ b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml @@ -0,0 +1,14 @@ +id: bugfix-1836 +title: decisions-tab-a-time-only-stam +protocol: bugfix +phase: investigate +plan_phases: [] +current_plan_phase: null +gates: + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-10-19T15:34:35.563Z' +updated_at: '2026-10-19T15:34:35.563Z' From afbf4dcbf10c0fb4d46b1895302f4a2a1774cb7b Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 08:39:35 -0700 Subject: [PATCH 02/11] chore(porch): bugfix-1836 fix phase-transition --- .../bugfix-1836-decisions-tab-a-time-only-stam/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml index 38d1a0e4c..3407385dd 100644 --- a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml +++ b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml @@ -1,7 +1,7 @@ id: bugfix-1836 title: decisions-tab-a-time-only-stam protocol: bugfix -phase: investigate +phase: fix plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-10-19T15:34:35.563Z' -updated_at: '2026-10-19T15:34:35.563Z' +updated_at: '2026-10-19T15:39:35.502Z' From 2d1cd05f75cd916bf8277ac09a149e6153db2c63 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 08:48:44 -0700 Subject: [PATCH 03/11] [Bugfix #1836] Fix: Read time-only and missing stamps without a warning The Decisions tab flagged nearly every card "Not converted" because the stamp parser accepted only a full `YYYY-MM-DD h:mm am ZONE` stamp, and a heading with no stamp had its question read as one. - A `_Zone: _` line in the file's header is the zone of every stamp that names none. A time-only stamp is today in that zone, at read time, and the card shows the converted time alone, never a date nobody wrote. - A heading part that is not a stamp stays in the question, as written. A stamp with no zone known is shown as written, with no warning. - The warning stays for an unknown or ambiguous zone (IST, BST, AST) and for a time the clocks skipped or repeated. - decision-protocol.md (both trees) documents the zone line and bare times and keeps the full form as the one to write; its new example runs in the doc test. Co-Authored-By: Claude Opus 5.5 --- apps/web/__tests__/DecisionsView.test.tsx | 21 ++++- apps/web/src/components/DecisionsView.tsx | 13 ++- codev-skeleton/resources/decision-protocol.md | 43 +++++++--- codev/resources/decision-protocol.md | 43 +++++++--- codev/state/bugfix-1836_thread.md | 36 ++++++++ .../__tests__/decision-protocol-doc.test.ts | 42 +++++++-- .../__tests__/decisions-parse.test.ts | 55 ++++++++++-- .../__tests__/decisions-stamp.test.ts | 86 +++++++++++++++++-- .../__tests__/e2e/decisions-tab.test.ts | 52 +++++++++++ .../src/agent-farm/servers/decisions/parse.ts | 46 +++++++--- .../src/agent-farm/servers/decisions/stamp.ts | 74 ++++++++++------ packages/types/src/api.ts | 15 +++- 12 files changed, 436 insertions(+), 90 deletions(-) create mode 100644 codev/state/bugfix-1836_thread.md diff --git a/apps/web/__tests__/DecisionsView.test.tsx b/apps/web/__tests__/DecisionsView.test.tsx index 378b5ba19..32f4dc7ae 100644 --- a/apps/web/__tests__/DecisionsView.test.tsx +++ b/apps/web/__tests__/DecisionsView.test.tsx @@ -14,7 +14,7 @@ vi.mock('../src/hooks/useDecisions.js', () => ({ useDecisions: (isActive: boolean) => mockUseDecisions(isActive), })); -import { DecisionsView, STAMP_FORMAT } from '../src/components/DecisionsView.js'; +import { DecisionsView, STAMP_FORMAT, STAMP_TIME_FORMAT } from '../src/components/DecisionsView.js'; /** The viewer's zone for every test here: neither the stamps' zone (PT) nor UTC. */ const VIEWER_TZ = 'America/New_York'; @@ -171,7 +171,6 @@ describe('DecisionsView card stamps', () => { }); it.each([ - ['unrecognized-format', '2026-10-17 25:61 PT', 'Not converted: the date or time is not in a recognized format'], ['unknown-zone', '2026-10-17 1:30 am IST', 'Not converted: the time zone is not one the tab recognizes'], ['nonexistent-time', '2026-03-08 2:30 am PT', 'Not converted: that time does not exist on that date in that zone (the clocks skipped it)'], ['ambiguous-time', '2026-11-01 1:30 am PT', 'Not converted: that time happens twice on that date in that zone (the clocks went back)'], @@ -182,6 +181,24 @@ describe('DecisionsView card stamps', () => { expect(c.querySelector('time')).toBeNull(); }); + it('shows a time-only stamp as the converted time alone, with no date (#1836)', () => { + const instant = '2026-10-20T01:30:00.000Z'; + showItems(makeItem('main-12', 'Q', { stamp: { raw: '6:30 pm', instant, timeOnly: true } })); + const time = within(card('Q')).getByText((_, el) => el?.tagName === 'TIME'); + const timeAlone = new Intl.DateTimeFormat(undefined, { ...STAMP_TIME_FORMAT, timeZone: VIEWER_TZ }).format(new Date(instant)); + expect(time.textContent).toBe(timeAlone); + expect(time.textContent).not.toMatch(/Oct|2026/); + expect(time).toHaveAttribute('title', '6:30 pm'); + }); + + it('shows a stamp with no zone known as written, with no warning (#1836)', () => { + showItems(makeItem('main-12', 'Q', { stamp: { raw: '6:30 pm', instant: null, timeOnly: true } })); + const c = card('Q'); + expect(c.querySelector('.decisions-stamp')).toHaveTextContent(/^6:30 pm$/); + expect(c).not.toHaveTextContent('Not converted'); + expect(c.querySelector('.decisions-stamp-flag')).toBeNull(); + }); + it('shows no stamp for a heading without one', () => { showItems(makeItem('main-12', 'Q')); const c = card('Q'); diff --git a/apps/web/src/components/DecisionsView.tsx b/apps/web/src/components/DecisionsView.tsx index 1a32560b0..aa6b5d528 100644 --- a/apps/web/src/components/DecisionsView.tsx +++ b/apps/web/src/components/DecisionsView.tsx @@ -76,7 +76,6 @@ const ITEM_PROBLEM_TEXT: Record = { }; const UNCONVERTED_TEXT: Record, string> = { - 'unrecognized-format': 'the date or time is not in a recognized format', 'unknown-zone': 'the time zone is not one the tab recognizes', 'nonexistent-time': 'that time does not exist on that date in that zone (the clocks skipped it)', 'ambiguous-time': 'that time happens twice on that date in that zone (the clocks went back)', @@ -95,6 +94,13 @@ export const STAMP_FORMAT: Intl.DateTimeFormatOptions = { timeZoneName: 'short', }; +/** A stamp written without a date: the time alone, so the tab never shows a date nobody wrote. */ +export const STAMP_TIME_FORMAT: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', +}; + const EXTERNAL_LINK_RE = /^https?:\/\//i; /** @@ -202,15 +208,16 @@ function Stamp({ stamp }: { stamp: DecisionStamp }) { if (stamp.instant) { return ( ); } + if (!stamp.unconvertedReason) return {stamp.raw}; return ( {stamp.raw} - Not converted{stamp.unconvertedReason && `: ${UNCONVERTED_TEXT[stamp.unconvertedReason]}`} + Not converted: {UNCONVERTED_TEXT[stamp.unconvertedReason]} ); diff --git a/codev-skeleton/resources/decision-protocol.md b/codev-skeleton/resources/decision-protocol.md index 4981fb23f..c9fb262c7 100644 --- a/codev-skeleton/resources/decision-protocol.md +++ b/codev-skeleton/resources/decision-protocol.md @@ -56,17 +56,25 @@ per option and a comment box. heading, so inside an item use `####` or bold labels, never a higher heading. The file has exactly one `## Open`. - **The heading is id · stamp · question.** The first word is the id; ` · ` (space, middle dot, - space) separates the id, the stamp and the question. Open items that share an id are - read-only until the ids differ. -- **The stamp carries its zone; nothing assumes one.** `YYYY-MM-DD h:mm am` (or `pm`), or 24-hour - `YYYY-MM-DD HH:mm`, then the zone the time was read in. The tab converts the stamp using only - that zone and shows it in the viewer's local time. It reads the North-American generic zones - `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` (daylight saving follows the stamp's date), fixed - abbreviations such as `UTC`, `GMT`, `EST`/`EDT`, `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` - is North-American Central), offsets such as `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names - such as `Europe/Berlin`. Any other zone, including ambiguous abbreviations such as `IST`, - `BST` and `AST`, is shown as written and marked not converted, as is a time the clocks - skipped or repeated that day. + space) separates the id, the stamp and the question. A heading without a stamp is id · + question, and a first part the tab cannot read as a stamp stays in the question, shown as + written. Open items that share an id are read-only until the ids differ. +- **The stamp carries its zone; nothing assumes one.** Write the full form: `YYYY-MM-DD h:mm am` + (or `pm`), or 24-hour `YYYY-MM-DD HH:mm`, then the zone the time was read in. The tab converts + the stamp using only that zone and shows its date and time in the viewer's local time. It + reads the North-American generic zones `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` (daylight saving + follows the stamp's date), fixed abbreviations such as `UTC`, `GMT`, `EST`/`EDT`, + `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` is North-American Central), offsets such as + `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names such as `Europe/Berlin`. Any other zone, + including ambiguous abbreviations such as `IST`, `BST` and `AST`, is shown as written and + marked not converted, as is a time the clocks skipped or repeated that day. +- **A file may name its zone once.** A line `_Zone: _` above `## Open`, from the same + list of zones, is the zone of every stamp that names none. A bare time, `h:mm am` or `HH:mm`, + is read as today in that zone when the tab reads the file, and shown as the time alone: as + written, converted to your clock, with no date because none was written. Without a file zone, + a stamp that names no zone is shown as written. A bare time is always read as today, so one + written before midnight and read after it lands on the wrong day: the full form stays the one + to write. - **Exactly one `**Your word:**` line**, starting a line, outside a code block. The options are the backticked words on it, separated by ` / `. The first is the recommended one, and the tab marks it. A description follows a word after ` = ` and runs to the next ` / ` before a @@ -85,6 +93,19 @@ per option and a comment box. hand or written by the tab, the card shows it as the current ruling and takes no new submit. - **`## Ruled`** is shown read-only, as written. +In a file that names its zone, a bare time converts, and a heading without a stamp is an id +and a question (the item bodies are left out here): + +```markdown +_Zone: ET_ + +## Open + +### web-6 · 9:40 pm · Should the export keep the old column names? + +### web-5 · Should the nightly report move to the morning? +``` + A worked example, a choice with bare words on the word line: ```markdown diff --git a/codev/resources/decision-protocol.md b/codev/resources/decision-protocol.md index 4981fb23f..c9fb262c7 100644 --- a/codev/resources/decision-protocol.md +++ b/codev/resources/decision-protocol.md @@ -56,17 +56,25 @@ per option and a comment box. heading, so inside an item use `####` or bold labels, never a higher heading. The file has exactly one `## Open`. - **The heading is id · stamp · question.** The first word is the id; ` · ` (space, middle dot, - space) separates the id, the stamp and the question. Open items that share an id are - read-only until the ids differ. -- **The stamp carries its zone; nothing assumes one.** `YYYY-MM-DD h:mm am` (or `pm`), or 24-hour - `YYYY-MM-DD HH:mm`, then the zone the time was read in. The tab converts the stamp using only - that zone and shows it in the viewer's local time. It reads the North-American generic zones - `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` (daylight saving follows the stamp's date), fixed - abbreviations such as `UTC`, `GMT`, `EST`/`EDT`, `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` - is North-American Central), offsets such as `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names - such as `Europe/Berlin`. Any other zone, including ambiguous abbreviations such as `IST`, - `BST` and `AST`, is shown as written and marked not converted, as is a time the clocks - skipped or repeated that day. + space) separates the id, the stamp and the question. A heading without a stamp is id · + question, and a first part the tab cannot read as a stamp stays in the question, shown as + written. Open items that share an id are read-only until the ids differ. +- **The stamp carries its zone; nothing assumes one.** Write the full form: `YYYY-MM-DD h:mm am` + (or `pm`), or 24-hour `YYYY-MM-DD HH:mm`, then the zone the time was read in. The tab converts + the stamp using only that zone and shows its date and time in the viewer's local time. It + reads the North-American generic zones `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` (daylight saving + follows the stamp's date), fixed abbreviations such as `UTC`, `GMT`, `EST`/`EDT`, + `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` is North-American Central), offsets such as + `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names such as `Europe/Berlin`. Any other zone, + including ambiguous abbreviations such as `IST`, `BST` and `AST`, is shown as written and + marked not converted, as is a time the clocks skipped or repeated that day. +- **A file may name its zone once.** A line `_Zone: _` above `## Open`, from the same + list of zones, is the zone of every stamp that names none. A bare time, `h:mm am` or `HH:mm`, + is read as today in that zone when the tab reads the file, and shown as the time alone: as + written, converted to your clock, with no date because none was written. Without a file zone, + a stamp that names no zone is shown as written. A bare time is always read as today, so one + written before midnight and read after it lands on the wrong day: the full form stays the one + to write. - **Exactly one `**Your word:**` line**, starting a line, outside a code block. The options are the backticked words on it, separated by ` / `. The first is the recommended one, and the tab marks it. A description follows a word after ` = ` and runs to the next ` / ` before a @@ -85,6 +93,19 @@ per option and a comment box. hand or written by the tab, the card shows it as the current ruling and takes no new submit. - **`## Ruled`** is shown read-only, as written. +In a file that names its zone, a bare time converts, and a heading without a stamp is an id +and a question (the item bodies are left out here): + +```markdown +_Zone: ET_ + +## Open + +### web-6 · 9:40 pm · Should the export keep the old column names? + +### web-5 · Should the nightly report move to the morning? +``` + A worked example, a choice with bare words on the word line: ```markdown diff --git a/codev/state/bugfix-1836_thread.md b/codev/state/bugfix-1836_thread.md new file mode 100644 index 000000000..2fbb8b1a3 --- /dev/null +++ b/codev/state/bugfix-1836_thread.md @@ -0,0 +1,36 @@ +# bugfix-1836 — Decisions tab: time-only or missing stamps flagged "not converted" + +## Investigate (2026-10-19) + +Reproduced with the tab's own parser (`parseDecisions`) on the heading shapes the issue names: + +- `### main-3 · 6:30 pm · Should we ship?` → stamp `{raw: '6:30 pm', instant: null, unconvertedReason: 'unrecognized-format'}` → the card shows "Not converted: the date or time is not in a recognized format". +- `### main-4 · Should we file the bug?` → the question becomes the stamp (`raw: 'Should we file the bug?'`), the question is `''`, and the card warns. +- `### main-6 · 2026-10-18 6:30 pm PT · Full stamp` → converts (unchanged). + +Root cause, three places: + +1. `servers/decisions/stamp.ts` — `STAMP_RE` requires a date and a zone; `resolveStamp` takes no context, so any stamp without both is `unrecognized-format`. Nothing reads a file-level zone. +2. `servers/decisions/parse.ts` `splitHeading` — the first ` · ` part is always the stamp, so a stampless heading's question is fed to `resolveStamp`. +3. `apps/web/src/components/DecisionsView.tsx` `Stamp` — any null instant renders the "Not converted" flag. + +Scope: well under 300 LOC (stamp.ts, parse.ts, the view, the type union, the doc in both trees, tests). + +Open question sent to the architect: what "unless a `## Ruled` line for it says otherwise" means for an open item's date. Real hub files (codev, shannon) carry no dated Ruled lines for open items. + +## Architect ruling (2026-10-19) + +Option (a): a time-only stamp is today in the file's zone at read time; the `## Ruled` clause is dropped. Refinement: a time-only stamp shows only the converted time, never a date; full stamps keep their date. The doc says a bare time means "as written, converted to your clock". + +## Fix (2026-10-19) + +- `stamp.ts`: date and zone are both optional in a stamp. `resolveStamp(raw, fileZone)` returns `null` for text that is not a stamp; a stamp with no zone and no file zone comes back unconverted with **no** reason; a time-only stamp gets today's date in its zone (so today's offset) and `timeOnly: true`. The zone token is now one word (`\S+`), so a question that starts with a time (`10:30 standup moved…`) is not read as a stamp with zone "standup moved…". +- `parse.ts`: the one `_Zone: _` line above the first `##` is the file zone (none, or more than one, gives none; a trailing period is tolerated). A first heading part that is not a stamp stays in the question, as written. +- Type: `unrecognized-format` removed from `unconvertedReason` (it can no longer reach the wire); `timeOnly?: true` added. +- View: a time-only instant renders with `STAMP_TIME_FORMAT` (time + viewer zone, no date); an unconverted stamp with no reason is plain text with no flag. The flag remains for unknown/ambiguous zones and skipped/repeated times. +- Doc (both trees, byte-identical): heading rule, full form still recommended, new "A file may name its zone once" bullet, and a file-zone example the doc test parses. +- Side effect worth knowing: the item template's `` placeholder is not a stamp, so it now reads as part of the template's question; the doc test says so. + +Tests: 25 new/changed server tests and 2 web tests fail on HEAD's source and pass with the fix. Full codev unit suite green (6511). Playwright Decisions suite 11/11 on a throwaway Tower, including two new cases (zoned file: `6:30 PM PDT`, no stamp keeps its question, full stamp dated, no "Not converted"; unzoned file: `6:30 pm` as written, only the IST card warns). Screenshots checked by eye. + +Not from this change: 74 web tests (Terminal*, VirtualKeyboard, architect-toolbar) fail locally on Node v26.3.1 because `localStorage` is undefined in jsdom there (Node's own experimental global shadows it); they do not touch the Decisions code. diff --git a/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts b/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts index c9f60e690..abccd2c21 100644 --- a/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts @@ -7,7 +7,7 @@ * checks are the standing "grep both trees" lesson, executed. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { parseDecisions } from '../servers/decisions/parse.js'; @@ -37,6 +37,7 @@ const fileSkeleton = block('## Ruled'); const template = block(''); const example = block('### web-4'); const qaPair = block('**You asked'); +const zoned = block('_Zone: '); /** The file skeleton with `item` posted at the top of `## Open`. */ const posted = (item: string) => fileSkeleton.replace('## Open\n', `## Open\n\n${item}\n`); @@ -51,7 +52,9 @@ describe('decision-protocol.md — its examples are what the tab parses', () => it('the item template reads its id, question and options; the first option is recommended', () => { const [item] = parseDecisions(posted(template)).items; expect(item.id).toBe('-'); - expect(item.question).toMatch(/^ · ', description: '', recommended: true }, { word: '', description: '', recommended: false }, @@ -137,9 +140,34 @@ describe('decision-protocol.md — its examples are what the tab parses', () => expect(onLine).toMatchObject({ currentRuling: '', writable: true }); }); - it('no time in the doc assumes a zone: every example time carries one, and none is written as PT-only', () => { + it('no time in the doc assumes a zone: every example time carries one or its file names one, and none is written as PT-only', () => { expect(doc).not.toMatch(/(?:am|pm) PT\b/); - for (const b of blocks) expect(b).not.toMatch(/\d{1,2}:\d{2} (?:am|pm)(?! [A-Z])/); + expect(doc).not.toMatch(/_Zone: PT_/); + for (const b of blocks) if (!b.includes('_Zone: ')) expect(b).not.toMatch(/\d{1,2}:\d{2} (?:am|pm)(?! [A-Z])/); + }); + + describe("the file-zone example: a bare time converts, a heading without a stamp keeps its question (#1836)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('reads the bare time as today in the file zone, with no reason on either heading', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + // Noon on 2026-01-15 in New York: standard time, UTC-5. + vi.setSystemTime(new Date('2026-01-15T17:00:00.000Z')); + const items = parseDecisions(zoned).items; + expect(items.map(i => [i.id, i.stamp, i.question])).toEqual([ + ['web-6', { raw: '9:40 pm', instant: '2026-01-16T02:40:00.000Z', timeOnly: true }, 'Should the export keep the old column names?'], + ['web-5', null, 'Should the nightly report move to the morning?'], + ]); + expect(resolveStamp('9:40 pm', 'ET')).toEqual(items[0].stamp); + expect(doc).toContain('`_Zone: _`'); + }); + + it('without the zone line, the bare time is shown as written with no reason', () => { + const [item] = parseDecisions(zoned.replace(/^_Zone: .*\n/m, '')).items; + expect(item.stamp).toEqual({ raw: '9:40 pm', instant: null, timeOnly: true }); + }); }); it('every zone the doc names converts, and the ambiguous abbreviations it names do not', () => { @@ -147,12 +175,12 @@ describe('decision-protocol.md — its examples are what the tab parses', () => '+05:30', 'UTC+5:30', 'GMT-3', 'Europe/Berlin']; for (const zone of zones) { expect(doc).toContain(`\`${zone}\``); - expect(resolveStamp(`2026-01-15 9:00 am ${zone}`).instant, zone).not.toBeNull(); + expect(resolveStamp(`2026-01-15 9:00 am ${zone}`)?.instant, zone).not.toBeNull(); } - expect(resolveStamp('2026-01-15 9:00 am CST').instant).toBe('2026-01-15T15:00:00.000Z'); + expect(resolveStamp('2026-01-15 9:00 am CST')?.instant).toBe('2026-01-15T15:00:00.000Z'); for (const zone of ['IST', 'BST', 'AST']) { expect(doc).toContain(`\`${zone}\``); - expect(resolveStamp(`2026-01-15 9:00 am ${zone}`).unconvertedReason, zone).toBe('unknown-zone'); + expect(resolveStamp(`2026-01-15 9:00 am ${zone}`)?.unconvertedReason, zone).toBe('unknown-zone'); } }); }); diff --git a/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts b/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts index a781fb78d..7e84636a8 100644 --- a/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts @@ -2,7 +2,7 @@ * Spec 1819 — decisions file parser and structural grammar. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { classifyLines } from '../servers/decisions/grammar.js'; @@ -242,10 +242,55 @@ describe('parseDecisions — headings', () => { expect(items.map(i => [i.id, i.stamp, i.question])).toEqual([['a-1', null, 'Plain question']]); }); - it('reads the part after a single separator as the stamp, shown unconverted when it is not one', () => { - const { items } = parseDecisions('## Open\n### a-2 · Only a question\n**Your word:** `y`\n'); - expect(items[0].stamp).toEqual({ raw: 'Only a question', instant: null, unconvertedReason: 'unrecognized-format' }); - expect(items[0].question).toBe(''); + it('reads a heading with no stamp as id and question: the question is not read as a stamp (#1836)', () => { + const { items } = parseDecisions('## Open\n### a-2 · Only a question\n**Your word:** `y`\n### a-3 · Is A · B ok?\n**Your word:** `y`\n'); + expect(items.map(i => [i.id, i.stamp, i.question])).toEqual([['a-2', null, 'Only a question'], ['a-3', null, 'Is A · B ok?']]); + }); + + it('keeps a first part that is not a stamp in the question, as written (#1836)', () => { + const { items } = parseDecisions('## Open\n### a-4 · yesterday 9 am PT · Ship it?\n**Your word:** `y`\n'); + expect(items[0]).toMatchObject({ stamp: null, question: 'yesterday 9 am PT · Ship it?' }); + }); + + describe("the file's `_Zone:` line (#1836)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const file = (header: string, heading: string): string => + `# Decisions\n\n${header}\n\n## Open\n\n### ${heading}\n\n**Your word:** \`y\`\n\n## Ruled\n`; + + it('converts a time-only stamp as today in the zone the header names', () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-19T18:00:00.000Z')); + const [item] = parseDecisions(file('_Status: 1 open._\n_Zone: PT_', 'main-3 · 6:30 pm · Ship it?')).items; + expect(item.stamp).toEqual({ raw: '6:30 pm', instant: '2026-10-20T01:30:00.000Z', timeOnly: true }); + expect(item.question).toBe('Ship it?'); + expect(parseDecisions(file('_Zone: America/Los_Angeles._', 'main-3 · 6:30 pm · Ship it?')).items[0].stamp?.instant) + .toBe('2026-10-20T01:30:00.000Z'); + }); + + it('shows a time-only stamp as written, with no reason, when the file names no zone', () => { + const [item] = parseDecisions(file('_Status: 1 open._', 'main-3 · 6:30 pm · Ship it?')).items; + expect(item.stamp).toEqual({ raw: '6:30 pm', instant: null, timeOnly: true }); + expect(item.question).toBe('Ship it?'); + }); + + it('reads the zone only from the header, and only when it is named once', () => { + const late = '# Decisions\n\n## Open\n\n_Zone: PT_\n\n### main-3 · 6:30 pm · Ship it?\n\n**Your word:** `y`\n'; + expect(parseDecisions(late).items[0].stamp?.instant).toBeNull(); + const fenced = file('```\n_Zone: PT_\n```', 'main-3 · 6:30 pm · Ship it?'); + expect(parseDecisions(fenced).items[0].stamp?.instant).toBeNull(); + const twice = file('_Zone: PT_\n_Zone: ET_', 'main-3 · 6:30 pm · Ship it?'); + expect(parseDecisions(twice).items[0].stamp?.instant).toBeNull(); + }); + + it("leaves a full stamp's own zone, and an ambiguous file zone's warning, in place", () => { + const full = parseDecisions(file('_Zone: PT_', 'main-3 · 2026-10-17 9:40 pm ET · Ship it?')).items[0]; + expect(full.stamp).toEqual({ raw: '2026-10-17 9:40 pm ET', instant: '2026-10-18T01:40:00.000Z' }); + const ist = parseDecisions(file('_Zone: IST_', 'main-3 · 6:30 pm · Ship it?')).items[0]; + expect(ist.stamp?.unconvertedReason).toBe('unknown-zone'); + }); }); it('accepts indented item headings and keeps level-4 headings in the body', () => { diff --git a/packages/codev/src/agent-farm/__tests__/decisions-stamp.test.ts b/packages/codev/src/agent-farm/__tests__/decisions-stamp.test.ts index 20078306c..0107a670d 100644 --- a/packages/codev/src/agent-farm/__tests__/decisions-stamp.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decisions-stamp.test.ts @@ -1,11 +1,12 @@ /** * Spec 1819 — decision heading timestamps. Nothing may assume PT. + * Issue #1836 — the file's zone, time-only stamps, and text that is not a stamp. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { resolveStamp } from '../servers/decisions/stamp.js'; -const instant = (raw: string): string | null => resolveStamp(raw).instant; +const instant = (raw: string): string | null => resolveStamp(raw)?.instant ?? null; describe('resolveStamp — generic zones follow DST for the stamp\'s date', () => { it('resolves PT to PDT in summer and PST in winter', () => { @@ -61,8 +62,8 @@ describe('resolveStamp — numeric offsets and IANA names', () => { }); it('rejects out-of-range offsets', () => { - expect(resolveStamp('2026-10-17 12:10 pm +15:00').unconvertedReason).toBe('unknown-zone'); - expect(resolveStamp('2026-10-17 12:10 pm +05:75').unconvertedReason).toBe('unknown-zone'); + expect(resolveStamp('2026-10-17 12:10 pm +15:00')?.unconvertedReason).toBe('unknown-zone'); + expect(resolveStamp('2026-10-17 12:10 pm +05:75')?.unconvertedReason).toBe('unknown-zone'); }); it('resolves IANA names with their DST', () => { @@ -93,7 +94,6 @@ describe('resolveStamp — unconverted, never guessed', () => { }); it.each([ - '2026-10-17 12:10 am', '2026-10-17', 'yesterday 9 am PT', '2026-10-17 13:10 pm UTC', @@ -101,16 +101,23 @@ describe('resolveStamp — unconverted, never guessed', () => { '2026-10-17 24:00 UTC', '2026-10-17 9:60 UTC', '2026-02-30 9:00 am UTC', - ])('unrecognized format: %s', raw => { - expect(resolveStamp(raw)).toEqual({ raw, instant: null, unconvertedReason: 'unrecognized-format' }); + '10:30 standup moved to the afternoon', + 'Should we ship the release?', + ])('not a stamp: %s', raw => { + expect(resolveStamp(raw)).toBeNull(); + expect(resolveStamp(raw, 'PT')).toBeNull(); + }); + + it('shows a stamp that names no zone, in a file that names none, as written with no reason', () => { + expect(resolveStamp('2026-10-17 12:10 am')).toEqual({ raw: '2026-10-17 12:10 am', instant: null }); }); it('leaves a spring-forward gap unconverted', () => { - expect(resolveStamp('2026-03-08 2:30 am PT').unconvertedReason).toBe('nonexistent-time'); + expect(resolveStamp('2026-03-08 2:30 am PT')?.unconvertedReason).toBe('nonexistent-time'); }); it('leaves a fall-back fold unconverted', () => { - expect(resolveStamp('2026-11-01 1:30 am PT').unconvertedReason).toBe('ambiguous-time'); + expect(resolveStamp('2026-11-01 1:30 am PT')?.unconvertedReason).toBe('ambiguous-time'); }); it('converts the instants either side of a transition', () => { @@ -128,6 +135,67 @@ describe('resolveStamp — unconverted, never guessed', () => { }); }); +describe("resolveStamp — the file's zone and time-only stamps (Issue #1836)", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + /** Read as at `now`. */ + const at = (now: string): void => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(now)); + }; + + it("converts a time-only stamp as today in the file's zone, with today's offset", () => { + at('2026-10-19T18:00:00.000Z'); + expect(resolveStamp('6:30 pm', 'PT')).toEqual({ raw: '6:30 pm', instant: '2026-10-20T01:30:00.000Z', timeOnly: true }); + at('2026-01-15T18:00:00.000Z'); + expect(resolveStamp('6:30 pm', 'PT')).toEqual({ raw: '6:30 pm', instant: '2026-01-16T02:30:00.000Z', timeOnly: true }); + }); + + it("takes today in the zone, not in UTC", () => { + // 8 pm PDT on Oct 19 is already Oct 20 in UTC. + at('2026-10-20T03:00:00.000Z'); + expect(resolveStamp('6:30 pm', 'PT')?.instant).toBe('2026-10-20T01:30:00.000Z'); + expect(resolveStamp('18:30', 'UTC')?.instant).toBe('2026-10-20T18:30:00.000Z'); + expect(resolveStamp('9:00 am', 'Asia/Tokyo')?.instant).toBe('2026-10-20T00:00:00.000Z'); + }); + + it('shows a time-only stamp with no zone known as written, with no reason', () => { + at('2026-10-19T18:00:00.000Z'); + expect(resolveStamp('6:30 pm')).toEqual({ raw: '6:30 pm', instant: null, timeOnly: true }); + }); + + it('converts a time-only stamp in the zone it names', () => { + at('2026-10-19T18:00:00.000Z'); + expect(resolveStamp('6:30 pm ET', 'PT')).toEqual({ raw: '6:30 pm ET', instant: '2026-10-19T22:30:00.000Z', timeOnly: true }); + }); + + it("converts a dated stamp that names no zone in the file's zone", () => { + expect(resolveStamp('2026-10-17 9:40 pm', 'ET')).toEqual({ raw: '2026-10-17 9:40 pm', instant: '2026-10-18T01:40:00.000Z' }); + }); + + it("leaves a full stamp's own zone in charge", () => { + expect(resolveStamp('2026-07-01 3:05 pm ET', 'PT')).toEqual(resolveStamp('2026-07-01 3:05 pm ET')); + expect(instant('2026-07-01 3:05 pm ET')).toBe('2026-07-01T19:05:00.000Z'); + }); + + it('still warns for an ambiguous zone, in the stamp or the file', () => { + at('2026-10-19T18:00:00.000Z'); + for (const zone of ['IST', 'BST', 'AST']) { + expect(resolveStamp('6:30 pm', zone), zone).toEqual({ raw: '6:30 pm', instant: null, timeOnly: true, unconvertedReason: 'unknown-zone' }); + expect(resolveStamp(`2026-10-17 6:30 pm ${zone}`, 'PT')?.unconvertedReason, zone).toBe('unknown-zone'); + } + }); + + it("still warns for a time today's clocks skip or repeat", () => { + at('2026-03-08T20:00:00.000Z'); + expect(resolveStamp('2:30 am', 'PT')?.unconvertedReason).toBe('nonexistent-time'); + at('2026-11-01T20:00:00.000Z'); + expect(resolveStamp('1:30 am', 'PT')?.unconvertedReason).toBe('ambiguous-time'); + }); +}); + describe('resolveStamp — independent of the machine time zone', () => { const originalTz = process.env.TZ; afterEach(() => { diff --git a/packages/codev/src/agent-farm/__tests__/e2e/decisions-tab.test.ts b/packages/codev/src/agent-farm/__tests__/e2e/decisions-tab.test.ts index 4f00084aa..30672a1d9 100644 --- a/packages/codev/src/agent-farm/__tests__/e2e/decisions-tab.test.ts +++ b/packages/codev/src/agent-farm/__tests__/e2e/decisions-tab.test.ts @@ -360,6 +360,58 @@ test.describe('Spec 1819: Decisions tab', () => { }); }); +test.describe('Issue #1836: time-only and missing stamps carry no warning', () => { + const STAMPS = `# Pending owner decisions — all architects (hub: main) + +_Status: 3 open — main-14, main-13, main-12. Written 6:40 pm._ +_Zone: PT_ + +## Open + +### main-14 · 6:30 pm · Ship the release tonight? + +**Your word:** \`ship\` / \`hold\` + +### main-13 · File the flaky test as a bug? + +**Your word:** \`file\` / \`hold\` + +### main-12 · 2026-10-17 9:38 pm PT · ${QUESTION.main} + +**Your word:** \`accept\` / \`hold\` + +## Ruled +`; + + test("a file zone converts a bare time to a time alone; a heading without a stamp keeps its question", async ({ page }) => { + const ws = makeWorkspace({ [DEFAULT_FILE]: STAMPS }); + await openDecisions(page, ws); + + const timeOnly = card(page, 'Ship the release tonight?').locator('time.decisions-stamp'); + await expect(timeOnly).toHaveAttribute('title', '6:30 pm'); + await expect(timeOnly).toHaveText(/\d:\d{2}/); + await expect(timeOnly).not.toHaveText(/2026|Oct/); + await expect(card(page, 'File the flaky test as a bug?').locator('.decisions-stamp')).toHaveCount(0); + await expect(card(page, QUESTION.main).locator('time.decisions-stamp')).toHaveText(/2026/); + await expect(page.locator('.decisions-view')).not.toContainText('Not converted'); + await screenshot(page, 'decisions-stamps-zoned'); + }); + + test('without a file zone a bare time is shown as written; only an ambiguous zone still warns', async ({ page }) => { + const ist = '### main-11 · 2026-10-17 9:30 pm IST · Keep the old export?\n\n**Your word:** `keep` / `hold`\n\n## Ruled\n'; + const ws = makeWorkspace({ [DEFAULT_FILE]: STAMPS.replace('_Zone: PT_\n', '').replace('## Ruled\n', ist) }); + await openDecisions(page, ws); + + const timeOnly = card(page, 'Ship the release tonight?').locator('.decisions-stamp'); + await expect(timeOnly).toHaveText('6:30 pm'); + await expect(card(page, 'Ship the release tonight?')).not.toContainText('Not converted'); + await expect(page.locator('.decisions-stamp-flag')).toHaveCount(1); + await expect(card(page, 'Keep the old export?').locator('.decisions-stamp-flag')) + .toHaveText('Not converted: the time zone is not one the tab recognizes'); + await screenshot(page, 'decisions-stamps-unzoned'); + }); +}); + test.describe('Spec 1819: Decisions tab at phone width', () => { test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); diff --git a/packages/codev/src/agent-farm/servers/decisions/parse.ts b/packages/codev/src/agent-farm/servers/decisions/parse.ts index b162a5c12..fd50c000a 100644 --- a/packages/codev/src/agent-farm/servers/decisions/parse.ts +++ b/packages/codev/src/agent-farm/servers/decisions/parse.ts @@ -5,7 +5,10 @@ // from `grammar.ts`; this module adds the format's conventions: // - `## Open` / `## Ruled` sections; items are `###` headings inside `## Open`, // running to the next heading of level 1–3 (or end of file). -// - item id = the heading's first token; ` · ` separates id, stamp and question. +// - item id = the heading's first token; ` · ` separates id, stamp and question. A first +// part that is not a stamp belongs to the question. +// - a `_Zone: _` line in the header (above the first `##`) is the zone of every +// stamp that names none. // - options = backticked words on the `**Your word:**` line, first recommended; an // option with no `= …` there takes its description from the body's `**Options:**`. // - current ruling = everything after the `**Your word:**` line, verbatim. @@ -19,6 +22,7 @@ import type { DecisionItemProblem, DecisionOption, DecisionQaEntry, + DecisionStamp, DecisionsDocument, DecisionsFileProblem, } from '@cluesmith/codev-types'; @@ -49,6 +53,7 @@ export interface ParsedDecisions { } const SEPARATOR = ' · '; +const ZONE_LINE_RE = /^ {0,3}_Zone:[ \t]*(\S+?)\.?_[ \t]*$/; const ASKED_RE = /^ {0,3}- \*\*You asked \(([^)]*)\):\*\*[ \t]*(.*)$/; const ANSWER_RE = /^ {0,3}- \*\*(.+?)['’]s answer \(([^)]*)\):\*\*[ \t]*(.*)$/; const CONTINUATION_INDENT_RE = /^(?: {2}|\t)/; @@ -76,16 +81,33 @@ function joinTrimmed(lines: SourceLine[]): string { } /** - * Split a heading's text into id, stamp text and question: the first token is the id; - * after a ` · `, the first part is the stamp and the rest (re-joined) the question. A - * heading without separators has a question and no stamp. + * The file's zone: the one `_Zone: _` line above the first `##` heading. None, or + * more than one, gives no file zone. */ -function splitHeading(headingText: string): { id: string; stampText: string | null; question: string } { +function fileZone(lines: SourceLine[]): string | null { + const zones: string[] = []; + for (const line of lines) { + if (line.headingLevel >= 2) break; + const zone = line.fenced ? null : ZONE_LINE_RE.exec(line.text); + if (zone) zones.push(zone[1]); + } + return zones.length === 1 ? zones[0] : null; +} + +/** + * Split a heading's text into id, stamp and question: the first token is the id; after a + * ` · `, the first part is the stamp and the rest (re-joined) the question. A heading + * without separators, or whose first part is not a stamp, has a question and no stamp. + */ +function splitHeading(headingText: string, zone: string | null): { id: string; stamp: DecisionStamp | null; question: string } { const id = headingText.split(/\s+/)[0] ?? ''; const rest = headingText.slice(id.length); - if (!rest.startsWith(SEPARATOR)) return { id, stampText: null, question: rest.trim() }; - const [stampText, ...questionParts] = rest.slice(SEPARATOR.length).split(SEPARATOR); - return { id, stampText: stampText.trim(), question: questionParts.join(SEPARATOR).trim() }; + if (!rest.startsWith(SEPARATOR)) return { id, stamp: null, question: rest.trim() }; + const afterId = rest.slice(SEPARATOR.length); + const [stampText, ...questionParts] = afterId.split(SEPARATOR); + const stamp = resolveStamp(stampText.trim(), zone); + if (!stamp) return { id, stamp: null, question: afterId.trim() }; + return { id, stamp, question: questionParts.join(SEPARATOR).trim() }; } /** @@ -236,6 +258,7 @@ export function parseDecisions(text: string): ParsedDecisions { if (ruledHeadings.length > 1) problems.push('ruled-multiple'); const offsetOf = (lineIndex: number): number => (lineIndex < lines.length ? lines[lineIndex].start : text.length); + const zone = fileZone(lines); const items: ParsedItem[] = []; for (const open of openHeadings) { @@ -249,7 +272,7 @@ export function parseDecisions(text: string): ParsedDecisions { break; } } - items.push(parseItem(text, lines, i, endLine, offsetOf)); + items.push(parseItem(text, lines, i, endLine, offsetOf, zone)); i = endLine - 1; } } @@ -274,9 +297,10 @@ function parseItem( headingLine: number, endLine: number, offsetOf: (lineIndex: number) => number, + zone: string | null, ): ParsedItem { const heading = lines[headingLine]; - const { id, stampText, question } = splitHeading(heading.headingText); + const { id, stamp, question } = splitHeading(heading.headingText, zone); const start = heading.start; const end = offsetOf(endLine); const itemLines = lines.slice(headingLine + 1, endLine); @@ -311,7 +335,7 @@ function parseItem( return { id, - stamp: stampText === null ? null : resolveStamp(stampText), + stamp, question, bodyMarkdown: joinTrimmed(rest), options, diff --git a/packages/codev/src/agent-farm/servers/decisions/stamp.ts b/packages/codev/src/agent-farm/servers/decisions/stamp.ts index 622e88881..79503c8d2 100644 --- a/packages/codev/src/agent-farm/servers/decisions/stamp.ts +++ b/packages/codev/src/agent-farm/servers/decisions/stamp.ts @@ -1,9 +1,11 @@ -// Decision heading timestamps (Spec 1819). +// Decision heading timestamps (Spec 1819, Issue #1836). // -// A stamp is `YYYY-MM-DD h:mm am|pm ` or `YYYY-MM-DD HH:mm `. It is -// resolved to an absolute instant using ONLY the zone written in it — nothing here -// defaults to, or special-cases, any zone. Anything not in the accepted forms is -// returned unconverted with a reason, so the tab shows it verbatim. +// A stamp is `[YYYY-MM-DD] h:mm am|pm [ZONE]` or `[YYYY-MM-DD] HH:mm [ZONE]`. It is +// resolved to an absolute instant using the zone written in it or, failing that, the +// file's `_Zone: …_` line — nothing here defaults to, or special-cases, any zone. A +// stamp without a date is today in its zone. A stamp with no zone at all is returned +// unconverted with no reason, so the tab shows it as written; text in none of these +// forms is not a stamp at all (null). // // IANA and generic zones are resolved with the runtime's built-in `Intl` time-zone // data by offset probing: the wall-clock time is tried against every offset the @@ -60,7 +62,7 @@ const FIXED_ZONES: Record = { NZDT: 13 * 60, }; -const STAMP_RE = /^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})(?:\s*(am|pm))?\s+(\S.*?)\s*$/i; +const STAMP_RE = /^(?:(\d{4})-(\d{2})-(\d{2})\s+)?(\d{1,2}):(\d{2})(?:\s*(am|pm))?(?:\s+(\S+))?$/i; const OFFSET_RE = /^(?:UTC|GMT)?([+-])(\d{1,2})(?::?(\d{2}))?$/; const MINUTE_MS = 60_000; @@ -134,44 +136,60 @@ function instantsForWallClock(wallMs: number, zone: string): number[] { return [...instants].sort((a, b) => a - b); } -/** Resolve a heading stamp to an instant using only the zone written in it. */ -export function resolveStamp(raw: string): DecisionStamp { +/** The wall-clock time in `zone` at the given instant, as if UTC. */ +function wallClockAt(instantMs: number, zone: Zone): number { + const offsetMinutes = zone.kind === 'fixed' ? zone.offsetMinutes : offsetAt(instantMs, zone.name); + return instantMs + offsetMinutes * MINUTE_MS; +} + +/** + * Resolve a heading stamp to an instant, using the zone written in it or else `fileZone`. + * A stamp without a date takes today's date in that zone. Returns null for text that is not + * a stamp. + */ +export function resolveStamp(raw: string, fileZone: string | null = null): DecisionStamp | null { const m = STAMP_RE.exec(raw.trim()); - if (!m) return { raw, instant: null, unconvertedReason: 'unrecognized-format' }; + if (!m) return null; const [, y, mo, d, h, mi, meridiem, zoneText] = m; - // `12:10 am` with no zone backtracks to read `am` as the zone; it has none. - if (/^(am|pm)$/i.test(zoneText)) return { raw, instant: null, unconvertedReason: 'unrecognized-format' }; - const year = Number(y); - const month = Number(mo); - const day = Number(d); let hour = Number(h); const minute = Number(mi); if (meridiem) { - if (hour < 1 || hour > 12) return { raw, instant: null, unconvertedReason: 'unrecognized-format' }; + if (hour < 1 || hour > 12) return null; const pm = meridiem.toLowerCase() === 'pm'; hour = (hour % 12) + (pm ? 12 : 0); } else if (hour > 23) { - return { raw, instant: null, unconvertedReason: 'unrecognized-format' }; + return null; } - if (minute > 59) return { raw, instant: null, unconvertedReason: 'unrecognized-format' }; - - const wallMs = Date.UTC(year, month - 1, day, hour, minute); - const check = new Date(wallMs); - if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) { - return { raw, instant: null, unconvertedReason: 'unrecognized-format' }; + if (minute > 59) return null; + + const timeOnly = y === undefined; + let dateMs = 0; + if (!timeOnly) { + const year = Number(y); + const month = Number(mo); + const day = Number(d); + dateMs = Date.UTC(year, month - 1, day); + const check = new Date(dateMs); + if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return null; } - const zone = parseZone(zoneText); - if (!zone) return { raw, instant: null, unconvertedReason: 'unknown-zone' }; + const stamp: DecisionStamp = timeOnly ? { raw, instant: null, timeOnly: true } : { raw, instant: null }; + const zoneName = zoneText ?? fileZone; + if (!zoneName) return stamp; + const zone = parseZone(zoneName); + if (!zone) return { ...stamp, unconvertedReason: 'unknown-zone' }; + + if (timeOnly) dateMs = Math.floor(wallClockAt(Date.now(), zone) / DAY_MS) * DAY_MS; + const wallMs = dateMs + (hour * 60 + minute) * MINUTE_MS; if (zone.kind === 'fixed') { - return { raw, instant: new Date(wallMs - zone.offsetMinutes * MINUTE_MS).toISOString() }; + return { ...stamp, instant: new Date(wallMs - zone.offsetMinutes * MINUTE_MS).toISOString() }; } const instants = instantsForWallClock(wallMs, zone.name); - if (instants.length === 0) return { raw, instant: null, unconvertedReason: 'nonexistent-time' }; - if (instants.length > 1) return { raw, instant: null, unconvertedReason: 'ambiguous-time' }; - return { raw, instant: new Date(instants[0]).toISOString() }; + if (instants.length === 0) return { ...stamp, unconvertedReason: 'nonexistent-time' }; + if (instants.length > 1) return { ...stamp, unconvertedReason: 'ambiguous-time' }; + return { ...stamp, instant: new Date(instants[0]).toISOString() }; } diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 22096eafe..04ad9fd85 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -599,14 +599,23 @@ export interface TeamApiResponse { // --- Decisions (GET/POST /workspace/:path/api/decisions…, Spec 1819) --- -/** A heading timestamp, resolved to an instant only from the zone written in it. */ +/** + * A heading timestamp, resolved to an instant only from the zone written in it or, failing + * that, the file's `_Zone: …_` line. + */ export interface DecisionStamp { /** The stamp exactly as written in the heading. */ raw: string; /** ISO-8601 UTC instant, or null when the stamp could not be converted. */ instant: string | null; - /** Why `instant` is null. */ - unconvertedReason?: 'unrecognized-format' | 'unknown-zone' | 'nonexistent-time' | 'ambiguous-time'; + /** The stamp gives a time but no date (it was read as today): show the time alone. */ + timeOnly?: true; + /** + * Why `instant` is null, when the stamp names a zone the tab cannot use or a time the + * clocks skipped or repeated. Absent with a null instant: no zone is known, so the stamp + * is shown as written. + */ + unconvertedReason?: 'unknown-zone' | 'nonexistent-time' | 'ambiguous-time'; } export interface DecisionOption { From ed499441522b7354ebe7b0197a368251887e3203 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 08:49:23 -0700 Subject: [PATCH 04/11] chore(porch): bugfix-1836 pr phase-transition --- .../bugfix-1836-decisions-tab-a-time-only-stam/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml index 3407385dd..170f6182e 100644 --- a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml +++ b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml @@ -1,7 +1,7 @@ id: bugfix-1836 title: decisions-tab-a-time-only-stam protocol: bugfix -phase: fix +phase: pr plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-10-19T15:34:35.563Z' -updated_at: '2026-10-19T15:39:35.502Z' +updated_at: '2026-10-19T15:49:23.345Z' From 60d6dba5cd49c9139dfcf416bda53696079456d5 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 08:54:18 -0700 Subject: [PATCH 05/11] [Bugfix #1836] Keep a bare time followed by a word in the question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `### main-4 · 10:30 standup?` read `standup?` as an unknown zone, so the card warned and lost its question. A time-only stamp whose written zone the tab cannot resolve now warns only when the word is shaped like a zone (an abbreviation, an Area/Location name, an offset); otherwise it is not a stamp. Co-Authored-By: Claude Opus 5.5 --- codev/state/bugfix-1836_thread.md | 6 ++++++ .../src/agent-farm/__tests__/decisions-parse.test.ts | 4 ++-- .../src/agent-farm/__tests__/decisions-stamp.test.ts | 9 +++++++++ packages/codev/src/agent-farm/servers/decisions/stamp.ts | 8 +++++++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/codev/state/bugfix-1836_thread.md b/codev/state/bugfix-1836_thread.md index 2fbb8b1a3..9c3fdf4e2 100644 --- a/codev/state/bugfix-1836_thread.md +++ b/codev/state/bugfix-1836_thread.md @@ -34,3 +34,9 @@ Option (a): a time-only stamp is today in the file's zone at read time; the `## Tests: 25 new/changed server tests and 2 web tests fail on HEAD's source and pass with the fix. Full codev unit suite green (6511). Playwright Decisions suite 11/11 on a throwaway Tower, including two new cases (zoned file: `6:30 PM PDT`, no stamp keeps its question, full stamp dated, no "Not converted"; unzoned file: `6:30 pm` as written, only the IST card warns). Screenshots checked by eye. Not from this change: 74 web tests (Terminal*, VirtualKeyboard, architect-toolbar) fail locally on Node v26.3.1 because `localStorage` is undefined in jsdom there (Node's own experimental global shadows it); they do not touch the Decisions code. + +## PR #1837, CMAP round 1 (2026-10-19) + +- gemini APPROVE. +- codex and claude REQUEST_CHANGES, same finding: `### main-4 · 10:30 standup?` read `standup?` as an unknown zone, so the card warned and the question came out empty. Fixed: when a time-only stamp names a zone the tab cannot resolve, it warns only if the word is shaped like a zone (`ZONE_LIKE_RE`: an all-caps abbreviation, an `Area/Location` name, or an offset); otherwise the text is not a stamp and stays in the question. Dated stamps keep warning for any unknown zone. Tests: `10:30 standup?` and `6:30 pm tonight` are not stamps; `IST`, `XYZ`, `Not/AZone`, `+15:00` after a bare time still warn. +- Claude's non-blocking notes, left for the architect: an unparseable `_Zone:` line (`_Zone: Pacific Time_`) is silently no zone, and `_Zone: XYZ_` warns on each card with nothing pointing at the header line (a `DecisionsFileProblem` would be the home; follow-up); an as-written stamp renders in the normal stamp font, not monospace. diff --git a/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts b/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts index 7e84636a8..2cd5e11e7 100644 --- a/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts @@ -248,8 +248,8 @@ describe('parseDecisions — headings', () => { }); it('keeps a first part that is not a stamp in the question, as written (#1836)', () => { - const { items } = parseDecisions('## Open\n### a-4 · yesterday 9 am PT · Ship it?\n**Your word:** `y`\n'); - expect(items[0]).toMatchObject({ stamp: null, question: 'yesterday 9 am PT · Ship it?' }); + const { items } = parseDecisions('## Open\n### a-4 · yesterday 9 am PT · Ship it?\n**Your word:** `y`\n### a-5 · 10:30 standup?\n**Your word:** `y`\n'); + expect(items.map(i => [i.stamp, i.question])).toEqual([[null, 'yesterday 9 am PT · Ship it?'], [null, '10:30 standup?']]); }); describe("the file's `_Zone:` line (#1836)", () => { diff --git a/packages/codev/src/agent-farm/__tests__/decisions-stamp.test.ts b/packages/codev/src/agent-farm/__tests__/decisions-stamp.test.ts index 0107a670d..44b08f598 100644 --- a/packages/codev/src/agent-farm/__tests__/decisions-stamp.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decisions-stamp.test.ts @@ -102,6 +102,8 @@ describe('resolveStamp — unconverted, never guessed', () => { '2026-10-17 9:60 UTC', '2026-02-30 9:00 am UTC', '10:30 standup moved to the afternoon', + '10:30 standup?', + '6:30 pm tonight', 'Should we ship the release?', ])('not a stamp: %s', raw => { expect(resolveStamp(raw)).toBeNull(); @@ -188,6 +190,13 @@ describe("resolveStamp — the file's zone and time-only stamps (Issue #1836)", } }); + it('warns for a time-only stamp whose written zone is shaped like one, known or not', () => { + at('2026-10-19T18:00:00.000Z'); + for (const zone of ['IST', 'XYZ', 'Not/AZone', '+15:00']) { + expect(resolveStamp(`6:30 pm ${zone}`, 'PT')?.unconvertedReason, zone).toBe('unknown-zone'); + } + }); + it("still warns for a time today's clocks skip or repeat", () => { at('2026-03-08T20:00:00.000Z'); expect(resolveStamp('2:30 am', 'PT')?.unconvertedReason).toBe('nonexistent-time'); diff --git a/packages/codev/src/agent-farm/servers/decisions/stamp.ts b/packages/codev/src/agent-farm/servers/decisions/stamp.ts index 79503c8d2..21616fe6a 100644 --- a/packages/codev/src/agent-farm/servers/decisions/stamp.ts +++ b/packages/codev/src/agent-farm/servers/decisions/stamp.ts @@ -64,6 +64,8 @@ const FIXED_ZONES: Record = { const STAMP_RE = /^(?:(\d{4})-(\d{2})-(\d{2})\s+)?(\d{1,2}):(\d{2})(?:\s*(am|pm))?(?:\s+(\S+))?$/i; const OFFSET_RE = /^(?:UTC|GMT)?([+-])(\d{1,2})(?::?(\d{2}))?$/; +/** Shaped like a zone (an abbreviation, an Area/Location name, an offset), known or not. */ +const ZONE_LIKE_RE = /^(?:[A-Z]{2,5}|[A-Z][A-Za-z_]*(?:\/[A-Za-z0-9_+-]+)+|(?:UTC|GMT)?[+-]\d{1,2}(?::?\d{2})?)$/; const MINUTE_MS = 60_000; const DAY_MS = 24 * 60 * MINUTE_MS; @@ -179,7 +181,11 @@ export function resolveStamp(raw: string, fileZone: string | null = null): Decis const zoneName = zoneText ?? fileZone; if (!zoneName) return stamp; const zone = parseZone(zoneName); - if (!zone) return { ...stamp, unconvertedReason: 'unknown-zone' }; + if (!zone) { + // A bare time and a word that is no zone (`10:30 standup?`) is a question, not a stamp. + if (timeOnly && zoneText !== undefined && !ZONE_LIKE_RE.test(zoneText)) return null; + return { ...stamp, unconvertedReason: 'unknown-zone' }; + } if (timeOnly) dateMs = Math.floor(wallClockAt(Date.now(), zone) / DAY_MS) * DAY_MS; const wallMs = dateMs + (hour * 60 + minute) * MINUTE_MS; From 62c4af874c893ba2563e6e54089d716e52f54ae1 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 08:59:01 -0700 Subject: [PATCH 06/11] chore(porch): bugfix-1836 pr gate-requested --- .../bugfix-1836-decisions-tab-a-time-only-stam/status.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml index 170f6182e..18dce31eb 100644 --- a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml +++ b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml @@ -7,8 +7,10 @@ current_plan_phase: null gates: pr: status: pending + requested_at: '2026-10-19T15:59:01.219Z' iteration: 1 build_complete: false history: [] started_at: '2026-10-19T15:34:35.563Z' -updated_at: '2026-10-19T15:49:23.345Z' +updated_at: '2026-10-19T15:59:01.220Z' +pr_ready_for_human: true From b3bf1dd29ab20ed6298609eeb57487a6130b6897 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 08:59:09 -0700 Subject: [PATCH 07/11] chore(porch): bugfix-1836 record PR #1837 --- .../bugfix-1836-decisions-tab-a-time-only-stam/status.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml index 18dce31eb..00f835e73 100644 --- a/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml +++ b/codev/projects/bugfix-1836-decisions-tab-a-time-only-stam/status.yaml @@ -12,5 +12,10 @@ iteration: 1 build_complete: false history: [] started_at: '2026-10-19T15:34:35.563Z' -updated_at: '2026-10-19T15:59:01.220Z' +updated_at: '2026-10-19T15:59:09.960Z' pr_ready_for_human: true +pr_history: + - phase: pr + pr_number: 1837 + branch: builder/bugfix-1836 + created_at: '2026-10-19T15:59:09.959Z' From 7d8d8ad532b0c343e5b1b9b579b7b19dbc87c465 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 09:05:38 -0700 Subject: [PATCH 08/11] [Bugfix #1836] Put the _Zone: line in the file template; parse.ts reads the clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decision-protocol.md (both trees): the §4 file template carries `_Zone: _` with a note to replace it, and §3 step 1 says the hub keeps that line naming the zone its clock reads, so a hub following the doc writes one. The doc test checks the template line and that, once filled in, a bare-time item converts. - parse.ts header: the parse is no longer a pure function of the text (bare times are read as today). `version` hashes the text only, so a client keeps the instants it has until the file changes; splice's verify() is safe only because it compares no stamps. Follow-up: #1838. Co-Authored-By: Claude Opus 5.5 --- codev-skeleton/resources/decision-protocol.md | 10 +++++++--- codev/resources/decision-protocol.md | 10 +++++++--- codev/state/bugfix-1836_thread.md | 7 +++++++ .../__tests__/decision-protocol-doc.test.ts | 16 ++++++++++++++-- .../src/agent-farm/servers/decisions/parse.ts | 12 +++++++++--- 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/codev-skeleton/resources/decision-protocol.md b/codev-skeleton/resources/decision-protocol.md index c9fb262c7..23a165c5c 100644 --- a/codev-skeleton/resources/decision-protocol.md +++ b/codev-skeleton/resources/decision-protocol.md @@ -124,7 +124,9 @@ A worked example, a choice with bare words on the word line: 1. **Post.** The hub puts the item at the top of `## Open`, updates the status line (`N open — `), and tells the owner in one chat line: - `New decision from : — `, plus `[urgent]` if marked. + `New decision from : — `, plus `[urgent]` if marked. An item whose + stamp is a bare time is read in the zone on the file's `_Zone: _` line, so the hub + keeps that line naming the zone its clock reads (section 4). 2. **Learn of rulings.** The owner rules in the tab, by typing in the file, or in chat. A ruling made in the tab reaches the hub as a message from Tower: `RULED : `, or `QUESTION :` when no word was chosen, with the comment on the lines after it. The notice @@ -195,11 +197,13 @@ Rule inline: one word on the line under an item's **Your word:** line, or in the Newest first. Only main edits this file; architects send items with `afx send architect:main 'DECISION …'`. _Status: 0 open._ +_Zone: _ ## Open ## Ruled ``` -Newest first in both sections. `## Open` holds only what still needs a word; `## Ruled` keeps -one line per item. +Replace `` with the zone the hub's clock reads (`PT`, `ET`, `Europe/Berlin`, …): it is the +zone of every stamp that names none, bare times included. Newest first in both sections. +`## Open` holds only what still needs a word; `## Ruled` keeps one line per item. diff --git a/codev/resources/decision-protocol.md b/codev/resources/decision-protocol.md index c9fb262c7..23a165c5c 100644 --- a/codev/resources/decision-protocol.md +++ b/codev/resources/decision-protocol.md @@ -124,7 +124,9 @@ A worked example, a choice with bare words on the word line: 1. **Post.** The hub puts the item at the top of `## Open`, updates the status line (`N open — `), and tells the owner in one chat line: - `New decision from : — `, plus `[urgent]` if marked. + `New decision from : — `, plus `[urgent]` if marked. An item whose + stamp is a bare time is read in the zone on the file's `_Zone: _` line, so the hub + keeps that line naming the zone its clock reads (section 4). 2. **Learn of rulings.** The owner rules in the tab, by typing in the file, or in chat. A ruling made in the tab reaches the hub as a message from Tower: `RULED : `, or `QUESTION :` when no word was chosen, with the comment on the lines after it. The notice @@ -195,11 +197,13 @@ Rule inline: one word on the line under an item's **Your word:** line, or in the Newest first. Only main edits this file; architects send items with `afx send architect:main 'DECISION …'`. _Status: 0 open._ +_Zone: _ ## Open ## Ruled ``` -Newest first in both sections. `## Open` holds only what still needs a word; `## Ruled` keeps -one line per item. +Replace `` with the zone the hub's clock reads (`PT`, `ET`, `Europe/Berlin`, …): it is the +zone of every stamp that names none, bare times included. Newest first in both sections. +`## Open` holds only what still needs a word; `## Ruled` keeps one line per item. diff --git a/codev/state/bugfix-1836_thread.md b/codev/state/bugfix-1836_thread.md index 9c3fdf4e2..c65c3d359 100644 --- a/codev/state/bugfix-1836_thread.md +++ b/codev/state/bugfix-1836_thread.md @@ -40,3 +40,10 @@ Not from this change: 74 web tests (Terminal*, VirtualKeyboard, architect-toolba - gemini APPROVE. - codex and claude REQUEST_CHANGES, same finding: `### main-4 · 10:30 standup?` read `standup?` as an unknown zone, so the card warned and the question came out empty. Fixed: when a time-only stamp names a zone the tab cannot resolve, it warns only if the word is shaped like a zone (`ZONE_LIKE_RE`: an all-caps abbreviation, an `Area/Location` name, or an offset); otherwise the text is not a stamp and stays in the question. Dated stamps keep warning for any unknown zone. Tests: `10:30 standup?` and `6:30 pm tonight` are not stamps; `IST`, `XYZ`, `Not/AZone`, `+15:00` after a bare time still warn. - Claude's non-blocking notes, left for the architect: an unparseable `_Zone:` line (`_Zone: Pacific Time_`) is silently no zone, and `_Zone: XYZ_` warns on each card with nothing pointing at the header line (a `DecisionsFileProblem` would be the home; follow-up); an as-written stamp renders in the normal stamp font, not monospace. +- Re-review: codex APPROVE, claude APPROVE (gemini APPROVE in round 1). Claude cleared two risks: `splice.ts` never re-serializes a heading, and item fingerprints hash raw text, so a daily-changing time-only instant can't make submits fail. + +## Integration review (architect, 2026-10-19) + +- §4 file template now carries `_Zone: _` with a one-line note to replace it; §3 step 1 says the hub keeps that line naming the zone its clock reads. Doc test: the template carries the line; filled in (`ET`), a bare-time item converts; left as ``, the card warns unknown-zone. +- `parse.ts` header no longer claims a pure function of the text: the clock enters through time-only stamps. Noted that `version` (the client's cache key) hashes the text only, so a client keeps the instants it has until the file changes, and that `verify()` in `splice.ts` is safe only because it compares no stamps. +- Follow-up filed: #1838 (an unusable `_Zone:` header line reported once as a `DecisionsFileProblem`). diff --git a/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts b/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts index abccd2c21..d21a4fd3e 100644 --- a/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts @@ -37,7 +37,7 @@ const fileSkeleton = block('## Ruled'); const template = block(''); const example = block('### web-4'); const qaPair = block('**You asked'); -const zoned = block('_Zone: '); +const zoned = block('_Zone: ET_'); /** The file skeleton with `item` posted at the top of `## Open`. */ const posted = (item: string) => fileSkeleton.replace('## Open\n', `## Open\n\n${item}\n`); @@ -143,7 +143,7 @@ describe('decision-protocol.md — its examples are what the tab parses', () => it('no time in the doc assumes a zone: every example time carries one or its file names one, and none is written as PT-only', () => { expect(doc).not.toMatch(/(?:am|pm) PT\b/); expect(doc).not.toMatch(/_Zone: PT_/); - for (const b of blocks) if (!b.includes('_Zone: ')) expect(b).not.toMatch(/\d{1,2}:\d{2} (?:am|pm)(?! [A-Z])/); + for (const b of blocks) if (!b.includes('_Zone: ET_')) expect(b).not.toMatch(/\d{1,2}:\d{2} (?:am|pm)(?! [A-Z])/); }); describe("the file-zone example: a bare time converts, a heading without a stamp keeps its question (#1836)", () => { @@ -164,6 +164,18 @@ describe('decision-protocol.md — its examples are what the tab parses', () => expect(doc).toContain('`_Zone: _`'); }); + it("the file template carries the zone line, and once it names a zone a bare time posted into it converts", () => { + expect(fileSkeleton).toContain('_Zone: _'); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-01-15T17:00:00.000Z')); + const bareTime = example.replace('2026-10-17 9:40 pm ET', '9:40 pm'); + const filled = parseDecisions(posted(bareTime).replace('_Zone: _', '_Zone: ET_')).items[0]; + expect(filled.stamp).toEqual({ raw: '9:40 pm', instant: '2026-01-16T02:40:00.000Z', timeOnly: true }); + // Left unfilled, the placeholder is no zone the tab knows: the card says so. + expect(parseDecisions(posted(bareTime)).items[0].stamp?.unconvertedReason).toBe('unknown-zone'); + expect(doc).toMatch(/keeps that line naming the zone its clock reads/); + }); + it('without the zone line, the bare time is shown as written with no reason', () => { const [item] = parseDecisions(zoned.replace(/^_Zone: .*\n/m, '')).items; expect(item.stamp).toEqual({ raw: '9:40 pm', instant: null, timeOnly: true }); diff --git a/packages/codev/src/agent-farm/servers/decisions/parse.ts b/packages/codev/src/agent-farm/servers/decisions/parse.ts index fd50c000a..d4b069388 100644 --- a/packages/codev/src/agent-farm/servers/decisions/parse.ts +++ b/packages/codev/src/agent-farm/servers/decisions/parse.ts @@ -1,8 +1,14 @@ // Decisions file parser (Spec 1819). // -// A pure function from the decisions file's text to its read model. The file is -// the only state: this runs on every read, nothing is cached. Structure comes -// from `grammar.ts`; this module adds the format's conventions: +// A function from the decisions file's text, and the clock, to its read model. The +// file is the only state: this runs on every read, nothing is cached. The clock +// enters only through time-only stamps, which are read as today in their zone +// (#1836), so the same text can parse to different instants on different days: +// - `version` (the client's cache key) hashes the text alone and does not cover +// that: a client keeps the instants it has until the file changes. +// - `splice.ts`'s `verify()` re-parses at a later moment than the parse it checks +// against; it is safe only because it compares no stamps. +// Structure comes from `grammar.ts`; this module adds the format's conventions: // - `## Open` / `## Ruled` sections; items are `###` headings inside `## Open`, // running to the next heading of level 1–3 (or end of file). // - item id = the heading's first token; ` · ` separates id, stamp and question. A first From aad45677d87c6118a8652abba752bebed24756dc Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 09:14:46 -0700 Subject: [PATCH 09/11] [Bugfix #1836] Stamps in UTC: the file template ships _Zone: UTC_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ruling: agents write stamps in UTC and the tab converts them to the viewer's clock. - decision-protocol.md (both trees): the §4 template ships `_Zone: UTC_`, and its note says the line stays UTC. Agents write stamps as `YYYY-MM-DD HH:mm UTC` (24-hour, `date -u '+%Y-%m-%d %H:%M UTC'`). §2's template, the worked example and the file-zone example use that form; other zones are still accepted for anything a person types. - Doc test: every dated example stamp is that form; a bare time in the template file converts in UTC. The unfilled-placeholder case is now a parser test on a literal. Co-Authored-By: Claude Opus 5.5 --- codev-skeleton/resources/decision-protocol.md | 57 ++++++++++--------- codev/resources/decision-protocol.md | 57 ++++++++++--------- codev/state/bugfix-1836_thread.md | 7 +++ .../__tests__/decision-protocol-doc.test.ts | 44 ++++++++------ .../__tests__/decisions-parse.test.ts | 2 + 5 files changed, 96 insertions(+), 71 deletions(-) diff --git a/codev-skeleton/resources/decision-protocol.md b/codev-skeleton/resources/decision-protocol.md index 23a165c5c..24572d588 100644 --- a/codev-skeleton/resources/decision-protocol.md +++ b/codev-skeleton/resources/decision-protocol.md @@ -30,7 +30,7 @@ and never reuses a number. ## 2. The item ```markdown -### - · · +### - · · - **Problem:** (or **Context:**) what is wrong or undecided, as the owner would see it. One to three sentences. - **Root cause:** why, for a fix. @@ -44,8 +44,10 @@ and never reuses a number. **Your word:** `` / `` / `hold` ``` -Delete labels that do not apply; never rename them. The hub bounces a malformed item back, -naming what is missing, and does not post it. +The stamp is when the item was written, in UTC on the 24-hour clock, as +`date -u '+%Y-%m-%d %H:%M UTC'` prints it; the tab shows it in each viewer's own time. Delete +labels that do not apply; never rename them. The hub bounces a malformed item back, naming +what is missing, and does not post it. ### What the tab reads @@ -59,22 +61,23 @@ per option and a comment box. space) separates the id, the stamp and the question. A heading without a stamp is id · question, and a first part the tab cannot read as a stamp stays in the question, shown as written. Open items that share an id are read-only until the ids differ. -- **The stamp carries its zone; nothing assumes one.** Write the full form: `YYYY-MM-DD h:mm am` - (or `pm`), or 24-hour `YYYY-MM-DD HH:mm`, then the zone the time was read in. The tab converts - the stamp using only that zone and shows its date and time in the viewer's local time. It - reads the North-American generic zones `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` (daylight saving - follows the stamp's date), fixed abbreviations such as `UTC`, `GMT`, `EST`/`EDT`, - `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` is North-American Central), offsets such as - `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names such as `Europe/Berlin`. Any other zone, - including ambiguous abbreviations such as `IST`, `BST` and `AST`, is shown as written and - marked not converted, as is a time the clocks skipped or repeated that day. +- **The stamp carries its zone; nothing assumes one.** Agents write it as `YYYY-MM-DD HH:mm UTC`, + 24-hour and in UTC (section 2). The tab converts the stamp using only the zone written in it + and shows its date and time in the viewer's local time. For anything a person types it also + reads `YYYY-MM-DD h:mm am` (or `pm`), or 24-hour `YYYY-MM-DD HH:mm`, then the zone the time + was read in: the North-American generic zones `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` + (daylight saving follows the stamp's date), fixed abbreviations such as `UTC`, `GMT`, + `EST`/`EDT`, `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` is North-American Central), offsets + such as `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names such as `Europe/Berlin`. Any other + zone, including ambiguous abbreviations such as `IST`, `BST` and `AST`, is shown as written + and marked not converted, as is a time the clocks skipped or repeated that day. - **A file may name its zone once.** A line `_Zone: _` above `## Open`, from the same - list of zones, is the zone of every stamp that names none. A bare time, `h:mm am` or `HH:mm`, - is read as today in that zone when the tab reads the file, and shown as the time alone: as - written, converted to your clock, with no date because none was written. Without a file zone, - a stamp that names no zone is shown as written. A bare time is always read as today, so one - written before midnight and read after it lands on the wrong day: the full form stays the one - to write. + list of zones, is the zone of every stamp that names none; the file template's is + `_Zone: UTC_` (section 4). A bare time, `h:mm am` or `HH:mm`, is read as today in that zone + when the tab reads the file, and shown as the time alone: as written, converted to your + clock, with no date because none was written. Without a file zone, a stamp that names no + zone is shown as written. A bare time is always read as today, so one written before + midnight and read after it lands on the wrong day: the full form stays the one to write. - **Exactly one `**Your word:**` line**, starting a line, outside a code block. The options are the backticked words on it, separated by ` / `. The first is the recommended one, and the tab marks it. A description follows a word after ` = ` and runs to the next ` / ` before a @@ -97,11 +100,11 @@ In a file that names its zone, a bare time converts, and a heading without a sta and a question (the item bodies are left out here): ```markdown -_Zone: ET_ +_Zone: UTC_ ## Open -### web-6 · 9:40 pm · Should the export keep the old column names? +### web-6 · 01:40 · Should the export keep the old column names? ### web-5 · Should the nightly report move to the morning? ``` @@ -109,7 +112,7 @@ _Zone: ET_ A worked example, a choice with bare words on the word line: ```markdown -### web-4 · 2026-10-17 9:40 pm ET · When a session expires mid-edit, should the login fix keep the page or sign the user out? +### web-4 · 2026-10-18 01:40 UTC · When a session expires mid-edit, should the login fix keep the page or sign the user out? - **Context:** users whose session expires while they edit lose their draft when the page reloads. - **Options:** (a) `retry` = refresh the sign-in in the background and keep the page (b) `signout` = sign the user out and keep the draft on this device @@ -124,9 +127,8 @@ A worked example, a choice with bare words on the word line: 1. **Post.** The hub puts the item at the top of `## Open`, updates the status line (`N open — `), and tells the owner in one chat line: - `New decision from : — `, plus `[urgent]` if marked. An item whose - stamp is a bare time is read in the zone on the file's `_Zone: _` line, so the hub - keeps that line naming the zone its clock reads (section 4). + `New decision from : — `, plus `[urgent]` if marked. The item's + stamp is in UTC (section 2), and the file's `_Zone: UTC_` line stays UTC (section 4). 2. **Learn of rulings.** The owner rules in the tab, by typing in the file, or in chat. A ruling made in the tab reaches the hub as a message from Tower: `RULED : `, or `QUESTION :` when no word was chosen, with the comment on the lines after it. The notice @@ -197,13 +199,14 @@ Rule inline: one word on the line under an item's **Your word:** line, or in the Newest first. Only main edits this file; architects send items with `afx send architect:main 'DECISION …'`. _Status: 0 open._ -_Zone: _ +_Zone: UTC_ ## Open ## Ruled ``` -Replace `` with the zone the hub's clock reads (`PT`, `ET`, `Europe/Berlin`, …): it is the -zone of every stamp that names none, bare times included. Newest first in both sections. +The `_Zone: UTC_` line stays UTC. Agents write every stamp in UTC, and the line is the zone of +any stamp that names none, bare times included; a person who types a time in another zone +writes the zone after it. Newest first in both sections. `## Open` holds only what still needs a word; `## Ruled` keeps one line per item. diff --git a/codev/resources/decision-protocol.md b/codev/resources/decision-protocol.md index 23a165c5c..24572d588 100644 --- a/codev/resources/decision-protocol.md +++ b/codev/resources/decision-protocol.md @@ -30,7 +30,7 @@ and never reuses a number. ## 2. The item ```markdown -### - · · +### - · · - **Problem:** (or **Context:**) what is wrong or undecided, as the owner would see it. One to three sentences. - **Root cause:** why, for a fix. @@ -44,8 +44,10 @@ and never reuses a number. **Your word:** `` / `` / `hold` ``` -Delete labels that do not apply; never rename them. The hub bounces a malformed item back, -naming what is missing, and does not post it. +The stamp is when the item was written, in UTC on the 24-hour clock, as +`date -u '+%Y-%m-%d %H:%M UTC'` prints it; the tab shows it in each viewer's own time. Delete +labels that do not apply; never rename them. The hub bounces a malformed item back, naming +what is missing, and does not post it. ### What the tab reads @@ -59,22 +61,23 @@ per option and a comment box. space) separates the id, the stamp and the question. A heading without a stamp is id · question, and a first part the tab cannot read as a stamp stays in the question, shown as written. Open items that share an id are read-only until the ids differ. -- **The stamp carries its zone; nothing assumes one.** Write the full form: `YYYY-MM-DD h:mm am` - (or `pm`), or 24-hour `YYYY-MM-DD HH:mm`, then the zone the time was read in. The tab converts - the stamp using only that zone and shows its date and time in the viewer's local time. It - reads the North-American generic zones `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` (daylight saving - follows the stamp's date), fixed abbreviations such as `UTC`, `GMT`, `EST`/`EDT`, - `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` is North-American Central), offsets such as - `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names such as `Europe/Berlin`. Any other zone, - including ambiguous abbreviations such as `IST`, `BST` and `AST`, is shown as written and - marked not converted, as is a time the clocks skipped or repeated that day. +- **The stamp carries its zone; nothing assumes one.** Agents write it as `YYYY-MM-DD HH:mm UTC`, + 24-hour and in UTC (section 2). The tab converts the stamp using only the zone written in it + and shows its date and time in the viewer's local time. For anything a person types it also + reads `YYYY-MM-DD h:mm am` (or `pm`), or 24-hour `YYYY-MM-DD HH:mm`, then the zone the time + was read in: the North-American generic zones `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` + (daylight saving follows the stamp's date), fixed abbreviations such as `UTC`, `GMT`, + `EST`/`EDT`, `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` is North-American Central), offsets + such as `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names such as `Europe/Berlin`. Any other + zone, including ambiguous abbreviations such as `IST`, `BST` and `AST`, is shown as written + and marked not converted, as is a time the clocks skipped or repeated that day. - **A file may name its zone once.** A line `_Zone: _` above `## Open`, from the same - list of zones, is the zone of every stamp that names none. A bare time, `h:mm am` or `HH:mm`, - is read as today in that zone when the tab reads the file, and shown as the time alone: as - written, converted to your clock, with no date because none was written. Without a file zone, - a stamp that names no zone is shown as written. A bare time is always read as today, so one - written before midnight and read after it lands on the wrong day: the full form stays the one - to write. + list of zones, is the zone of every stamp that names none; the file template's is + `_Zone: UTC_` (section 4). A bare time, `h:mm am` or `HH:mm`, is read as today in that zone + when the tab reads the file, and shown as the time alone: as written, converted to your + clock, with no date because none was written. Without a file zone, a stamp that names no + zone is shown as written. A bare time is always read as today, so one written before + midnight and read after it lands on the wrong day: the full form stays the one to write. - **Exactly one `**Your word:**` line**, starting a line, outside a code block. The options are the backticked words on it, separated by ` / `. The first is the recommended one, and the tab marks it. A description follows a word after ` = ` and runs to the next ` / ` before a @@ -97,11 +100,11 @@ In a file that names its zone, a bare time converts, and a heading without a sta and a question (the item bodies are left out here): ```markdown -_Zone: ET_ +_Zone: UTC_ ## Open -### web-6 · 9:40 pm · Should the export keep the old column names? +### web-6 · 01:40 · Should the export keep the old column names? ### web-5 · Should the nightly report move to the morning? ``` @@ -109,7 +112,7 @@ _Zone: ET_ A worked example, a choice with bare words on the word line: ```markdown -### web-4 · 2026-10-17 9:40 pm ET · When a session expires mid-edit, should the login fix keep the page or sign the user out? +### web-4 · 2026-10-18 01:40 UTC · When a session expires mid-edit, should the login fix keep the page or sign the user out? - **Context:** users whose session expires while they edit lose their draft when the page reloads. - **Options:** (a) `retry` = refresh the sign-in in the background and keep the page (b) `signout` = sign the user out and keep the draft on this device @@ -124,9 +127,8 @@ A worked example, a choice with bare words on the word line: 1. **Post.** The hub puts the item at the top of `## Open`, updates the status line (`N open — `), and tells the owner in one chat line: - `New decision from : — `, plus `[urgent]` if marked. An item whose - stamp is a bare time is read in the zone on the file's `_Zone: _` line, so the hub - keeps that line naming the zone its clock reads (section 4). + `New decision from : — `, plus `[urgent]` if marked. The item's + stamp is in UTC (section 2), and the file's `_Zone: UTC_` line stays UTC (section 4). 2. **Learn of rulings.** The owner rules in the tab, by typing in the file, or in chat. A ruling made in the tab reaches the hub as a message from Tower: `RULED : `, or `QUESTION :` when no word was chosen, with the comment on the lines after it. The notice @@ -197,13 +199,14 @@ Rule inline: one word on the line under an item's **Your word:** line, or in the Newest first. Only main edits this file; architects send items with `afx send architect:main 'DECISION …'`. _Status: 0 open._ -_Zone: _ +_Zone: UTC_ ## Open ## Ruled ``` -Replace `` with the zone the hub's clock reads (`PT`, `ET`, `Europe/Berlin`, …): it is the -zone of every stamp that names none, bare times included. Newest first in both sections. +The `_Zone: UTC_` line stays UTC. Agents write every stamp in UTC, and the line is the zone of +any stamp that names none, bare times included; a person who types a time in another zone +writes the zone after it. Newest first in both sections. `## Open` holds only what still needs a word; `## Ruled` keeps one line per item. diff --git a/codev/state/bugfix-1836_thread.md b/codev/state/bugfix-1836_thread.md index c65c3d359..af2b347cf 100644 --- a/codev/state/bugfix-1836_thread.md +++ b/codev/state/bugfix-1836_thread.md @@ -47,3 +47,10 @@ Not from this change: 74 web tests (Terminal*, VirtualKeyboard, architect-toolba - §4 file template now carries `_Zone: _` with a one-line note to replace it; §3 step 1 says the hub keeps that line naming the zone its clock reads. Doc test: the template carries the line; filled in (`ET`), a bare-time item converts; left as ``, the card warns unknown-zone. - `parse.ts` header no longer claims a pure function of the text: the clock enters through time-only stamps. Noted that `version` (the client's cache key) hashes the text only, so a client keeps the instants it has until the file changes, and that `verify()` in `splice.ts` is safe only because it compares no stamps. - Follow-up filed: #1838 (an unusable `_Zone:` header line reported once as a `DecisionsFileProblem`). + +## Owner ruling: everything in UTC (relayed by the architect, 2026-10-19) + +- §4 file template ships `_Zone: UTC_`, and the note says the line stays UTC (a person typing a time in another zone writes the zone after it). +- Stamp guidance: agents write `YYYY-MM-DD HH:mm UTC` (24-hour, `date -u '+%Y-%m-%d %H:%M UTC'`); §2's template heading and the worked example (`web-4 · 2026-10-18 01:40 UTC`, the same instant as before) use it; the file-zone example is `_Zone: UTC_` + `01:40`. Other zones stay documented as accepted for anything a person types. §3 step 1: the stamp is in UTC and the `_Zone: UTC_` line stays UTC. +- Doc test: every dated example stamp must be `YYYY-MM-DD HH:mm UTC`; the template line converts a bare time in UTC; a human-typed `4:40 pm ET` in the same file keeps its own zone. The unfilled-`` case moved to a parser test on a literal. +- Left as written, flagged to the architect: the Q&A example times (`10:05 pm ET`) and §3's "every time it writes carries its zone, read from the clock (`date`)". These are times the hub writes, not stamps, and the tab shows them as written, not converted. diff --git a/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts b/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts index d21a4fd3e..689c7f820 100644 --- a/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decision-protocol-doc.test.ts @@ -37,7 +37,7 @@ const fileSkeleton = block('## Ruled'); const template = block(''); const example = block('### web-4'); const qaPair = block('**You asked'); -const zoned = block('_Zone: ET_'); +const zoned = block('### web-6'); /** The file skeleton with `item` posted at the top of `## Open`. */ const posted = (item: string) => fileSkeleton.replace('## Open\n', `## Open\n\n${item}\n`); @@ -54,7 +54,7 @@ describe('decision-protocol.md — its examples are what the tab parses', () => expect(item.id).toBe('-'); // The stamp placeholder is not a stamp, so it stays in the question as written. expect(item.stamp).toBeNull(); - expect(item.question).toMatch(/^ · · ', description: '', recommended: true }, { word: '', description: '', recommended: false }, @@ -78,8 +78,17 @@ describe('decision-protocol.md — its examples are what the tab parses', () => it("the worked example's stamp converts using only the zone it carries", () => { const [item] = parseDecisions(posted(example)).items; - // 9:40 pm US Eastern on 2026-10-17 is daylight time, UTC-4. - expect(item.stamp).toEqual({ raw: '2026-10-17 9:40 pm ET', instant: '2026-10-18T01:40:00.000Z' }); + expect(item.stamp).toEqual({ raw: '2026-10-18 01:40 UTC', instant: '2026-10-18T01:40:00.000Z' }); + }); + + it('agents write stamps as YYYY-MM-DD HH:mm UTC: every dated example stamp is that form, and the command the doc gives prints it', () => { + const stamps = blocks.flatMap(b => [...b.matchAll(/^### \S+ · (\d{4}-\d{2}-\d{2} [^·]*?) · /gm)].map(m => m[1])); + expect(stamps.length).toBeGreaterThan(0); + for (const stamp of stamps) expect(stamp).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC$/); + expect(template).toContain('### - · · '); + expect(doc).toContain("`date -u '+%Y-%m-%d %H:%M UTC'`"); + // What that command prints at 01:05 UTC. + expect(resolveStamp('2026-10-18 01:05 UTC')).toEqual({ raw: '2026-10-18 01:05 UTC', instant: '2026-10-18T01:05:00.000Z' }); }); it('a submit writes the word and comment after the word line, the notice names them, and the card is then ruled', () => { @@ -143,7 +152,7 @@ describe('decision-protocol.md — its examples are what the tab parses', () => it('no time in the doc assumes a zone: every example time carries one or its file names one, and none is written as PT-only', () => { expect(doc).not.toMatch(/(?:am|pm) PT\b/); expect(doc).not.toMatch(/_Zone: PT_/); - for (const b of blocks) if (!b.includes('_Zone: ET_')) expect(b).not.toMatch(/\d{1,2}:\d{2} (?:am|pm)(?! [A-Z])/); + for (const b of blocks) if (!b.includes('_Zone: ')) expect(b).not.toMatch(/\d{1,2}:\d{2} (?:am|pm)(?! [A-Z])/); }); describe("the file-zone example: a bare time converts, a heading without a stamp keeps its question (#1836)", () => { @@ -153,32 +162,33 @@ describe('decision-protocol.md — its examples are what the tab parses', () => it('reads the bare time as today in the file zone, with no reason on either heading', () => { vi.useFakeTimers({ toFake: ['Date'] }); - // Noon on 2026-01-15 in New York: standard time, UTC-5. vi.setSystemTime(new Date('2026-01-15T17:00:00.000Z')); const items = parseDecisions(zoned).items; expect(items.map(i => [i.id, i.stamp, i.question])).toEqual([ - ['web-6', { raw: '9:40 pm', instant: '2026-01-16T02:40:00.000Z', timeOnly: true }, 'Should the export keep the old column names?'], + ['web-6', { raw: '01:40', instant: '2026-01-15T01:40:00.000Z', timeOnly: true }, 'Should the export keep the old column names?'], ['web-5', null, 'Should the nightly report move to the morning?'], ]); - expect(resolveStamp('9:40 pm', 'ET')).toEqual(items[0].stamp); + expect(resolveStamp('01:40', 'UTC')).toEqual(items[0].stamp); expect(doc).toContain('`_Zone: _`'); }); - it("the file template carries the zone line, and once it names a zone a bare time posted into it converts", () => { - expect(fileSkeleton).toContain('_Zone: _'); + it('the file template ships `_Zone: UTC_`, and a bare time posted into it converts in UTC', () => { + expect(fileSkeleton).toContain('_Status: 0 open._\n_Zone: UTC_\n'); + expect(doc).toContain('The `_Zone: UTC_` line stays UTC.'); + expect(doc).toContain('the file\'s `_Zone: UTC_` line stays UTC (section 4)'); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-01-15T17:00:00.000Z')); - const bareTime = example.replace('2026-10-17 9:40 pm ET', '9:40 pm'); - const filled = parseDecisions(posted(bareTime).replace('_Zone: _', '_Zone: ET_')).items[0]; - expect(filled.stamp).toEqual({ raw: '9:40 pm', instant: '2026-01-16T02:40:00.000Z', timeOnly: true }); - // Left unfilled, the placeholder is no zone the tab knows: the card says so. - expect(parseDecisions(posted(bareTime)).items[0].stamp?.unconvertedReason).toBe('unknown-zone'); - expect(doc).toMatch(/keeps that line naming the zone its clock reads/); + const bareTime = example.replace('2026-10-18 01:40 UTC', '21:40'); + const [item] = parseDecisions(posted(bareTime)).items; + expect(item.stamp).toEqual({ raw: '21:40', instant: '2026-01-15T21:40:00.000Z', timeOnly: true }); + // A person's time in another zone, typed into the same file, keeps its own zone. + const typed = parseDecisions(posted(example.replace('2026-10-18 01:40 UTC', '4:40 pm ET'))).items[0]; + expect(typed.stamp).toEqual({ raw: '4:40 pm ET', instant: '2026-01-15T21:40:00.000Z', timeOnly: true }); }); it('without the zone line, the bare time is shown as written with no reason', () => { const [item] = parseDecisions(zoned.replace(/^_Zone: .*\n/m, '')).items; - expect(item.stamp).toEqual({ raw: '9:40 pm', instant: null, timeOnly: true }); + expect(item.stamp).toEqual({ raw: '01:40', instant: null, timeOnly: true }); }); }); diff --git a/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts b/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts index 2cd5e11e7..1f7e202ae 100644 --- a/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts +++ b/packages/codev/src/agent-farm/__tests__/decisions-parse.test.ts @@ -290,6 +290,8 @@ describe('parseDecisions — headings', () => { expect(full.stamp).toEqual({ raw: '2026-10-17 9:40 pm ET', instant: '2026-10-18T01:40:00.000Z' }); const ist = parseDecisions(file('_Zone: IST_', 'main-3 · 6:30 pm · Ship it?')).items[0]; expect(ist.stamp?.unconvertedReason).toBe('unknown-zone'); + const placeholder = parseDecisions(file('_Zone: _', 'main-3 · 6:30 pm · Ship it?')).items[0]; + expect(placeholder.stamp?.unconvertedReason).toBe('unknown-zone'); }); }); From 74427903f997fa9f8b27a1af00b2bf0960cd5ffe Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 19 Oct 2026 09:16:03 -0700 Subject: [PATCH 10/11] [Bugfix #1836] Every time the hub writes is UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decision-protocol.md (both trees): §3's relay, Ruled and DONE times are `