@damian87/omp 0.4.1 → 0.6.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 (90) hide show
  1. package/.github/agents/researcher.md +7 -6
  2. package/.github/copilot-instructions.md +23 -0
  3. package/.github/skills/daily-log/SKILL.md +64 -0
  4. package/.github/skills/goal/SKILL.md +33 -0
  5. package/.github/skills/schedule/SKILL.md +71 -0
  6. package/README.md +19 -2
  7. package/dist/src/cli.js +272 -9
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/comms/index.d.ts +116 -0
  10. package/dist/src/comms/index.js +258 -0
  11. package/dist/src/comms/index.js.map +1 -0
  12. package/dist/src/comms/resolve-session.d.ts +35 -0
  13. package/dist/src/comms/resolve-session.js +53 -0
  14. package/dist/src/comms/resolve-session.js.map +1 -0
  15. package/dist/src/daily-log.d.ts +18 -0
  16. package/dist/src/daily-log.js +138 -0
  17. package/dist/src/daily-log.js.map +1 -0
  18. package/dist/src/goal.d.ts +4 -0
  19. package/dist/src/goal.js +44 -0
  20. package/dist/src/goal.js.map +1 -0
  21. package/dist/src/instructions-memory.d.ts +9 -0
  22. package/dist/src/instructions-memory.js +72 -0
  23. package/dist/src/instructions-memory.js.map +1 -0
  24. package/dist/src/mcp/tools/daily-log.d.ts +2 -0
  25. package/dist/src/mcp/tools/daily-log.js +148 -0
  26. package/dist/src/mcp/tools/daily-log.js.map +1 -0
  27. package/dist/src/omp-root.d.ts +1 -0
  28. package/dist/src/omp-root.js +19 -0
  29. package/dist/src/omp-root.js.map +1 -0
  30. package/dist/src/project-memory.d.ts +13 -0
  31. package/dist/src/project-memory.js +105 -0
  32. package/dist/src/project-memory.js.map +1 -0
  33. package/dist/src/schedule/commands.d.ts +34 -0
  34. package/dist/src/schedule/commands.js +130 -0
  35. package/dist/src/schedule/commands.js.map +1 -0
  36. package/dist/src/schedule/installer.d.ts +20 -0
  37. package/dist/src/schedule/installer.js +76 -0
  38. package/dist/src/schedule/installer.js.map +1 -0
  39. package/dist/src/schedule/installers/crontab.d.ts +17 -0
  40. package/dist/src/schedule/installers/crontab.js +112 -0
  41. package/dist/src/schedule/installers/crontab.js.map +1 -0
  42. package/dist/src/schedule/installers/launchd.d.ts +22 -0
  43. package/dist/src/schedule/installers/launchd.js +125 -0
  44. package/dist/src/schedule/installers/launchd.js.map +1 -0
  45. package/dist/src/schedule/installers/systemd.d.ts +15 -0
  46. package/dist/src/schedule/installers/systemd.js +136 -0
  47. package/dist/src/schedule/installers/systemd.js.map +1 -0
  48. package/dist/src/schedule/job-store.d.ts +21 -0
  49. package/dist/src/schedule/job-store.js +102 -0
  50. package/dist/src/schedule/job-store.js.map +1 -0
  51. package/dist/src/schedule/lock.d.ts +9 -0
  52. package/dist/src/schedule/lock.js +83 -0
  53. package/dist/src/schedule/lock.js.map +1 -0
  54. package/dist/src/schedule/paths.d.ts +15 -0
  55. package/dist/src/schedule/paths.js +36 -0
  56. package/dist/src/schedule/paths.js.map +1 -0
  57. package/dist/src/schedule/runner.d.ts +8 -0
  58. package/dist/src/schedule/runner.js +151 -0
  59. package/dist/src/schedule/runner.js.map +1 -0
  60. package/dist/src/schedule/types.d.ts +60 -0
  61. package/dist/src/schedule/types.js +5 -0
  62. package/dist/src/schedule/types.js.map +1 -0
  63. package/dist/src/state.d.ts +17 -0
  64. package/dist/src/state.js +101 -0
  65. package/dist/src/state.js.map +1 -0
  66. package/dist/src/trace.d.ts +19 -0
  67. package/dist/src/trace.js +74 -0
  68. package/dist/src/trace.js.map +1 -0
  69. package/dist/test/catalog.test.d.ts +1 -0
  70. package/dist/test/catalog.test.js +21 -0
  71. package/dist/test/catalog.test.js.map +1 -0
  72. package/dist/test/jira.test.d.ts +1 -0
  73. package/dist/test/jira.test.js +26 -0
  74. package/dist/test/jira.test.js.map +1 -0
  75. package/dist/test/lint.test.d.ts +1 -0
  76. package/dist/test/lint.test.js +9 -0
  77. package/dist/test/lint.test.js.map +1 -0
  78. package/dist/test/sync.test.d.ts +1 -0
  79. package/dist/test/sync.test.js +15 -0
  80. package/dist/test/sync.test.js.map +1 -0
  81. package/docs/research/2026-06-01-schedule-cron-feature.md +346 -0
  82. package/package.json +1 -1
  83. package/scripts/lib/daily-log.mjs +155 -0
  84. package/scripts/lib/hook-output.mjs +2 -1
  85. package/scripts/lib/omp-root.mjs +15 -0
  86. package/scripts/lib/project-memory.mjs +21 -0
  87. package/scripts/lib/schedule-results.mjs +88 -0
  88. package/scripts/prompt-submit.mjs +14 -2
  89. package/scripts/session-end.mjs +6 -1
  90. package/scripts/session-start.mjs +60 -2
