@clipboard-health/groundcrew 4.32.0 → 4.33.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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * groundcrew orchestrator — polls Linear projects and spins up workspace +
3
3
  * git-worktree pairs for ready tasks. Each tick fetches the board, runs
4
- * the cleaner, the reviewer, and the dispatcher; logging from those modules is
4
+ * the dispatcher, the reviewer, and the cleaner; logging from those modules is
5
5
  * the orchestrator's user-facing output.
6
6
  */
7
7
  export interface OrchestratorOptions {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * groundcrew orchestrator — polls Linear projects and spins up workspace +
3
3
  * git-worktree pairs for ready tasks. Each tick fetches the board, runs
4
- * the cleaner, the reviewer, and the dispatcher; logging from those modules is
4
+ * the dispatcher, the reviewer, and the cleaner; logging from those modules is
5
5
  * the orchestrator's user-facing output.
6
6
  */
7
7
  import { createBoard } from "../lib/board.js";
@@ -106,8 +106,6 @@ export async function orchestrate(options) {
106
106
  dryRun: options.dryRun,
107
107
  ...(signal === undefined ? {} : { signal }),
108
108
  };
109
- await cleaner.runOnce(tickArguments);
110
- await reviewer.runOnce(tickArguments);
111
109
  await dispatcher.runOnce({
112
110
  ...tickArguments,
113
111
  // Lazy: dispatcher only invokes this after its own early-returns, so
@@ -115,6 +113,8 @@ export async function orchestrate(options) {
115
113
  usage: async (usageSignal) => await fetchUsageOrEmpty(config, usageSignal),
116
114
  ...(idleSuffix === undefined ? {} : { idleSuffix }),
117
115
  });
116
+ await reviewer.runOnce(tickArguments);
117
+ await cleaner.runOnce(tickArguments);
118
118
  };
119
119
  await (options.watch ? runWatchLoop(tick, config) : tick());
120
120
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Per-iteration scanner that advances a task based on its worktree's pull
3
- * request state. Sits between the cleaner and the dispatcher in each
3
+ * request state. Sits after the dispatcher and before the cleaner in each
4
4
  * `orchestrate()` tick.
5
5
  *
6
6
  * - An **open** PR on an **in-progress** task → `markInReview`: frees a
@@ -15,10 +15,11 @@
15
15
  * fallback. (Linear's own GitHub integration moves merged issues to Done,
16
16
  * which groundcrew observes via `fetch()`.)
17
17
  *
18
- * The write-back lands in the task source, not the in-memory `BoardState`,
19
- * so the dispatcher in the SAME tick still sees prior state; the slot frees on
20
- * the NEXT tick's `board.fetch()`. That one-tick latency is deliberate. One
21
- * per `orchestrate()`; stateless across iterations. Mirrors `Cleaner`.
18
+ * The write-back lands in the task source, not the in-memory `BoardState`, and
19
+ * the dispatcher has already made the current tick's start decisions; the slot
20
+ * frees on the NEXT tick's `board.fetch()`. That one-tick latency is
21
+ * deliberate. One per `orchestrate()`; stateless across iterations. Mirrors
22
+ * `Cleaner`.
22
23
  */
23
24
  import type { Board } from "../lib/board.ts";
24
25
  import type { PullRequestSummary } from "../lib/pullRequests.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/commands/reviewer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,UAAU,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,KAAK,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAAC;AAE7C,UAAU,YAAY;IACpB,KAAK,EAAE,KAAK,CAAC;IACb,gBAAgB,EAAE,gBAAgB,CAAC;CACpC;AAED,sEAAsE;AACtE,UAAU,eAAe;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,UAAU,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AA+CD,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,QAAQ,CAwH3D"}
