@clipboard-health/groundcrew 4.32.1 → 4.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,5 +18,6 @@ export interface NormalizeOptions {
18
18
  updatedAt: string;
19
19
  }
20
20
  export declare function normalizeToIssue(options: NormalizeOptions): Issue | undefined;
21
- export declare function isActiveForFetch(parsed: ParsedTodoLine, todayIsoDate: string): boolean;
21
+ export declare function isActiveForFetch(parsed: ParsedTodoLine, nowIsoLocal: string): boolean;
22
+ export declare function isValidThresholdValue(value: string): boolean;
22
23
  //# sourceMappingURL=normalizer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"normalizer.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/todo-txt/normalizer.ts"],"names":[],"mappings":"AAGA,OAAO,EAAsC,KAAK,KAAK,EAAiB,MAAM,qBAAqB,CAAC;AACpG,OAAO,EAKL,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAkED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,SAAS,EAAE,CAAC,cAAc,GAAG,IAAI,CAAC,EAAE,CAAC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,gBAAgB,GAAG,KAAK,GAAG,SAAS,CAqD7E;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAYtF"}
1
+ {"version":3,"file":"normalizer.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/todo-txt/normalizer.ts"],"names":[],"mappings":"AAGA,OAAO,EAAsC,KAAK,KAAK,EAAiB,MAAM,qBAAqB,CAAC;AACpG,OAAO,EAML,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAkED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,cAAc,CAAC;IACvB,SAAS,EAAE,CAAC,cAAc,GAAG,IAAI,CAAC,EAAE,CAAC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,gBAAgB,GAAG,KAAK,GAAG,SAAS,CAqD7E;AAID,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAYrF;AAqCD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAO5D"}
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { AGENT_ANY } from "../../config.js";
3
3
  import { toCanonicalId } from "../../taskSource.js";