@@ -0,0 +1,346 @@
1
+ ---
2
+ date: 2026-06-01T08:06:56+01:00
3
+ researcher: Damian Borek
4
+ git_commit: c0f9a66846698b40b4423066ce980f9a1a34b805
5
+ branch: feature/schedule-cron
6
+ repository: oh-my-copilot
7
+ topic: "How to build a local cron/`/schedule` feature for oh-my-copilot (omp)"
8
+ tags: [research, codebase, scheduling, cron, daemon, cli, mode-state, hooks, team-runtime]
9
+ status: complete
10
+ last_updated: 2026-06-01
11
+ last_updated_by: Damian Borek
12
+ ---
13
+
14
+ # Research: A local `/schedule` cron feature for oh-my-copilot
15
+
16
+ **Date**: 2026-06-01 08:06:56 +0100
17
+ **Researcher**: Damian Borek
18
+ **Git Commit**: c0f9a66846698b40b4423066ce980f9a1a34b805
19
+ **Branch**: feature/schedule-cron
20
+ **Repository**: oh-my-copilot
21
+
22
+ ## Research Question
23
+
24
+ How can we add a local cron-job scheduler to oh-my-copilot that works the same way
25
+ Claude Code's `/schedule` does — i.e. a recurring local job an agent (or the user)
26
+ can register and that fires unattended, e.g. *"check the PR every 15 minutes"*, by
27
+ spawning a fresh non-interactive agent session each tick?
28
+
29
+ This document maps (a) how Claude Code's own `/schedule` works, (b) how oh-my-copilot
30
+ is built today and where a scheduler would plug in, and (c) the concrete
31
+ implementation approaches available, with a recommendation.
32
+
33
+ > Note: per the user's explicit request ("research how we can work out this"), this
34
+ > document includes a forward-looking *Implementation Approaches* section in addition
35
+ > to documenting the current codebase. The "Detailed Findings" sections describe only
36
+ > what exists today.
37
+
38
+ ---
39
+
40
+ ## Summary
41
+
42
+ - **Claude Code's CLI `/schedule`/`/loop`/`Cron*` is an *in-process, session-scoped*
43
+ timer** — no system crontab, no launchd, no separate daemon. Jobs live in RAM in the
44
+ running CLI process, support 5-field cron expressions in local time, cap at 50 tasks,
45
+ auto-expire after 3 days, and die when the session exits. The desktop app has a
46
+ *persistent* variant (survives restart, but needs the app open); cloud "routines" run
47
+ server-side. (Sources below.)
48
+ - **oh-my-copilot has no scheduling/timer/cron code today.** The only `setTimeout` uses
49
+ are one-off sleeps and fetch-abort timers — there is no recurring scheduler, no daemon,
50
+ no crontab/launchd/systemd integration anywhere in the repo.
51
+ - **oh-my-copilot already has every building block a scheduler needs**, just not wired
52
+ for time: an imperative CLI dispatch (`src/cli.ts`), a JSON-state "mode" pattern
53
+ (`src/mode-state/*` → `.omp/state/*.json`), agent-spawning code (council spawns
54
+ `copilot --model X -p ... --allow-all-tools`; team spawns agents into tmux panes), and
55
+ a lifecycle hook system.
56
+ - **Two viable designs**: (A) a Claude-Code-parity in-process/detached **Node daemon**
57
+ holding a cron table loaded from JSON; (B) **delegate to the OS scheduler**
58
+ (crontab / launchd / systemd user timers) so jobs survive reboot with no daemon to
59
+ babysit. **Recommended default: a hybrid** — store job definitions as JSON under
60
+ `.omp/state/schedule/`, install one OS-scheduler entry per job that calls
61
+ `omp schedule run --id=<id>`, and put overlap-locking + per-run logging inside that
62
+ run handler.
63
+
64
+ ---
65
+
66
+ ## Detailed Findings
67
+
68
+ ### A. How Claude Code's `/schedule` works (external research)
69
+
70
+ Sourced via Perplexity (`sonar-pro`) against the official docs and guides
71
+ (`code.claude.com/docs/en/scheduled-tasks`, `claudefa.st`, MindStudio).
72
+
73
+ There are **three distinct scheduling models** in the Claude ecosystem; only the first
74
+ is the `/schedule`/cron the question refers to:
75
+
76
+ 1. **CLI session-scoped tasks** — `/loop`, `/schedule`, and the `CronCreate` /
77
+ `CronList` / `CronDelete` tools.
78
+ - **Mechanism**: an internal timer/wakeup loop *inside the running CLI process*.
79
+ No evidence of system crontab, launchd, or a separate daemon.
80
+ - **Storage**: in-memory only. Docs state "No persistence: restarting Claude Code
81
+ clears all session-scoped tasks." Jobs are not written to disk.
82
+ - **Cron model**: standard **5-field cron expression** (min, hour, dom, month, dow),
83
+ interpreted in **local timezone**. `/loop` accepts natural language
84
+ (`/loop 30m check the build`, `/loop check the build every 2 hours`, default
85
+ every 10 min; units `s/m/h/d`, seconds rounded up to minutes) and converts it to a
86
+ cron string passed to `CronCreate`.
87
+ - **Re-invocation**: when a job is due, the scheduler **injects the stored prompt as a
88
+ new turn into the same conversation** — it does not launch a separate OS process.
89
+ If Claude is mid-response, the job "fires once when idle, not once per missed
90
+ interval" (no catch-up) — classic in-process timer-queue behavior.
91
+ - **Limits/controls**: up to **50 tasks per session**; recurring tasks **auto-expire
92
+ after 3 days**; disable entirely with `CLAUDE_CODE_DISABLE_CRON=1`.
93
+ - **Dependency**: requires the CLI process to stay alive. Close the terminal → jobs
94
+ gone.
95
+ 2. **Desktop scheduled tasks** — persistent across app restarts, fire a *fresh session*
96
+ per tick, but require the desktop app to be open. Storage format not publicly
97
+ documented.
98
+ 3. **Cloud routines** (MindStudio) — server-side, run without your machine.
99
+
100
+ **Implication for omp**: Claude Code's *CLI* model is the simplest (an in-RAM timer),
101
+ but its biggest limitation — "dies when the session exits" — is exactly what an
102
+ unattended *"check the PR every 15 min"* job needs to avoid. To match the spirit
103
+ ("fresh agent session each tick") while being *durable*, omp should lean toward the
104
+ desktop/OS-scheduler behavior, not the ephemeral CLI behavior.
105
+
106
+ ### B. oh-my-copilot CLI dispatch — where a subcommand plugs in
107
+
108
+ `src/cli.ts` uses a **single imperative `runCli()`** that parses argv into
109
+ `[group, command, value]` and routes via if/else chains. There is **no formal command
110
+ registry** — each subcommand is a hardcoded branch.
111
+
112
+ Existing subcommand groups (representative): `version`, `list`, `setup`, `doctor`,
113
+ `launch -- <args>`, `team …`, `team api …`, `council "<q>"`, `mcp`,
114
+ `ralph start|status|cancel|tick`, `ultrawork start|status|cancel`,
115
+ `ultraqa start|status|cancel|cycle <verdict>`, `catalog …`, `project inspect`,
116
+ `skill install`, `lint:skills`, `sync:dry-run`, `jira …`.
117
+
118
+ To add a scheduler you would:
119
+ 1. Add a `schedule …` branch in `runCli()` (`src/cli.ts`).
120
+ 2. Import a handler module (new `src/schedule/*`).
121
+ 3. Parse flags with the existing `flagValue()` helper.
122
+ 4. Return the standard `CliResult` (`{ ok, exitCode, message, output? }`).
123
+ 5. Extend the help text.
124
+
125
+ This is the exact pattern `ralph`/`ultrawork`/`ultraqa` already follow.
126
+
127
+ ### C. The mode-state pattern — the closest existing analogue
128
+
129
+ `src/mode-state/{ralph,ultrawork,ultraqa}.ts` + `src/mode-state/paths.ts` implement a
130
+ **persisted-JSON state machine**, which is the nearest thing to a scheduler today:
131
+
132
+ - State is a typed object (`active`, counters, `startedAt` ISO string, `prompt`/
133
+ `objective`/`goal`, `sessionId`, `projectPath`) written to
134
+ `modeStatePath(cwd, "<mode>")` = `{cwd}/.omp/state/<mode>.json`.
135
+ - Functions follow a uniform shape: `start*` (write state), `read*` (load), `cancel*`
136
+ (delete file), and an advance function (`tickRalph`, `recordUltraqaCycle`).
137
+ - **Crucially, none of these have a timer.** "Looping" is driven externally: the caller
138
+ re-invokes `omp ralph tick`, and the `prompt-submit` hook injects the active state as
139
+ context on the *next* user prompt. They are persistence + context-injection patterns,
140
+ **not** event loops.
141
+
142
+ A `schedule.ts` mode would reuse this shape but, unlike the others, needs an *actual
143
+ time trigger* (a daemon or OS scheduler) to advance itself.
144
+
145
+ ### D. Agent-spawning mechanics — how omp launches a session today
146
+
147
+ Two existing spawn paths a scheduler could reuse:
148
+
149
+ 1. **Council (direct child process)** — `src/council/index.ts:48-50`:
150
+ ```ts
151
+ spawn(copilotBin, ["--model", req.model, "-p", req.prompt, "--allow-all-tools"],
152
+ { stdio: ["ignore", "pipe", "pipe"] })
153
+ ```
154
+ with a one-off `setTimeout` kill at `src/council/index.ts:58` to enforce `timeoutMs`.
155
+ **This is the cleanest template for a scheduled, non-interactive, captured run.**
156
+
157
+ 2. **Team (tmux panes)** — `src/team/runtime.ts` + `src/team/tmux.ts`. `startTeam()`
158
+ creates `tmux new-session -d … -s omp-team-{name}`, splits panes, then launches the
159
+ worker bin with `tmux send-keys -t {pane} -l -- {bin}` + `C-m`. `resolveWorkerBin`
160
+ picks `claude`/`codex`/`gemini`. This is good for *visible, long-lived* workers but
161
+ heavier than a scheduled tick needs.
162
+
163
+ `monitorTeam()` (`src/team/runtime.ts:211-268`) shows omp's polling idiom: a
164
+ `while` loop with `await sleep(pollInterval)` (default 1000ms, 600s timeout) — **not**
165
+ `setInterval`.
166
+
167
+ ### E. Hooks — optional integration surface
168
+
169
+ `hooks/hooks.json` registers 6 events, each `node <plugin>/scripts/<x>.mjs` with a 5s
170
+ timeout, data over stdin as JSON: `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
171
+ `PostToolUse`, `SessionEnd`, `Error`.
172
+
173
+ - `scripts/session-start.mjs` — logs + version check; returns
174
+ `hookSpecificOutput.additionalContext`.
175
+ - `scripts/prompt-submit.mjs` — reads active mode-state files and injects
176
+ "RALPH/ULTRAWORK/ULTRAQA ACTIVE …" continuation context before the prompt.
177
+
178
+ A scheduler could optionally use `session-start` to **surface due/overdue jobs** or even
179
+ opportunistically tick them, but the hooks have a **5s budget** and only fire on session
180
+ events, so they cannot be the primary time trigger.
181
+
182
+ ### F. State & path conventions
183
+
184
+ - Project-local root: `{projectRoot}/.omp/state/` (`src/copilot/paths.ts` →
185
+ `resolveCopilotPaths().stateDir`).
186
+ - Mode files: `.omp/state/{ralph,ultrawork,ultraqa}.json`.
187
+ - Team: `.omp/state/team/{name}/{config.json,tasks/*.json,workers/*/…}` with lock files
188
+ (`tasks/{id}.lock`) acquired via exclusive open (`src/team/task-store.ts`).
189
+ - Hook log: `.omp/state/hooks.log` (JSONL).
190
+
191
+ A scheduler fits naturally as `.omp/state/schedule/` (jobs + per-run logs + daemon
192
+ pidfile/locks).
193
+
194
+ ### G. Existing timer/cron inventory (grep results)
195
+
196
+ No recurring scheduling exists. Every match is a one-shot:
197
+
198
+ - `src/council/index.ts:58` — `setTimeout` kill (model spawn timeout).
199
+ - `src/team/runtime.ts:32` — `setTimeout` `sleep()` helper.
200
+ - `src/team/tmux.ts:90` — `setTimeout` `sleep()` helper.
201
+ - `scripts/lib/version-check.mjs:35` — `setTimeout` fetch-abort (3s).
202
+
203
+ No `setInterval`, `cron`, `crontab`, `launchd`, `systemd`, `node-cron`, `schedule`.
204
+
205
+ ---
206
+
207
+ ## Implementation Approaches (forward-looking — the user's explicit ask)
208
+
209
+ External research (Perplexity `sonar-pro`) compared the scheduling/persistence layer
210
+ options for a cross-platform macOS+Linux Node CLI that must survive reboot.
211
+
212
+ ### Option A — In-process / detached Node daemon (Claude-Code-parity)
213
+
214
+ A long-lived `omp schedule daemon` process holds an in-memory cron table loaded from
215
+ JSON and spawns agent sessions at each tick (via the council-style `spawn`).
216
+
217
+ - **Scheduling lib**: `croner` or `node-cron` (both lightweight, 5/6-field cron, no
218
+ built-in persistence — you load jobs from your own JSON). `node-schedule` is older;
219
+ `bree` adds a worker pool; `agenda` adds durable persistence **but requires MongoDB**
220
+ (too heavy for a CLI).
221
+ - **Daemonization**: `child_process.spawn(process.execPath, [daemonScript], { detached: true, stdio: 'ignore' }).unref()`.
222
+ - **Lifecycle**: pidfile at `.omp/state/schedule/daemon.pid`; `start`/`stop`/`status`
223
+ via signal-0 liveness checks + `SIGTERM`.
224
+ - **Pros**: cross-platform-identical behavior; full control; matches Claude Code's
225
+ "fires a fresh session" model.
226
+ - **Cons**: **does not survive reboot on its own** — you still need launchd/systemd to
227
+ relaunch the daemon at boot. You own crash recovery, logging, restart policy. More
228
+ code.
229
+
230
+ ### Option B — Delegate to the OS scheduler (durable, recommended base)
231
+
232
+ Register one OS-native entry per job; each entry runs `omp schedule run --id=<id>`.
233
+
234
+ - **Linux (systemd --user)**: write `~/.config/systemd/user/omp-<id>.{service,timer}`
235
+ (`Type=oneshot` service; timer `OnCalendar=*:0/15` or `OnUnitActiveSec=15min`,
236
+ `Persistent=true`), then `systemctl --user daemon-reload && systemctl --user enable --now omp-<id>.timer`.
237
+ - **macOS (launchd)**: write `~/Library/LaunchAgents/com.omp.<id>.plist` with
238
+ `ProgramArguments=[omp, schedule, run, --id=<id>]` and `StartInterval`/
239
+ `StartCalendarInterval`, then `launchctl bootout gui/$UID/com.omp.<id> || true` +
240
+ `launchctl bootstrap gui/$UID <plist>`.
241
+ - **Cross-platform fallback (crontab)**: manage a delimited block
242
+ (`# BEGIN omp-jobs … # END omp-jobs`) via `crontab -l` → rewrite → `crontab -`.
243
+ - **Pros**: **survives reboot for free**; battle-tested; no daemon to babysit; each run
244
+ is a clean process.
245
+ - **Cons**: three code paths to template + parse; 1-minute granularity (cron); no
246
+ built-in overlap prevention (must add locking).
247
+
248
+ ### Option C (recommended) — Hybrid: JSON job store + OS scheduler + locked run handler
249
+
250
+ Combine B's durability with omp's existing JSON-state idiom:
251
+
252
+ 1. **`omp schedule add --id=<id> --cron="*/15 * * * *" --prompt="check PR #42" [--bin copilot] [--cwd .]`**
253
+ → writes `.omp/state/schedule/jobs/<id>.json` (reusing the mode-state JSON pattern),
254
+ then installs the platform-appropriate OS entry (systemd/launchd/crontab) that calls
255
+ `omp schedule run --id=<id>`.
256
+ 2. **`omp schedule run --id=<id>`** (invoked by the OS scheduler):
257
+ - Acquire a per-job **lock file** (`.omp/state/schedule/<id>.lock`, exclusive
258
+ `open(..., 'wx')` — the same technique `task-store.ts` already uses) to **prevent
259
+ overlapping runs**; exit early if locked.
260
+ - Spawn the agent with the **council template**:
261
+ `spawn(bin, ["-p", prompt, "--allow-all-tools"], {...})`.
262
+ - Capture stdout/stderr to `.omp/state/schedule/logs/<id>/<timestamp>.log`; record
263
+ exit code + `lastRunAt`/`lastStatus` back into the job JSON.
264
+ - Release the lock in `finally`.
265
+ 3. **`omp schedule list`** → read job JSONs (source of truth) and annotate with the
266
+ installed OS entry's status.
267
+ 4. **`omp schedule remove --id=<id>`** → delete the JSON + uninstall the OS entry.
268
+ 5. **`omp schedule run-now --id=<id>`** → manual one-off trigger (same run handler).
269
+
270
+ This keeps the scheduler's *state* in omp's familiar `.omp/state/` JSON world while
271
+ delegating the *time trigger* and *reboot persistence* to the OS — and reuses the
272
+ existing exclusive-lock and child-spawn idioms already in the codebase.
273
+
274
+ ### Recommendation matrix
275
+
276
+ | Approach | Survives reboot | Daemon to babysit | New deps | Cross-platform code paths | Matches "fresh session/tick" | Effort |
277
+ |---|---|---|---|---|---|---|
278
+ | A: Node daemon (croner/node-cron) | Only if OS-launched at boot | Yes | 1 small lib | 1 (uniform) | Yes | High |
279
+ | B: OS scheduler only | **Yes** | No | None | 3 (systemd/launchd/cron) | Yes | Medium |
280
+ | **C: Hybrid (JSON store + OS scheduler + locked run)** | **Yes** | No | None | 3 (shared run handler) | Yes | Medium |
281
+ | Agenda (Mongo) | Yes | Yes | Mongo | 1 | Yes | High / heavy |
282
+
283
+ **Default recommendation: Option C.** It is the simplest design that is durable across
284
+ reboot, needs no extra runtime dependency, reuses existing omp patterns (mode-state JSON,
285
+ exclusive locks, council-style spawn), and faithfully reproduces the
286
+ "spawn a fresh agent session each tick" behavior the user asked for — without inheriting
287
+ Claude Code CLI's "dies with the session" limitation. Option A's uniform daemon can be
288
+ added later as a Windows / no-systemd fallback if needed.
289
+
290
+ ---
291
+
292
+ ## Code References
293
+
294
+ - `src/cli.ts` — imperative `runCli()` dispatch; where a `schedule` branch + help entry go.
295
+ - `src/mode-state/ralph.ts`, `ultrawork.ts`, `ultraqa.ts` — JSON-state mode pattern to mirror for `schedule.ts`.
296
+ - `src/mode-state/paths.ts` — `modeStatePath(cwd, mode)` → `.omp/state/<mode>.json`.
297
+ - `src/council/index.ts:48-50` — `spawn(copilotBin, ["--model", m, "-p", prompt, "--allow-all-tools"])`: the run-handler spawn template.
298
+ - `src/council/index.ts:58` — one-off `setTimeout` kill (per-run timeout pattern).
299
+ - `src/team/runtime.ts:211-268` — `monitorTeam()` poll loop (`await sleep()` idiom).
300
+ - `src/team/tmux.ts:55-87` — tmux session/pane/send-keys API (heavier alt spawn path).
301
+ - `src/team/task-store.ts:63-78` — exclusive lock-file acquisition (reuse for overlap prevention).
302
+ - `src/copilot/paths.ts` — `resolveCopilotPaths().stateDir` = `{projectRoot}/.omp/state`.
303
+ - `hooks/hooks.json`, `scripts/prompt-submit.mjs`, `scripts/session-start.mjs` — hook surfaces for optionally surfacing due jobs (5s budget; not a primary trigger).
304
+
305
+ ## Architecture Documentation (patterns observed)
306
+
307
+ - **CLI**: single-file imperative dispatch returning a uniform `CliResult`; subcommands
308
+ are hardcoded branches, not a registry.
309
+ - **Mode state**: typed JSON written to `.omp/state/*.json`; `start/read/cancel/advance`
310
+ functions; "looping" is external + hook-injected, never timer-driven.
311
+ - **Agent spawn**: non-interactive `-p … --allow-all-tools` via `child_process.spawn`
312
+ (council) or tmux `send-keys` (team).
313
+ - **Concurrency safety**: exclusive lock files via `open(..., 'wx')`.
314
+ - **State root**: project-local `.omp/state/` for everything persistent.
315
+
316
+ ## External References (Perplexity sonar-pro, retrieved 2026-06-01)
317
+
318
+ Claude Code scheduling:
319
+ - https://code.claude.com/docs/en/scheduled-tasks (official)
320
+ - https://claudefa.st/blog/guide/development/scheduled-tasks
321
+ - https://www.mindstudio.ai/blog/claude-code-routines-scheduled-cloud-tasks/
322
+
323
+ Node schedulers / daemon patterns:
324
+ - https://blog.logrocket.com/comparing-best-node-js-schedulers/
325
+ - https://betterstack.com/community/guides/scaling-nodejs/best-nodejs-schedulers/
326
+ - https://blog.appsignal.com/2023/09/06/job-schedulers-for-node-bull-or-agenda.html
327
+
328
+ ## Open Questions
329
+
330
+ - **Trigger surface**: should the agent register jobs from *inside* a Copilot session
331
+ (a `/schedule` skill that shells out to `omp schedule add`), from the shell CLI, or
332
+ both? (Repo convention per project memory: prefer `omp` CLI subcommands + hooks over
333
+ MCP tools.)
334
+ - **Job scope**: per-project (`.omp/state/schedule/` in cwd) vs a user-global registry
335
+ (`~/.omp/...`)? OS-scheduler entries are user-global, so cwd must be captured per job.
336
+ - **Which agent bin** is the scheduled default (`copilot` vs `claude`/`codex`/`gemini`)
337
+ and how flags are passed for non-interactive, permission-bypassed runs.
338
+ - **Output delivery**: per-run log files only, or also notify (e.g. write to a team
339
+ mailbox / append to a daily log) when a run finds something actionable?
340
+ - **Windows**: out of scope for v1 (research targeted macOS+Linux); Task Scheduler or the
341
+ Option-A daemon would be the path if needed later.
342
+
343
+ ## Related Research
344
+
345
+ - Project memory: `feedback_omp_cli_over_mcp.md` — for omp features prefer `omp` CLI
346
+ subcommands + hooks over MCP tools (informs the trigger-surface question above).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damian87/omp",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "GitHub Copilot project skills catalog and Jira handoff tools.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,155 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { ompRoot } from "./omp-root.mjs";
4
+
5
+ const DAY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
6
+ const DEFAULT_NUDGE =
7
+ 'Your last session made progress but recorded nothing in the daily log — run `omp daily-log add "<text>"` to capture what changed and any key decisions, so this session has that context.';
8
+
9
+ // A session with at least this many user prompts counts as "did real work".
10
+ const WORK_THRESHOLD = 3;
11
+
12
+ function pad(n) {
13
+ return String(n).padStart(2, "0");
14
+ }
15
+
16
+ export function todayStr(d = new Date()) {
17
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
18
+ }
19
+
20
+ function dailyDir(directory) {
21
+ return join(ompRoot(directory), ".omp", "memory", "daily");
22
+ }
23
+
24
+ function dayFile(directory, date = todayStr()) {
25
+ return join(dailyDir(directory), `${date}.md`);
26
+ }
27
+
28
+ /** Today's Goal section text, or null when unset/empty. */
29
+ export function readTodayGoal(directory) {
30
+ try {
31
+ const p = dayFile(directory);
32
+ if (!existsSync(p)) return null;
33
+ const text = readFileSync(p, "utf8");
34
+ const m = text.match(/##\s+Goal\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i);
35
+ const goal = m ? m[1].trim() : "";
36
+ return goal || null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** The repo's durable objective from .omp/goal.md, or null when unset. */
43
+ export function readRepoGoal(directory) {
44
+ try {
45
+ const p = join(ompRoot(directory), ".omp", "goal.md");
46
+ if (!existsSync(p)) return null;
47
+ const text = readFileSync(p, "utf8");
48
+ const lines = (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).split("\n");
49
+ // Strip only our own `# Repo Goal` header so a hand-authored goal isn't lost.
50
+ if (/^#\s+Repo Goal\s*$/i.test(lines[0] ?? "")) lines.shift();
51
+ const goal = lines.join("\n").trim();
52
+ return goal || null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Count day-files + total log bullets within the last `days` days (inclusive). */
59
+ export function recentEntryStats(directory, days = 7) {
60
+ try {
61
+ const dir = dailyDir(directory);
62
+ if (!existsSync(dir)) return { files: 0, entries: 0 };
63
+ const cutoff = todayStr(new Date(Date.now() - days * 86400000));
64
+ const files = readdirSync(dir).filter((f) => DAY_FILE_RE.test(f) && f.slice(0, 10) >= cutoff);
65
+ let entries = 0;
66
+ for (const f of files) {
67
+ try {
68
+ entries += (readFileSync(join(dir, f), "utf8").match(/^\s*-\s+/gm) || []).length;
69
+ } catch {
70
+ // skip unreadable day file
71
+ }
72
+ }
73
+ return { files: files.length, entries };
74
+ } catch {
75
+ return { files: 0, entries: 0 };
76
+ }
77
+ }
78
+
79
+ function statePath(directory) {
80
+ return join(ompRoot(directory), ".omp", "state", "daily-log.json");
81
+ }
82
+
83
+ function readState(directory) {
84
+ const fresh = { date: todayStr(), prompts: 0, entriesAtStart: 0, pendingNudge: false, pendingReason: "" };
85
+ try {
86
+ const p = statePath(directory);
87
+ if (!existsSync(p)) return fresh;
88
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
89
+ // Ignore valid-but-wrong JSON (null, number, array) so callers can't throw.
90
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return { ...fresh, ...parsed };
91
+ } catch {
92
+ // start fresh on read/parse failure
93
+ }
94
+ return fresh;
95
+ }
96
+
97
+ function writeState(directory, state) {
98
+ try {
99
+ const p = statePath(directory);
100
+ mkdirSync(dirname(p), { recursive: true });
101
+ const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
102
+ writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
103
+ renameSync(tmp, p);
104
+ } catch {
105
+ // best effort
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Called at SessionStart ("a new session continuing from an existing one").
111
+ * Returns a one-line flush nudge when the PRIOR session did work but logged
112
+ * nothing (else ""), then resets the per-session baseline. Never throws.
113
+ */
114
+ export function startSession(directory) {
115
+ const prior = readState(directory);
116
+ const flush = prior.pendingNudge ? prior.pendingReason || DEFAULT_NUDGE : "";
117
+ writeState(directory, {
118
+ date: todayStr(),
119
+ prompts: 0,
120
+ entriesAtStart: recentEntryStats(directory, 0).entries,
121
+ pendingNudge: false,
122
+ pendingReason: "",
123
+ });
124
+ return flush;
125
+ }
126
+
127
+ /**
128
+ * Called at UserPromptSubmit. Increments the per-session work counter.
129
+ * Deliberately does NOT reset on day rollover — startSession owns the baseline,
130
+ * so a session that spans midnight still counts all its prompts as one session.
131
+ */
132
+ export function recordPrompt(directory) {
133
+ const state = readState(directory);
134
+ state.prompts = (state.prompts || 0) + 1;
135
+ writeState(directory, state);
136
+ }
137
+
138
+ /**
139
+ * Called at SessionEnd. Arms a nudge for the NEXT SessionStart when this session
140
+ * did real work (>= WORK_THRESHOLD prompts) but added no daily-log entries.
141
+ * Never throws.
142
+ */
143
+ export function endSession(directory) {
144
+ const state = readState(directory);
145
+ const sameDay = state.date === todayStr();
146
+ const added = recentEntryStats(directory, 0).entries - (state.entriesAtStart || 0);
147
+ const didWork = (state.prompts || 0) >= WORK_THRESHOLD;
148
+ // Only arm when the session started and ended on the same calendar day. Across
149
+ // a midnight boundary the entriesAtStart baseline refers to a different
150
+ // day-file, so the delta is unreliable — stay quiet rather than risk a
151
+ // spurious nudge.
152
+ state.pendingNudge = sameDay && didWork && added <= 0;
153
+ state.pendingReason = state.pendingNudge ? DEFAULT_NUDGE : "";
154
+ writeState(directory, state);
155
+ }
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { ompRoot } from "./omp-root.mjs";
3
4
 
4
5
  export function printContinue(hookEventName, additionalContext = "") {
5
6
  const output = additionalContext
@@ -17,7 +18,7 @@ export function failOpen() {
17
18
  }
18
19
 
19
20
  export function appendHookLog(directory, hookName, payload) {
20
- const logFile = join(directory, ".omp", "state", "hooks.log");
21
+ const logFile = join(ompRoot(directory), ".omp", "state", "hooks.log");
21
22
  try {
22
23
  mkdirSync(dirname(logFile), { recursive: true });
23
24
  appendFileSync(
@@ -0,0 +1,15 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+
4
+ // Plain-Node mirror of src/omp-root.ts for the hooks: walk up from `start` to the
5
+ // nearest project marker (.git, then package.json) so memory is scoped to the
6
+ // real project, not the literal cwd. Falls back to `start` when none is found.
7
+ export function ompRoot(start) {
8
+ let dir = resolve(start);
9
+ while (true) {
10
+ if (existsSync(join(dir, ".git")) || existsSync(join(dir, "package.json"))) return dir;
11
+ const parent = dirname(dir);
12
+ if (parent === dir) return resolve(start);
13
+ dir = parent;
14
+ }
15
+ }
@@ -0,0 +1,21 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ompRoot } from "./omp-root.mjs";
4
+
5
+ /**
6
+ * Must-follow directives from .omp/project-memory.json. These are injected at
7
+ * SessionStart unconditionally — rules are never relevance-gated, so the agent
8
+ * cannot skip them. (Notes stay on-demand; only directives are pushed.)
9
+ * Best-effort, never throws.
10
+ */
11
+ export function readDirectives(directory) {
12
+ try {
13
+ const p = join(ompRoot(directory), ".omp", "project-memory.json");
14
+ if (!existsSync(p)) return [];
15
+ const data = JSON.parse(readFileSync(p, "utf8"));
16
+ const list = Array.isArray(data?.directives) ? data.directives : [];
17
+ return list.filter((d) => typeof d === "string" && d.trim() !== "").map((d) => d.trim());
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ openSync,
6
+ readFileSync,
7
+ readSync,
8
+ readdirSync,
9
+ renameSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+
15
+ function readCursor(cursorPath) {
16
+ if (!existsSync(cursorPath)) return 0;
17
+ try {
18
+ return Number(JSON.parse(readFileSync(cursorPath, "utf8")).bytesRead) || 0;
19
+ } catch {
20
+ return 0;
21
+ }
22
+ }
23
+
24
+ function advanceCursor(cursorPath, bytes) {
25
+ mkdirSync(dirname(cursorPath), { recursive: true });
26
+ const tmp = `${cursorPath}.tmp.${process.pid}.${Date.now()}`;
27
+ writeFileSync(tmp, JSON.stringify({ bytesRead: bytes }), "utf8");
28
+ renameSync(tmp, cursorPath);
29
+ }
30
+
31
+ /**
32
+ * Scan unseen scheduled-run results across all jobs and return a banner string,
33
+ * advancing each job's byte-offset cursor. Bounded by maxEntries (total) and
34
+ * maxBytes (per file) so the SessionStart hook stays within its time budget.
35
+ * Append-only: never rewrites the results JSONL — only advances cursors.
36
+ */
37
+ export function scanScheduleResults(directory, opts = {}) {
38
+ const maxEntries = opts.maxEntries ?? 10;
39
+ const maxBytes = opts.maxBytes ?? 16_384;
40
+ const resultsDir = join(directory, ".omp", "state", "schedule", "results");
41
+ if (!existsSync(resultsDir)) return "";
42
+
43
+ const lines = [];
44
+ let budget = maxEntries;
45
+
46
+ for (const entry of readdirSync(resultsDir, { withFileTypes: true })) {
47
+ if (budget <= 0) break;
48
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
49
+ const id = entry.name.slice(0, -".jsonl".length);
50
+ const resultsPath = join(resultsDir, entry.name);
51
+ const cursorPath = join(resultsDir, `${id}.offset`);
52
+
53
+ let cursor = readCursor(cursorPath);
54
+ const size = statSync(resultsPath).size;
55
+ if (cursor > size) cursor = 0; // truncated/rotated → re-read from start
56
+ if (cursor >= size) continue;
57
+
58
+ const remaining = Math.min(size - cursor, maxBytes);
59
+ const fd = openSync(resultsPath, "r");
60
+ const buf = Buffer.alloc(remaining);
61
+ try {
62
+ readSync(fd, buf, 0, remaining, cursor);
63
+ } finally {
64
+ closeSync(fd);
65
+ }
66
+ const text = buf.toString("utf8");
67
+ const lastNewline = text.lastIndexOf("\n");
68
+ if (lastNewline === -1) continue;
69
+
70
+ const completeLines = text.slice(0, lastNewline + 1).split("\n").filter(Boolean);
71
+ let consumedBytes = 0;
72
+ for (const line of completeLines) {
73
+ if (budget <= 0) break;
74
+ consumedBytes += Buffer.byteLength(`${line}\n`, "utf8");
75
+ budget -= 1;
76
+ try {
77
+ const r = JSON.parse(line);
78
+ const summary = String(r.summary ?? "").slice(0, 100);
79
+ lines.push(`- ${id} @ ${r.ts}: ${r.status} — ${summary}`);
80
+ } catch {
81
+ // skip unparseable line but still advance past it
82
+ }
83
+ }
84
+ advanceCursor(cursorPath, cursor + consumedBytes);
85
+ }
86
+
87
+ return lines.length ? `[SCHEDULE RESULTS]\n${lines.join("\n")}` : "";
88
+ }