planrails 0.1.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +225 -0
  4. package/bin/planrails.mjs +7 -0
  5. package/docs/PLANNING_GUIDE.md +632 -0
  6. package/package.json +51 -0
  7. package/src/hooks/_lib.mjs +32 -0
  8. package/src/hooks/guard-never-delete.sh +29 -0
  9. package/src/hooks/install.mjs +173 -0
  10. package/src/hooks/plan-pre-tool.mjs +71 -0
  11. package/src/hooks/plan-session-start.mjs +46 -0
  12. package/src/hooks/plan-stop.mjs +60 -0
  13. package/src/hooks/plan-subagent-start.mjs +27 -0
  14. package/src/hooks/postcompact-journal.mjs +35 -0
  15. package/src/hooks/precompact-journal.mjs +128 -0
  16. package/src/hooks/selftest.mjs +128 -0
  17. package/src/init.mjs +155 -0
  18. package/src/issue.mjs +55 -0
  19. package/src/plan/fixtures/README.md +7 -0
  20. package/src/plan/fixtures/broken-cli.mjs +27 -0
  21. package/src/plan/fixtures/broken-hooks-root/.claude/settings.json +83 -0
  22. package/src/plan/fixtures/broken-hooks-root/.project-management/plans/.gitkeep +0 -0
  23. package/src/plan/fixtures/broken-hooks-root/CLAUDE.md +9 -0
  24. package/src/plan/fixtures/broken-root/.project-management/plans/broken/PLAN.md +4 -0
  25. package/src/plan/fixtures/broken-root/.project-management/plans/broken/gates.json +1 -0
  26. package/src/plan/fixtures/broken-root/.project-management/plans/broken/rules.json +1 -0
  27. package/src/plan/fixtures/broken-root/.project-management/plans/broken/state.json +67 -0
  28. package/src/plan/fixtures/broken-root/CLAUDE.md +3 -0
  29. package/src/plan/fixtures/broken-trial.mjs +25 -0
  30. package/src/plan/lib/brief.mjs +116 -0
  31. package/src/plan/lib/claude-md.mjs +66 -0
  32. package/src/plan/lib/glob.mjs +81 -0
  33. package/src/plan/lib/judgment.mjs +19 -0
  34. package/src/plan/lib/paths.mjs +65 -0
  35. package/src/plan/lib/schema.mjs +199 -0
  36. package/src/plan/lib/store.mjs +338 -0
  37. package/src/plan/lib/time.mjs +21 -0
  38. package/src/plan/plan.mjs +843 -0
  39. package/src/plan/run.mjs +88 -0
  40. package/src/plan/skill/SKILL.md +15 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Reading, validating and writing plan directories.