4
- import { DATE_RE, getMetadataAll, getMetadataFirst, hashLine, } from "./parser.js";
4
+ import { DATE_RE, DATETIME_RE, getMetadataAll, getMetadataFirst, hashLine, } from "./parser.js";
5
5
  function derivedCanonicalStatus(parsed) {
6
6
  if (parsed.completed) {
7
7
  return "done";
@@ -90,7 +90,9 @@ export function normalizeToIssue(options) {
90
90
  sourceRef,
91
91
  };
92
92
  }
93
- export function isActiveForFetch(parsed, todayIsoDate) {
93
+ // `nowIsoLocal` is either `YYYY-MM-DD` (treated as that day's midnight) or
94
+ // `YYYY-MM-DDTHH:MM:SS`, both in the source's configured timezone.
95
+ export function isActiveForFetch(parsed, nowIsoLocal) {
94
96
  if (parsed.completed) {
95
97
  return false;
96
98
  }
@@ -99,21 +101,52 @@ export function isActiveForFetch(parsed, todayIsoDate) {
99
101
  }
100
102
  const statusValue = getMetadataFirst(parsed, "status");
101
103
  if (statusValue === "todo") {
102
- return !isDeferredByThreshold(parsed, todayIsoDate);
104
+ return !isDeferredByThreshold(parsed, nowIsoLocal);
103
105
  }
104
106
  return statusValue === "in-progress" || statusValue === "in-review";
105
107
  }
106
- // Per todo.txt convention, t: (threshold) hides a task until that date.
107
- // Only not-yet-started tasks defer; in-progress/in-review work must stay
108
- // visible so the orchestrator keeps tracking it. Malformed t: values are
109
- // surfaced by validate() and do not block dispatch.
110
- function isDeferredByThreshold(parsed, todayIsoDate) {
108
+ // Per todo.txt convention, t: (threshold) hides a task until that date — or,
109
+ // with a `YYYY-MM-DDTHH:MM[:SS]` value, until that instant, enabling sub-day
110
+ // cadences for self-rearming recurring tasks. Only not-yet-started tasks
111
+ // defer; in-progress/in-review work must stay visible so the orchestrator
112
+ // keeps tracking it. Malformed t: values are surfaced by validate() and do
113
+ // not block dispatch.
114
+ function isDeferredByThreshold(parsed, nowIsoLocal) {
111
115
  const threshold = getMetadataFirst(parsed, "t");
112
- if (threshold === undefined || !DATE_RE.test(threshold) || !isCalendarDate(threshold)) {
116
+ if (threshold === undefined) {
113
117
  return false;
114
118
  }
115
- // ISO YYYY-MM-DD dates order lexicographically
116
- return threshold > todayIsoDate;
119
+ if (DATE_RE.test(threshold) && isCalendarDate(threshold)) {
120
+ // ISO YYYY-MM-DD dates order lexicographically
121
+ return threshold > nowIsoLocal.slice(0, 10);
122
+ }
123
+ if (DATETIME_RE.test(threshold) &&
124
+ isCalendarDate(threshold.slice(0, 10)) &&
125
+ isClockTime(threshold.slice(11))) {
126
+ const nowDateTime = nowIsoLocal.length > 10 ? nowIsoLocal : `${nowIsoLocal}T00:00:00`;
127
+ // Equal-length ISO datetime strings order lexicographically
128
+ return padSeconds(threshold) > padSeconds(nowDateTime);
129
+ }
130
+ return false;
131
+ }
132
+ function padSeconds(dateTime) {
133
+ return dateTime.length === 16 ? `${dateTime}:00` : dateTime;
134
+ }
135
+ // Format + calendar (+ clock) validity for both threshold forms. Shared with
136
+ // writeback's validate() so verify() flags what fetch would ignore — and what
137
+ // would otherwise crash rec: advancement (a non-calendar date survives the
138
+ // format-only regex but produces an Invalid Date in advanceDate).
139
+ export function isValidThresholdValue(value) {
140
+ if (DATE_RE.test(value)) {
141
+ return isCalendarDate(value);
142
+ }
143
+ return (DATETIME_RE.test(value) && isCalendarDate(value.slice(0, 10)) && isClockTime(value.slice(11)));
144
+ }
145
+ // DATETIME_RE is format-only; reject impossible clock values like 25:00 so
146
+ // they surface as malformed (visible) instead of deferring forever.
147
+ function isClockTime(value) {
148
+ const [hours, minutes, seconds = "00"] = value.split(":");
149
+ return Number(hours) <= 23 && Number(minutes) <= 59 && Number(seconds) <= 59;
117
150
  }
118
151
  // DATE_RE is format-only; non-calendar values like 2026-99-99 would compare
119
152
  // greater than any real date and defer the task forever.
@@ -13,6 +13,8 @@ export interface ParsedTodoLine {
13
13
  readonly isStatusFinalToken: boolean;
14
14
  }
15
15
  export declare const DATE_RE: RegExp;
16
+ /** Local datetime threshold form for `t:`, seconds optional. */
17
+ export declare const DATETIME_RE: RegExp;
16
18
  export declare function hashLine(raw: string): string;
17
19
  export declare function parseAllLines(fileContent: string): (ParsedTodoLine | null)[];
18
20
  export declare function getMetadataFirst(parsed: ParsedTodoLine, key: string): string | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/todo-txt/parser.ts"],"names":[],"mappings":"AAEA,KAAK,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AAEzD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,uFAAuF;IACvF,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;CACtC;AAED,eAAO,MAAM,OAAO,QAAwB,CAAC;AAM7C,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE5C;AA6ED,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,CAAC,cAAc,GAAG,IAAI,CAAC,EAAE,CAQ5E;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExF;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAE5E"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/todo-txt/parser.ts"],"names":[],"mappings":"AAEA,KAAK,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AAEzD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,uFAAuF;IACvF,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;CACtC;AAED,eAAO,MAAM,OAAO,QAAwB,CAAC;AAC7C,gEAAgE;AAChE,eAAO,MAAM,WAAW,QAA+C,CAAC;AAMxE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE5C;AA6ED,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,CAAC,cAAc,GAAG,IAAI,CAAC,EAAE,CAQ5E;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExF;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAE5E"}
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
3
+ /** Local datetime threshold form for `t:`, seconds optional. */
4
+ export const DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/;
3
5
  const KEY_VALUE_RE = /^(?<key>[a-zA-Z][a-zA-Z0-9-]*):(?<value>\S+)$/;
4
6
  const PRIORITY_RE = /^\((?<priority>[A-Z])\) /;
5
7
  const PROJECT_RE = /^\+\S+$/;
@@ -1 +1 @@
1
- {"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/todo-txt/source.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,EAKL,KAAK,UAAU,EAEhB,MAAM,qBAAqB,CAAC;AAI7B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AA6RxD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,cAAc,GACtB,UAAU,CAsJZ"}
1
+ {"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/todo-txt/source.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,EAKL,KAAK,UAAU,EAEhB,MAAM,qBAAqB,CAAC;AAI7B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AA+SxD,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,oBAAoB,EAC5B,OAAO,EAAE,cAAc,GACtB,UAAU,CAyJZ"}
@@ -7,7 +7,7 @@ import { readEnvironmentVariable } from "../../util.js";
7
7
  import { isActiveForFetch, normalizeToIssue } from "./normalizer.js";
8
8
  import { DATE_RE, getMetadataFirst, parseAllLines } from "./parser.js";
9
9
  import { copyPromptFile, updateTaskStatus, validateTodoFile, withLock } from "./writeback.js";
10
- const RECURRENCE_RE = /^\+?\d+[dwmy]$/;
10
+ const RECURRENCE_RE = /^\+?\d+[dwmyh]$/;
11
11
  function readPromptFile(promptPath) {
12
12
  try {
13
13
  return readFileSync(promptPath, "utf8");
@@ -113,6 +113,23 @@ function isoDateFor(timeZone, now) {
113
113
  function datePartFor(timeZone, now) {
114
114
  return isoDateFor(timeZone, now).replaceAll("-", "");
115
115
  }
116
+ function isoDateTimeFor(timeZone, now) {
117
+ const parts = new Intl.DateTimeFormat("en-CA", {
118
+ timeZone,
119
+ hour: "2-digit",
120
+ minute: "2-digit",
121
+ second: "2-digit",
122
+ hourCycle: "h23",
123
+ }).formatToParts(now);
124
+ const hour = parts.find((part) => part.type === "hour")?.value;
125
+ const minute = parts.find((part) => part.type === "minute")?.value;
126
+ const second = parts.find((part) => part.type === "second")?.value;
127
+ /* v8 ignore next 3 @preserve -- Intl.DateTimeFormat with hour/minute/second always returns these parts */
128
+ if (hour === undefined || minute === undefined || second === undefined) {
129
+ throw new Error(`todo-txt: could not format time in timezone "${timeZone}"`);
130
+ }
131
+ return `${isoDateFor(timeZone, now)}T${hour}:${minute}:${second}`;
132
+ }
116
133
  /* v8 ignore next @preserve -- Covered in source tests; full-suite V8 coverage remaps this helper inconsistently. */
117
134
  function nextGeneratedId(config, parsedAll) {
118
135
  const datePart = datePartFor(config.timezone, new Date());
@@ -180,7 +197,7 @@ function buildTodoLine(id, input) {
180
197
  }
181
198
  if (input.recurrence !== undefined) {
182
199
  if (!RECURRENCE_RE.test(input.recurrence)) {
183
- throw new Error("todo-txt: recurrence must look like 1d, 1w, 1m, 1y, or +1m");
200
+ throw new Error("todo-txt: recurrence must look like 1d, 1w, 1m, 1y, 2h, or +1m");
184
201
  }
185
202
  tokens.push(metadataToken("rec", input.recurrence));
186
203
  }
@@ -258,7 +275,7 @@ export function createTodoTxtTaskSource(config, context) {
258
275
  ]);
259
276
  function listTasks() {
260
277
  const updatedAt = fileUpdatedAt(todoPath);
261
- const todayIsoDate = isoDateFor(config.timezone, new Date());
278
+ const nowIsoLocal = isoDateTimeFor(config.timezone, new Date());
262
279
  const { parsedAll } = readAndParseTodo(todoPath);
263
280
  const issues = [];
264
281
  for (let i = 0; i < parsedAll.length; i++) {
@@ -266,7 +283,7 @@ export function createTodoTxtTaskSource(config, context) {
266
283
  if (parsed === null || parsed === undefined) {
267
284
  continue;
268
285
  }
269
- if (!isActiveForFetch(parsed, todayIsoDate)) {
286
+ if (!isActiveForFetch(parsed, nowIsoLocal)) {
270
287
  continue;
271
288
  }
272
289
  const issue = buildIssue({
@@ -363,7 +380,7 @@ export function createTodoTxtTaskSource(config, context) {
363
380
  async markDone(issue) {
364
381
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- TodoTxtTaskSource always writes TodoTxtSourceRef
365
382
  const ref = issue.sourceRef;
366
- const recurResult = await updateTaskStatus({ todoPath, ref }, "done");
383
+ const recurResult = await updateTaskStatus({ todoPath, ref, timezone: config.timezone }, "done");
367
384
  if (recurResult !== undefined) {
368
385
  copyPromptFile(recurResult.oldPromptPath, recurResult.newPromptPath);
369
386
  }
@@ -1,4 +1,4 @@
1
- import type { TodoTxtSourceRef } from "./normalizer.ts";
1
+ import { type TodoTxtSourceRef } from "./normalizer.ts";
2
2
  export interface RecurResult {
3
3
  newId: string;
4
4
  newTodoLine: string;
@@ -9,6 +9,8 @@ export interface UpdateOptions {
9
9
  todoPath: string;
10
10
  ref: TodoTxtSourceRef;
11
11
  now?: Date;
12
+ /** Source timezone for hour-unit recurrence wall-clock math. Defaults to UTC. */
13
+ timezone?: string;
12
14
  }
13
15
  export declare function withLock<T>(lockPath: string, fn: () => T | Promise<T>): Promise<T>;
14
16
  type StatusMutation = "in-progress" | "in-review" | "done";
@@ -1 +1 @@
1
- {"version":3,"file":"writeback.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/todo-txt/writeback.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAExD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;CACvB;AAkKD,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,gBAAgB,CAAC;IACtB,GAAG,CAAC,EAAE,IAAI,CAAC;CACZ;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAQxF;AAED,KAAK,cAAc,GAAG,aAAa,GAAG,WAAW,GAAG,MAAM,CAAC;AAgF3D,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,aAAa,EACtB,SAAS,EAAE,cAAc,GACxB,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAsElC;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAQrE;AAgGD,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAChC,MAAM,EAAE,CA0CV"}
1
+ {"version":3,"file":"writeback.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/todo-txt/writeback.ts"],"names":[],"mappings":"AAYA,OAAO,EAAyB,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAE/E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;CACvB;AAqOD,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,gBAAgB,CAAC;IACtB,GAAG,CAAC,EAAE,IAAI,CAAC;IACX,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAQxF;AAED,KAAK,cAAc,GAAG,aAAa,GAAG,WAAW,GAAG,MAAM,CAAC;AAwF3D,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,aAAa,EACtB,SAAS,EAAE,cAAc,GACxB,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CA+ElC;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAQrE;AA8GD,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAChC,MAAM,EAAE,CA0CV"}
@@ -1,6 +1,7 @@
1
1
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { DATE_RE, hashLine, parseAllLines } from "./parser.js";
4
+ import { isValidThresholdValue } from "./normalizer.js";
4
5
  function isoDate(date) {
5
6
  return date.toISOString().slice(0, 10);
6
7
  }
@@ -22,15 +23,15 @@ function addMonths(dateStr, months) {
22
23
  const d = new Date(Date.UTC(year, month - 1 + months, day));
23
24
  return isoDate(d);
24
25
  }
25
- const REC_RE = /^(?<strict>\+?)(?<amount>\d+)(?<unit>[dwmy])$/;
26
+ const REC_RE = /^(?<strict>\+?)(?<amount>\d+)(?<unit>[dwmyh])$/;
26
27
  function parseRecurrence(rec) {
27
28
  const m = REC_RE.exec(rec);
28
29
  if (m === null) {
29
30
  return undefined;
30
31
  }
31
32
  const [, strictStr, amountStr, unit] = m;
32
- /* v8 ignore next @preserve -- regex [dwmy] guarantees unit is always one of d/w/m/y */
33
- if (unit !== "d" && unit !== "w" && unit !== "m" && unit !== "y") {
33
+ /* v8 ignore next @preserve -- regex [dwmyh] guarantees unit is always one of d/w/m/y/h */
34
+ if (unit !== "d" && unit !== "w" && unit !== "m" && unit !== "y" && unit !== "h") {
34
35
  return undefined;
35
36
  }
36
37
  return {
@@ -53,11 +54,73 @@ function advanceDate(dateStr, rec) {
53
54
  }
54
55
  return addMonths(dateStr, amount * 12);
55
56
  }
57
+ // Add hours to a (timezone-naive wall-clock) datetime, minute precision.
58
+ function addHours(dateTime, hours) {
59
+ const padded = dateTime.length === 16 ? `${dateTime}:00` : dateTime;
60
+ const ms = Date.parse(`${padded}Z`);
61
+ return new Date(ms + hours * 60 * 60 * 1000).toISOString().slice(0, 16);
62
+ }
63
+ // t: may carry a datetime threshold; advance its date part and keep the time
64
+ // component so a recurring task stays scheduled at the same instant of day.
65
+ //
66
+ // Hour units advance differently: non-strict rec:Nh advances from the
67
+ // completion instant (the source-timezone wall clock), so a task that sat
68
+ // through daemon downtime re-arms N hours from now instead of stampeding
69
+ // through every missed slot. Strict rec:+Nh keeps schedule-aligned
70
+ // advancement from the previous threshold, matching due:'s strict semantics.
71
+ function advanceThreshold(threshold, rec, completionWall) {
72
+ if (rec.unit === "h") {
73
+ let base = completionWall;
74
+ if (rec.strict) {
75
+ base = threshold.length === 10 ? `${threshold}T00:00` : threshold;
76
+ }
77
+ return addHours(base, rec.amount);
78
+ }
79
+ const [datePart, timePart] = threshold.split("T");
80
+ /* v8 ignore next @preserve -- split always yields a first element */
81
+ const nextDate = advanceDate(datePart ?? threshold, rec);
82
+ return timePart === undefined ? nextDate : `${nextDate}T${timePart}`;
83
+ }
84
+ // "YYYY-MM-DDTHH:MM" wall-clock time for `now` in the given timezone.
85
+ function wallClockDateTime(timeZone, now) {
86
+ const parts = new Intl.DateTimeFormat("en-CA", {
87
+ timeZone,
88
+ year: "numeric",
89
+ month: "2-digit",
90
+ day: "2-digit",
91
+ hour: "2-digit",
92
+ minute: "2-digit",
93
+ hourCycle: "h23",
94
+ }).formatToParts(now);
95
+ const get = (type) => parts.find((part) => part.type === type)?.value;
96
+ const year = get("year");
97
+ const month = get("month");
98
+ const day = get("day");
99
+ const hour = get("hour");
100
+ const minute = get("minute");
101
+ /* v8 ignore next 3 @preserve -- Intl.DateTimeFormat with these options always returns the parts */
102
+ if (year === undefined ||
103
+ month === undefined ||
104
+ day === undefined ||
105
+ hour === undefined ||
106
+ minute === undefined) {
107
+ throw new Error(`todo-txt: could not format datetime in timezone "${timeZone}"`);
108
+ }
109
+ return `${year}-${month}-${day}T${hour}:${minute}`;
110
+ }
56
111
  function advanceId(id, newDate) {
57
112
  const dateCompact = compactDate(newDate);
58
113
  // Replace the first 8-digit run (compact date) in the id
59
114
  const replaced = id.replace(/\d{8}/, dateCompact);
60
- return replaced === id ? `${id}-${dateCompact}` : replaced;
115
+ if (replaced !== id) {
116
+ return replaced;
117
+ }
118
+ // Unchanged: either the id has no date run (append one), or the date run
119
+ // already equals the new date — same-day hourly recurrence. For the latter,
120
+ // strip any prior collision suffixes and let buildUniqueId number this
121
+ // cycle (-002, -003, …) instead of growing a suffix chain.
122
+ const base = id.replace(/(?:-\d{3})+$/, "");
123
+ return base.includes(dateCompact) ? base : `${id}-${dateCompact}`;
61
124
  }
62
125
  function buildUniqueId(baseNewId, existingIds) {
63
126
  if (existingIds.has(baseNewId.toLowerCase())) {
@@ -157,7 +220,7 @@ function assertValidTransition(newStatus, currentStatus, id) {
157
220
  throw new Error(`todo-txt: cannot mark done: task "${id}" has status "${s}"`);
158
221
  }
159
222
  }
160
- function buildRecurResult(parsed, parsedAll, originalLine, ref, completionDateStr, now) {
223
+ function buildRecurResult(parsed, parsedAll, originalLine, ref, completionDateStr, completionWallStr, now) {
161
224
  const recStr = parsed.metadata["rec"]?.[0];
162
225
  if (recStr === undefined) {
163
226
  return undefined;
@@ -173,18 +236,25 @@ function buildRecurResult(parsed, parsedAll, originalLine, ref, completionDateSt
173
236
  .filter((id) => id !== undefined));
174
237
  const oldDue = parsed.metadata["due"]?.[0];
175
238
  const oldT = parsed.metadata["t"]?.[0];
176
- // due: advances from old due (strict) or completion date (normal)
239
+ // due: advances from old due (strict) or completion date (normal). Hour
240
+ // units never advance due: — validate() rejects the combination, and a line
241
+ // that bypassed verify carries its due forward unchanged rather than
242
+ // feeding an hour recurrence into date-only math.
177
243
  /* v8 ignore next @preserve -- oldDue undefined with rec: is unusual; callers typically pair rec: with due: */
178
244
  const dueBase = rec.strict ? (oldDue ?? completionDateStr) : completionDateStr;
179
- /* v8 ignore next @preserve -- oldDue undefined means skip due advancement */
180
- const newDue = oldDue === undefined ? undefined : advanceDate(dueBase, rec);
181
- // t: always advances from its own current value by the same period
182
- const newT = oldT === undefined ? undefined : advanceDate(oldT, rec);
245
+ let newDue;
246
+ if (oldDue !== undefined) {
247
+ newDue = rec.unit === "h" ? oldDue : advanceDate(dueBase, rec);
248
+ }
249
+ // t: advances from its own current value by the same period (hour units:
250
+ // see advanceThreshold for strict vs non-strict base)
251
+ const newT = oldT === undefined ? undefined : advanceThreshold(oldT, rec, completionWallStr);
183
252
  // Compute new date for id advancement: prefer due:, then t:, so ids stay
184
- // schedule-aligned for t:-only recurring tasks
253
+ // schedule-aligned for t:-only recurring tasks. Slice to the date part —
254
+ // t: may carry a datetime.
185
255
  const newScheduleDate = newDue ?? newT;
186
256
  /* v8 ignore next @preserve -- rec: without due: or t: is unusual; id falls back to completion date */
187
- const newDateForId = newScheduleDate === undefined ? now : new Date(`${newScheduleDate}T00:00:00Z`);
257
+ const newDateForId = newScheduleDate === undefined ? now : new Date(`${newScheduleDate.slice(0, 10)}T00:00:00Z`);
188
258
  const baseNewId = advanceId(ref.id, newDateForId);
189
259
  const newId = buildUniqueId(baseNewId, existingIds);
190
260
  const newTodoLine = buildRecurringLine(originalLine, ref.id, newId, oldDue, newDue, oldT, newT);
@@ -237,8 +307,9 @@ export async function updateTaskStatus(options, newStatus) {
237
307
  let updatedLine;
238
308
  if (newStatus === "done") {
239
309
  const completionDateStr = isoDate(now);
310
+ const completionWallStr = wallClockDateTime(options.timezone ?? "UTC", now);
240
311
  updatedLine = buildDoneLine(originalLine, completionDateStr);
241
- recurResult = buildRecurResult(parsed, parsedAll, originalLine, ref, completionDateStr, now);
312
+ recurResult = buildRecurResult(parsed, parsedAll, originalLine, ref, completionDateStr, completionWallStr, now);
242
313
  }
243
314
  else {
244
315
  updatedLine = replaceStatusToken(originalLine, newStatus);
@@ -286,15 +357,24 @@ function validateDepsAndDates(parsed, parsedAll, id, prefix, errors) {
286
357
  errors.push(`${prefix}: unresolved dep "${depId}" for task "${id}"`);
287
358
  }
288
359
  }
289
- for (const dateField of ["due", "t"]) {
290
- const dateVal = parsed.metadata[dateField]?.[0];
291
- if (dateVal !== undefined && !DATE_RE.test(dateVal)) {
292
- errors.push(`${prefix}: malformed ${dateField}: date "${dateVal}" for task "${id}" (expected YYYY-MM-DD)`);
293
- }
360
+ const dueVal = parsed.metadata["due"]?.[0];
361
+ if (dueVal !== undefined && !DATE_RE.test(dueVal)) {
362
+ errors.push(`${prefix}: malformed due: date "${dueVal}" for task "${id}" (expected YYYY-MM-DD)`);
363
+ }
364
+ // t: also accepts a datetime threshold for sub-day recurring tasks.
365
+ // Calendar/clock validity is enforced for both forms — a non-calendar value
366
+ // would otherwise crash rec: advancement during markDone.
367
+ const tVal = parsed.metadata["t"]?.[0];
368
+ if (tVal !== undefined && !isValidThresholdValue(tVal)) {
369
+ errors.push(`${prefix}: malformed t: date "${tVal}" for task "${id}" (expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS])`);
294
370
  }
295
371
  const recVal = parsed.metadata["rec"]?.[0];
296
- if (recVal !== undefined && parseRecurrence(recVal) === undefined) {
297
- errors.push(`${prefix}: malformed rec: "${recVal}" for task "${id}" (expected e.g. 1d, 1w, +1m)`);
372
+ const recParsed = recVal === undefined ? undefined : parseRecurrence(recVal);
373
+ if (recParsed?.unit === "h" && (tVal === undefined || dueVal !== undefined)) {
374
+ errors.push(`${prefix}: hourly rec: "${recVal}" for task "${id}" requires a t: threshold and is incompatible with due:`);
375
+ }
376
+ if (recVal !== undefined && recParsed === undefined) {
377
+ errors.push(`${prefix}: malformed rec: "${recVal}" for task "${id}" (expected e.g. 1d, 1w, +1m, 2h)`);
298
378
  }
299
379
  }
300
380
  function validateActiveTaskLine(parsed, parsedAll, tasksDir, id, prefix, errors, knownAgents) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.32.1",
3
+ "version": "4.34.0",
4
4
  "description": "Linear-driven orchestrator that launches AI coding agents in git worktrees, with workspace lifecycle and usage tracking.",
5
5
  "keywords": [
6
6
  "agent",