1
+ {"version":3,"file":"reviewer.d.ts","sourceRoot":"","sources":["../../src/commands/reviewer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EACL,KAAK,UAAU,EAIhB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,UAAU,EAAE;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,KAAK,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAAC;AAE7C,UAAU,YAAY;IACpB,KAAK,EAAE,KAAK,CAAC;IACb,gBAAgB,EAAE,gBAAgB,CAAC;CACpC;AAED,sEAAsE;AACtE,UAAU,eAAe;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,SAAS,aAAa,EAAE,CAAC;IAC1C,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,UAAU,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AA+CD,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG,QAAQ,CAwH3D"}
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Per-iteration scanner that advances a task based on its worktree's pull
3
- * request state. Sits between the cleaner and the dispatcher in each
3
+ * request state. Sits after the dispatcher and before the cleaner in each
4
4
  * `orchestrate()` tick.
5
5
  *
6
6
  * - An **open** PR on an **in-progress** task → `markInReview`: frees a
@@ -15,10 +15,11 @@
15
15
  * fallback. (Linear's own GitHub integration moves merged issues to Done,
16
16
  * which groundcrew observes via `fetch()`.)
17
17
  *
18
- * The write-back lands in the task source, not the in-memory `BoardState`,
19
- * so the dispatcher in the SAME tick still sees prior state; the slot frees on
20
- * the NEXT tick's `board.fetch()`. That one-tick latency is deliberate. One
21
- * per `orchestrate()`; stateless across iterations. Mirrors `Cleaner`.
18
+ * The write-back lands in the task source, not the in-memory `BoardState`, and
19
+ * the dispatcher has already made the current tick's start decisions; the slot
20
+ * frees on the NEXT tick's `board.fetch()`. That one-tick latency is
21
+ * deliberate. One per `orchestrate()`; stateless across iterations. Mirrors
22
+ * `Cleaner`.
22
23
  */
23
24
  import { naturalIdFromCanonical, } from "../lib/taskSource.js";
24
25
  import { debug, errorMessage, log, logEvent } from "../lib/util.js";
@@ -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,CAsJZ"}
@@ -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());
@@ -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({
@@ -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;
@@ -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;AA2KD,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;AAiF3D,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;AAwGD,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
  }
@@ -53,6 +54,14 @@ function advanceDate(dateStr, rec) {
53
54
  }
54
55
  return addMonths(dateStr, amount * 12);
55
56
  }
57
+ // t: may carry a datetime threshold; advance its date part and keep the time
58
+ // component so a recurring task stays scheduled at the same instant of day.
59
+ function advanceThreshold(threshold, rec) {
60
+ const [datePart, timePart] = threshold.split("T");
61
+ /* v8 ignore next @preserve -- split always yields a first element */
62
+ const nextDate = advanceDate(datePart ?? threshold, rec);
63
+ return timePart === undefined ? nextDate : `${nextDate}T${timePart}`;
64
+ }
56
65
  function advanceId(id, newDate) {
57
66
  const dateCompact = compactDate(newDate);
58
67
  // Replace the first 8-digit run (compact date) in the id
@@ -179,12 +188,13 @@ function buildRecurResult(parsed, parsedAll, originalLine, ref, completionDateSt
179
188
  /* v8 ignore next @preserve -- oldDue undefined means skip due advancement */
180
189
  const newDue = oldDue === undefined ? undefined : advanceDate(dueBase, rec);
181
190
  // t: always advances from its own current value by the same period
182
- const newT = oldT === undefined ? undefined : advanceDate(oldT, rec);
191
+ const newT = oldT === undefined ? undefined : advanceThreshold(oldT, rec);
183
192
  // Compute new date for id advancement: prefer due:, then t:, so ids stay
184
- // schedule-aligned for t:-only recurring tasks
193
+ // schedule-aligned for t:-only recurring tasks. Slice to the date part —
194
+ // t: may carry a datetime.
185
195
  const newScheduleDate = newDue ?? newT;
186
196
  /* 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`);
197
+ const newDateForId = newScheduleDate === undefined ? now : new Date(`${newScheduleDate.slice(0, 10)}T00:00:00Z`);
188
198
  const baseNewId = advanceId(ref.id, newDateForId);
189
199
  const newId = buildUniqueId(baseNewId, existingIds);
190
200
  const newTodoLine = buildRecurringLine(originalLine, ref.id, newId, oldDue, newDue, oldT, newT);
@@ -286,11 +296,16 @@ function validateDepsAndDates(parsed, parsedAll, id, prefix, errors) {
286
296
  errors.push(`${prefix}: unresolved dep "${depId}" for task "${id}"`);
287
297
  }
288
298
  }
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
- }
299
+ const dueVal = parsed.metadata["due"]?.[0];
300
+ if (dueVal !== undefined && !DATE_RE.test(dueVal)) {
301
+ errors.push(`${prefix}: malformed due: date "${dueVal}" for task "${id}" (expected YYYY-MM-DD)`);
302
+ }
303
+ // t: also accepts a datetime threshold for sub-day recurring tasks.
304
+ // Calendar/clock validity is enforced for both forms — a non-calendar value
305
+ // would otherwise crash rec: advancement during markDone.
306
+ const tVal = parsed.metadata["t"]?.[0];
307
+ if (tVal !== undefined && !isValidThresholdValue(tVal)) {
308
+ errors.push(`${prefix}: malformed t: date "${tVal}" for task "${id}" (expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS])`);
294
309
  }
295
310
  const recVal = parsed.metadata["rec"]?.[0];
296
311
  if (recVal !== undefined && parseRecurrence(recVal) === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clipboard-health/groundcrew",
3
- "version": "4.32.0",
3
+ "version": "4.33.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",