3
+ *
4
+ * Two rules live here and nowhere else:
5
+ * 1. A task is `done` only with evidence — a gate run this CLI recorded, or a
6
+ * person's word with a reason. `validatePlan` refuses anything else, whoever
7
+ * wrote the file. (CLAUDE.md: "NEVER TAKE A COMPLETION CLAIM FROM A DOCUMENT.
8
+ * RUN THE GATE." — this makes that a check instead of a sentence.)
9
+ * 2. Every cross-reference resolves: task→gate, learning→rule/gate, rule→file,
10
+ * evidence→gate run. A dangling id is how a claim goes unverified.
11
+ */
12
+ import {
13
+ existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, readdirSync,
14
+ openSync, closeSync, unlinkSync, statSync, renameSync,
15
+ } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { FILES, planDir, plansRoot, projectRoot } from "./paths.mjs";
18
+ import { SCHEMAS, formatIssues, PLAN_ID, DEFAULT_DONE_WHEN } from "./schema.mjs";
19
+ import { hoursSince } from "./time.mjs";
20
+
21
+ // ---------- low-level ---------------------------------------------------------
22
+
23
+ export function readJson(path, fallback) {
24
+ if (!existsSync(path)) return fallback;
25
+ return JSON.parse(readFileSync(path, "utf8"));
26
+ }
27
+ export function readJsonl(path) {
28
+ if (!existsSync(path)) return [];
29
+ const out = [];
30
+ const lines = readFileSync(path, "utf8").split("\n");
31
+ for (let i = 0; i < lines.length; i++) {
32
+ const l = lines[i].trim();
33
+ if (!l) continue;
34
+ try { out.push(JSON.parse(l)); } catch { throw new Error(`${path}:${i + 1} is not JSON`); }
35
+ }
36
+ return out;
37
+ }
38
+ export function appendJsonl(path, obj) {
39
+ mkdirSync(dirname(path), { recursive: true });
40
+ appendFileSync(path, JSON.stringify(obj) + "\n");
41
+ }
42
+ /** Atomic: write a temp file, then rename over the target. A crash mid-write leaves the old file intact. */
43
+ export function writeJsonAtomic(path, obj) {
44
+ mkdirSync(dirname(path), { recursive: true });
45
+ const tmp = `${path}.${process.pid}.tmp`;
46
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
47
+ renameSync(tmp, path);
48
+ }
49
+
50
+ function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
51
+
52
+ /**
53
+ * A lock around read-modify-write of state.json. Two sessions logging at once
54
+ * would otherwise lose one of the writes. Stale locks (> 30 s) are broken —
55
+ * a crashed process must not wedge every later one.
56
+ */
57
+ export function withLock(path, fn) {
58
+ const lock = `${path}.lock`;
59
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
60
+ for (let i = 0; i < 60; i++) {
61
+ try {
62
+ const fd = openSync(lock, "wx");
63
+ writeFileSync(fd, token); closeSync(fd);
64
+ // Release ONLY a lock that still carries our token: after a stale-break by another process, the file is theirs.
65
+ try { return fn(); } finally { try { if (readFileSync(lock, "utf8") === token) unlinkSync(lock); } catch { /* already gone */ } }
66
+ } catch (e) {
67
+ if (e.code !== "EEXIST") throw e;
68
+ try { if (Date.now() - statSync(lock).mtimeMs > 30_000) { unlinkSync(lock); continue; } } catch { /* raced */ }
69
+ sleepSync(50);
70
+ }
71
+ }
72
+ throw new Error(`could not lock ${path} after 3 s — another process holds ${lock}`);
73
+ }
74
+
75
+ // ---------- loading -----------------------------------------------------------
76
+
77
+ export function listPlanIds() {
78
+ const root = plansRoot();
79
+ if (!existsSync(root)) return [];
80
+ return readdirSync(root, { withFileTypes: true })
81
+ .filter((d) => d.isDirectory() && existsSync(join(root, d.name, FILES.state)))
82
+ .map((d) => d.name)
83
+ .sort();
84
+ }
85
+
86
+ /** Everything about one plan, parsed but NOT yet validated. Missing optional files read as empty. */
87
+ /**
88
+ * A state.json written before the conditions of done existed (or by an older copy of this
89
+ * system in another project) has no doneWhen / doneChecklist keys. They get their defaults
90
+ * here, on read, so no migration script is needed; the next write stores them. The schema
91
+ * declares the same defaults, but validation reads the raw file, so this is the place that
92
+ * every command actually goes through. (The trial session crashed on this on 2026-09-12.)
93
+ */
94
+ export function withStateDefaults(state) {
95
+ if (!state || typeof state !== "object") return state;
96
+ if (!Array.isArray(state.doneWhen)) state.doneWhen = structuredClone(DEFAULT_DONE_WHEN);
97
+ for (const t of state.tasks || []) { if (!Array.isArray(t.doneWhen)) t.doneWhen = []; if (!Array.isArray(t.doneChecklist)) t.doneChecklist = []; }
98
+ return state;
99
+ }
100
+
101
+ export function loadPlan(id) {
102
+ const dir = planDir(id);
103
+ if (!existsSync(join(dir, FILES.state))) throw new Error(`no plan "${id}" at ${dir}`);
104
+ const p = (f) => join(dir, f);
105
+ return {
106
+ id, dir,
107
+ state: withStateDefaults(readJson(p(FILES.state), null)),
108
+ gates: readJson(p(FILES.gates), { gates: [] }),
109
+ rules: readJson(p(FILES.rules), { rules: [] }),
110
+ log: readJsonl(p(FILES.log)),
111
+ learnings: readJsonl(p(FILES.learnings)),
112
+ decisions: readJsonl(p(FILES.decisions)),
113
+ gateRuns: readJsonl(p(FILES.gateRuns)),
114
+ planMd: existsSync(p(FILES.planMd)) ? readFileSync(p(FILES.planMd), "utf8") : "",
115
+ };
116
+ }
117
+
118
+ /** Cheap view for hooks: state + rules only (they run on every tool call). */
119
+ export function loadPlanCheap(id) {
120
+ const dir = planDir(id);
121
+ return { id, dir, state: withStateDefaults(readJson(join(dir, FILES.state), null)), rules: readJson(join(dir, FILES.rules), { rules: [] }) };
122
+ }
123
+
124
+ export function activePlanIds() {
125
+ return listPlanIds().filter((id) => { try { return readJson(join(planDir(id), FILES.state), {}).status === "active"; } catch { return false; } });
126
+ }
127
+
128
+ // ---------- validation --------------------------------------------------------
129
+
130
+ function parseOrIssues(schema, value, file, errors) {
131
+ const r = schema.safeParse(value);
132
+ if (!r.success) { errors.push(...formatIssues(file, r.error)); return null; }
133
+ return r.data;
134
+ }
135
+
136
+ /**
137
+ * Returns { errors, warnings }. Errors fail `npm run check`; warnings are printed
138
+ * by `status` and `doctor`. The split matters: a failing gate is a normal state
139
+ * mid-work (warning), a task marked done with no evidence is a lie (error).
140
+ */
141
+ export function validatePlan(plan) {
142
+ const errors = [];
143
+ const warnings = [];
144
+ const E = (m) => errors.push(`${plan.id}: ${m}`);
145
+ const W = (m) => warnings.push(`${plan.id}: ${m}`);
146
+
147
+ if (!plan.state) { E("state.json missing or empty"); return { errors, warnings }; }
148
+ const state = parseOrIssues(SCHEMAS.State, plan.state, "state.json", errors);
149
+ const gates = parseOrIssues(SCHEMAS.Gates, plan.gates, "gates.json", errors);
150
+ const rules = parseOrIssues(SCHEMAS.Rules, plan.rules, "rules.json", errors);
151
+ plan.log.forEach((e, i) => parseOrIssues(SCHEMAS.LogEntry, e, `log.jsonl:${i + 1}`, errors));
152
+ plan.learnings.forEach((e, i) => parseOrIssues(SCHEMAS.Learning, e, `learnings.jsonl:${i + 1}`, errors));
153
+ plan.decisions.forEach((e, i) => parseOrIssues(SCHEMAS.Decision, e, `decisions.jsonl:${i + 1}`, errors));
154
+ plan.gateRuns.forEach((e, i) => parseOrIssues(SCHEMAS.GateRun, e, `gate-runs.jsonl:${i + 1}`, errors));
155
+ if (!state || !gates || !rules) return { errors, warnings };
156
+
157
+ if (state.id !== plan.id) E(`state.json id "${state.id}" does not match directory name`);
158
+ if (!plan.planMd.trim()) E("PLAN.md is empty");
159
+
160
+ const dup = (arr, what) => {
161
+ const seen = new Set();
162
+ for (const x of arr) { if (seen.has(x.id)) E(`duplicate ${what} id ${x.id}`); seen.add(x.id); }
163
+ };
164
+ dup(state.tasks, "task"); dup(gates.gates, "gate"); dup(rules.rules, "rule");
165
+ dup(plan.learnings, "learning"); dup(plan.decisions, "decision");
166
+
167
+ const gateIds = new Set(gates.gates.map((g) => g.id));
168
+ const taskIds = new Set(state.tasks.map((t) => t.id));
169
+ const ruleIds = new Set(rules.rules.map((r) => r.id));
170
+ const learningIds = new Set(plan.learnings.map((l) => l.id));
171
+ const passRuns = new Map(); // runId → run (kind run, pass)
172
+ for (const r of plan.gateRuns) if (r.kind === "run" && r.result === "pass") passRuns.set(r.runId, r);
173
+ const gateById = new Map(gates.gates.map((g) => [g.id, g]));
174
+ const citedRuns = new Map(); // runId → task that cites it (a run proves ONE task)
175
+
176
+ for (const t of state.tasks) {
177
+ if (t.gate && !gateIds.has(t.gate)) E(`${t.id} names gate ${t.gate}, which gates.json does not define`);
178
+ if (t.gate && gateById.get(t.gate)?.kind === "report") E(`${t.id} names ${t.gate}, a report gate — a report informs, it never proves a task done`);
179
+ if (!t.gate && !t.manualCheck) E(`${t.id} has neither a gate nor a manualCheck — nothing can prove it done`);
180
+ for (const d of t.dependsOn) if (!taskIds.has(d)) E(`${t.id} depends on ${d}, which does not exist`);
181
+ if (t.dependsOn.includes(t.id)) E(`${t.id} depends on itself — a shifted task numbering usually causes this`);
182
+ if (t.status === "done") {
183
+ if (!t.evidence) E(`${t.id} is done with no evidence — run the gate (plan task done) or record a manual check`);
184
+ else if (t.evidence.kind === "gate") {
185
+ const run = passRuns.get(t.evidence.runId);
186
+ if (!run) E(`${t.id} cites gate run ${t.evidence.runId}, but gate-runs.jsonl has no passing run with that id`);
187
+ else {
188
+ if (run.gate !== t.evidence.gate) E(`${t.id} evidence names ${t.evidence.gate} but run ${t.evidence.runId} was ${run.gate}`);
189
+ // The run must be THIS close's run: same timestamp, after the task started, cited by no other task.
190
+ if (run.at !== t.evidence.at) E(`${t.id} evidence time ${t.evidence.at} does not match run ${run.runId} (${run.at})`);
191
+ if (t.startedAt && Date.parse(run.at) < Date.parse(t.startedAt)) E(`${t.id} cites run ${run.runId} made at ${run.at}, before the task started (${t.startedAt})`);
192
+ if (citedRuns.has(run.runId)) E(`run ${run.runId} is cited by both ${citedRuns.get(run.runId)} and ${t.id} — one run proves one task`);
193
+ citedRuns.set(run.runId, t.id);
194
+ }
195
+ if (t.gate && t.evidence.gate !== t.gate) E(`${t.id} was proved by ${t.evidence.gate} but its declared gate is ${t.gate}`);
196
+ const g = gateById.get(t.evidence.gate);
197
+ if (g?.knownFail && !verifyStatus(plan, g).verified) E(`${t.id} cites ${g.id}, which declares a known-fail case that was never verified — nothing showed the gate CAN fail; run: gate verify`);
198
+ } else if (t.evidence.kind === "manual" && t.gate) {
199
+ if (t.evidence.by !== "owner") E(`${t.id} has gate ${t.gate} but was closed by hand by the agent — only the owner may close a gated task without its gate`);
200
+ else W(`${t.id} has gate ${t.gate} but was closed by the owner's word (${t.evidence.reason.slice(0, 60)}…)`);
201
+ }
202
+ if (!t.doneAt) E(`${t.id} is done but doneAt is null`);
203
+ // The done checklist: every task-level statement must be answered; plan-level ones may have been added later (warn).
204
+ const answered = new Set(t.doneChecklist.map((c) => c.id));
205
+ if (!t.doneChecklist.length) W(`${t.id} was closed without a checklist (before doneWhen existed, or by hand) — plan review shows it`);
206
+ else {
207
+ for (const s of t.doneWhen) if (!answered.has(s.id)) E(`${t.id} is done but its own condition ${s.id} ("${s.statement.slice(0, 50)}…") was never answered`);
208
+ for (const s of state.doneWhen) {
209
+ if (answered.has(s.id)) continue;
210
+ if (s.since && t.doneAt && Date.parse(s.since) > Date.parse(t.doneAt)) continue; // added after this task closed: not held to it
211
+ W(`${t.id} was closed before plan condition ${s.id} existed`);
212
+ }
213
+ }
214
+ } else if (t.evidence) E(`${t.id} carries evidence but is ${t.status}`);
215
+ else if (t.doneChecklist.length) E(`${t.id} carries done-checklist answers but is ${t.status}`);
216
+ for (const s of t.doneWhen) if (state.doneWhen.some((p) => p.id === s.id)) E(`${t.id} redefines plan condition ${s.id}; task conditions need their own ids`);
217
+ if (t.status === "blocked" && !t.blocked) E(`${t.id} is blocked with no blocked.reason`);
218
+ if (t.status === "doing" && t.startedAt && hoursSince(t.startedAt) > 48) W(`${t.id} has been "doing" for ${Math.round(hoursSince(t.startedAt))} h — split it or log where it stands`);
219
+ }
220
+
221
+ // One writer per file: two tasks in flight that both list a file is how parallel agents clobber each other
222
+ // (CLAUDE.md: an agent reading leaves 99–112 overwrote every other leaf's layoutRule).
223
+ const doingTasks = state.tasks.filter((t) => t.status === "doing");
224
+ for (let i = 0; i < doingTasks.length; i++) for (let j = i + 1; j < doingTasks.length; j++) {
225
+ const shared = doingTasks[i].files.filter((f) => doingTasks[j].files.includes(f));
226
+ if (shared.length) W(`${doingTasks[i].id} and ${doingTasks[j].id} are both in flight and both list ${shared[0]} — one writer per file; split the files or serialise the tasks`);
227
+ }
228
+ for (const r of rules.rules) {
229
+ if (r.file && !plan.dir) continue;
230
+ if (r.file && !existsSyncSafe(join(plan.dir, r.file))) E(`rule ${r.id} points at ${r.file}, which does not exist`);
231
+ if (r.learning && !learningIds.has(r.learning)) E(`rule ${r.id} cites learning ${r.learning}, which does not exist`);
232
+ }
233
+ for (const l of plan.learnings) {
234
+ if (l.enforcement === "rule" && !(l.ref && ruleIds.has(l.ref))) E(`learning ${l.id} says it is enforced by a rule but ref "${l.ref}" is not in rules.json`);
235
+ if (l.enforcement === "gate" && !(l.ref && gateIds.has(l.ref))) E(`learning ${l.id} says it is enforced by a gate but ref "${l.ref}" is not in gates.json`);
236
+ if (l.enforcement === "docs" && !(l.ref && existsSyncSafe(join(projectRoot(), l.ref.split("#")[0])))) E(`learning ${l.id} says it is in docs but ref "${l.ref}" is not a file`);
237
+ if (l.task && !taskIds.has(l.task)) E(`learning ${l.id} names task ${l.task}, which does not exist`);
238
+ }
239
+ for (const d of plan.decisions) {
240
+ if (d.task && !taskIds.has(d.task)) E(`decision ${d.id} names task ${d.task}, which does not exist`);
241
+ if (d.supersedes && !plan.decisions.some((x) => x.id === d.supersedes)) E(`decision ${d.id} supersedes ${d.supersedes}, which does not exist`);
242
+ }
243
+ for (const e of plan.log) if (e.task && !taskIds.has(e.task)) E(`a log entry names task ${e.task}, which does not exist`);
244
+ for (const r of plan.gateRuns) if (!gateIds.has(r.gate)) W(`gate-runs.jsonl records ${r.gate}, which gates.json no longer defines`);
245
+
246
+ if (state.status === "active") {
247
+ if (!state.tasks.length) E("an active plan needs at least one task");
248
+ if (!gates.gates.length) E("an active plan needs at least one gate");
249
+ if (!state.paths.length) W("paths is empty — the Stop hook cannot watch this plan's work");
250
+ if (!state.activatedAt) E("active plan has activatedAt null");
251
+ const last = plan.log.at(-1);
252
+ if (!last) W("no log entry yet — the brief has no RESUME line");
253
+ else if (hoursSince(last.at) > 24 * 7) W(`last log entry is ${Math.round(hoursSince(last.at) / 24)} days old`);
254
+ for (const g of gates.gates) {
255
+ if (g.knownFail && !plan.gateRuns.some((r) => r.gate === g.id && r.kind === "verify" && r.result === "pass"))
256
+ W(`gate ${g.id} has a knownFail case that has never been verified — run: plan gate verify ${plan.id} ${g.id}`);
257
+ if (!g.knownFail && g.kind !== "report") W(`gate ${g.id} has no knownFail case — nothing shows it CAN fail`);
258
+ else if (g.knownFail && verifyStatus(plan, g).stale) E(`gate ${g.id}'s command changed after it was last verified — a gate edited to pass must fail its known-fail case again; run: gate verify ${g.id}`);
259
+ }
260
+ }
261
+ if (state.status === "done") {
262
+ for (const t of state.tasks) if (t.status !== "done" && t.status !== "dropped") E(`plan is done but ${t.id} is ${t.status}`);
263
+ if (!state.closedAt) E("done plan has closedAt null");
264
+ }
265
+ return { errors, warnings };
266
+ }
267
+
268
+ function existsSyncSafe(p) { try { return existsSync(p); } catch { return false; } }
269
+
270
+ export function validatePlanId(id) {
271
+ const r = PLAN_ID.safeParse(id);
272
+ if (!r.success) throw new Error(`plan id "${id}": ${r.error.issues[0].message}`);
273
+ return id;
274
+ }
275
+
276
+ // ---------- derived views -----------------------------------------------------
277
+
278
+ export function lastRunFor(plan, gateId) {
279
+ for (let i = plan.gateRuns.length - 1; i >= 0; i--) {
280
+ const r = plan.gateRuns[i];
281
+ if (r.gate === gateId && r.kind === "run") return r;
282
+ }
283
+ return null;
284
+ }
285
+ export function verifiedFor(plan, gateId) {
286
+ return plan.gateRuns.some((r) => r.gate === gateId && r.kind === "verify" && r.result === "pass");
287
+ }
288
+ /**
289
+ * Is this gate proven able to fail, for the command it has NOW? A verify run records the
290
+ * gate's main command (gateCommand); if the command changed since, the gate is stale and
291
+ * must be verified again. That makes "edit the gate so it passes" a visible act: the
292
+ * edited gate cannot close a task until its known-fail case fails again. Verify runs
293
+ * written before gateCommand existed are accepted as they are.
294
+ */
295
+ export function verifyStatus(plan, gate) {
296
+ let last = null;
297
+ for (let i = plan.gateRuns.length - 1; i >= 0; i--) { const r = plan.gateRuns[i]; if (r.gate === gate.id && r.kind === "verify" && r.result === "pass") { last = r; break; } }
298
+ const stale = Boolean(last && last.gateCommand != null && last.gateCommand !== gate.command);
299
+ return { verified: Boolean(last), stale, last };
300
+ }
301
+ export function nextId(prefix, existing) {
302
+ let max = 0;
303
+ for (const x of existing) { const n = Number(String(x.id).slice(prefix.length)); if (n > max) max = n; }
304
+ return `${prefix}${max + 1}`;
305
+ }
306
+ /**
307
+ * Evaluate the auto:* statements of a task's checklist. `gateOutcome` is
308
+ * { kind: "gate", run } for a run made in this same command, or { kind: "manual", reason, by }.
309
+ * Returns entries for the auto statements and the list of failures.
310
+ */
311
+ export function evaluateAuto(plan, task, statements, gateOutcome, { existsSyncFn = existsSync, root = projectRoot() } = {}) {
312
+ const entries = []; const failures = [];
313
+ for (const s of statements) {
314
+ if (s.kind === "manual") continue;
315
+ let ok = false, answer = "";
316
+ if (s.kind === "auto:gate") {
317
+ if (gateOutcome.kind === "gate" && gateOutcome.run?.result === "pass") { ok = true; answer = `auto: ${gateOutcome.run.gate} passed in this command (run ${gateOutcome.run.runId})`; }
318
+ else if (gateOutcome.kind === "manual") { ok = true; answer = `auto: no gate run — manual reason by ${gateOutcome.by}: ${gateOutcome.reason.slice(0, 120)}`; }
319
+ else answer = `auto: gate ${gateOutcome.run?.gate || task.gate} did not pass`;
320
+ } else if (s.kind === "auto:logged") {
321
+ const since = task.startedAt ? Date.parse(task.startedAt) : 0;
322
+ const hit = [...plan.log].reverse().find((e) => e.task === task.id && Date.parse(e.at) >= since);
323
+ ok = Boolean(hit); answer = hit ? `auto: log entry at ${hit.at}: ${hit.what.slice(0, 100)}` : `auto: no log entry names ${task.id} since it started — run: plan log <id> --task ${task.id} --what "…" --next "…"`;
324
+ } else if (s.kind === "auto:files") {
325
+ const missing = task.files.filter((f) => !existsSyncFn(join(root, f)));
326
+ ok = missing.length === 0; answer = ok ? `auto: ${task.files.length} file(s) present` : `auto: missing on disk: ${missing.join(", ")}`;
327
+ }
328
+ entries.push({ id: s.id, statement: s.statement, kind: s.kind, answer, at: null });
329
+ if (!ok) failures.push(`${s.id}: ${answer}`);
330
+ }
331
+ return { entries, failures };
332
+ }
333
+
334
+ export function taskCounts(state) {
335
+ const c = { todo: 0, doing: 0, done: 0, blocked: 0, dropped: 0 };
336
+ for (const t of state.tasks) c[t.status]++;
337
+ return c;
338
+ }
@@ -0,0 +1,21 @@
1
+ /** Local time with its UTC offset, e.g. 2026-09-12T10:32:05+05:30. Readable by people, sortable as text. */
2
+ export function nowIso(d = new Date()) {
3
+ const pad = (n, w = 2) => String(Math.abs(n)).padStart(w, "0");
4
+ const off = -d.getTimezoneOffset();
5
+ const sign = off >= 0 ? "+" : "-";
6
+ return (
7
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
8
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
9
+ `${sign}${pad(Math.floor(off / 60))}:${pad(off % 60)}`
10
+ );
11
+ }
12
+ export function today() { return nowIso().slice(0, 10); }
13
+ /** Short form for briefs: "09-12 10:32". */
14
+ export function shortStamp(iso) {
15
+ if (!iso) return "never";
16
+ return iso.slice(5, 16).replace("T", " ");
17
+ }
18
+ export function hoursSince(iso) {
19
+ const t = Date.parse(iso);
20
+ return Number.isFinite(t) ? (Date.now() - t) / 3.6e6 : Infinity;
21
+ }