@lh8ppl/claude-memory-kit 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 (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,21 @@
1
+ // Shared privacy-tag sanitizer (FR-15, design §6.6). Used by every
2
+ // disk-write hook handler (UserPromptSubmit, Stop) so the strip /
3
+ // preserve rules are byte-identical between the prompt-capture and
4
+ // turn-capture code paths.
5
+ //
6
+ // Contract:
7
+ // - <private>...</private> blocks (multiline + multi-occurrence) are
8
+ // REPLACED with the literal "[private content redacted]" placeholder.
9
+ // The original content never reaches any disk path.
10
+ // - <retain>...</retain> blocks are preserved VERBATIM (the tags
11
+ // themselves are kept) — the auto-extract subagent downstream
12
+ // uses them as force-save signals; stripping here would break
13
+ // that contract.
14
+
15
+ const PRIVATE_RE = /<private>[\s\S]*?<\/private>/g;
16
+ export const REDACTED_PLACEHOLDER = '[private content redacted]';
17
+
18
+ export function sanitizePrivacyTags(text) {
19
+ if (typeof text !== 'string' || text === '') return text;
20
+ return text.replace(PRIVATE_RE, REDACTED_PLACEHOLDER);
21
+ }
@@ -0,0 +1,217 @@
1
+ // Provenance frontmatter writer + reader (Task 13, T-011).
2
+ // Pure-functional formatting/parsing — no I/O. Two cooperating boundaries
3
+ // share the same on-disk canonical shape so write → read → write is
4
+ // byte-identical.
5
+ //
6
+ // Public surface:
7
+ // writeBullet({id, text, provenance}) → result
8
+ // - formats a 2-line bullet (bullet text on line 1, HTML-comment
9
+ // provenance on line 2) with all 7 required fields
10
+ // readBullet({bulletLine, commentLine}) → {id, text, provenance} | null
11
+ // - parses the pair; returns null on any non-match (graceful skip
12
+ // so callers iterating freeform markdown don't crash)
13
+ // parseBulletProvenance(commentLine) → provenance | null
14
+ // - just the comment parser; used by scratchpad.mjs's consolidator
15
+ // and (post-extraction) anywhere else that needs to read
16
+ // provenance from a freestanding comment line
17
+ //
18
+ // The 7 required fields per Task 13.2 / design §4:
19
+ // - id (in bullet line as `(P-XXX)`, not duplicated in comment)
20
+ // - text (the bullet body)
21
+ // - source (file path, no inline line number)
22
+ // - source_line (positive integer; separate from `source`)
23
+ // - sha1
24
+ // - write (enum)
25
+ // - trust (enum)
26
+ // - at (ISO 8601 UTC timestamp)
27
+ //
28
+ // Spec deviation: design §2.1's example uses `source: file.md:142` inline.
29
+ // This module uses `source: file.md, source_line: 142` (separate fields)
30
+ // per Task 13.2's explicit "7 required" enumeration. design.md §2.1 is
31
+ // updated in this PR to match.
32
+ //
33
+ // Uses shared modules per CLAUDE.md "Shared modules" rule:
34
+ // tier-paths.mjs — ID_PATTERN (validates the id format in the bullet line)
35
+ // result-shapes.mjs — ERROR_CATEGORIES, errorResult
36
+
37
+ import { ID_PATTERN } from './tier-paths.mjs';
38
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
39
+
40
+ const VALID_TRUST = new Set(['high', 'medium', 'low']);
41
+ const VALID_WRITE_SOURCES = new Set([
42
+ 'user-explicit',
43
+ 'auto-extract',
44
+ 'compressor',
45
+ 'manual-edit',
46
+ 'imported',
47
+ ]);
48
+ const REQUIRED_PROVENANCE_FIELDS = [
49
+ 'source',
50
+ 'source_line',
51
+ 'sha1',
52
+ 'write',
53
+ 'trust',
54
+ 'at',
55
+ ];
56
+
57
+ // PR-1 finding B2 was about newlines/colons in YAML-frontmatter scalar values.
58
+ // Layer-3 review finding B3 is the same shape with `,` as the separator: the
59
+ // HTML-comment provenance is `key: value, key: value, ...`, so a value
60
+ // containing `,` would silently inject a fake field. A `source` of
61
+ // `"Innocent, sha1: fake"` would round-trip as if it had an `sha1: fake`
62
+ // field of its own. Defensive boundary check: reject these chars in scalar
63
+ // provenance fields + the bullet text.
64
+ //
65
+ // `write` and `trust` are enums (already rejected if not in the allow-list);
66
+ // `source_line` is a number (no string-injection possible).
67
+ const UNSAFE_FOR_COMMENT = /[,\n\r]/;
68
+ const UNSAFE_FOR_BULLET_TEXT = /[\n\r]/; // commas are fine in bullet text (line 1, not the comment)
69
+ const FIELDS_TO_SANITIZE = ['source', 'sha1', 'at'];
70
+
71
+ // Match the bullet line: `- (<id>) <text>`. The id pattern is the kit's
72
+ // custom base32 alphabet from tier-paths.mjs; non-conforming ids are
73
+ // treated as "not a kit bullet" by readBullet.
74
+ const BULLET_RE = new RegExp(
75
+ `^- \\((${ID_PATTERN.source.replace(/^\^/, '').replace(/\$$/, '')})\\)\\s+(.+)$`,
76
+ );
77
+
78
+ // Match a provenance comment, tolerant of leading indentation.
79
+ const COMMENT_RE = /^\s*<!--.*-->\s*$/;
80
+
81
+ function validateBulletInput({ id, text, provenance }) {
82
+ const errors = [];
83
+
84
+ if (!id || typeof id !== 'string') {
85
+ errors.push('id: required, non-empty string');
86
+ } else if (!ID_PATTERN.test(id)) {
87
+ errors.push(
88
+ `id: must match the kit's citation-ID format (got ${JSON.stringify(id)})`,
89
+ );
90
+ }
91
+
92
+ if (!text || typeof text !== 'string' || !text.trim()) {
93
+ errors.push('text: required, non-empty string');
94
+ } else if (UNSAFE_FOR_BULLET_TEXT.test(text)) {
95
+ errors.push(
96
+ 'text: must not contain newlines (would break the 2-line bullet+comment shape; see review finding B3)',
97
+ );
98
+ }
99
+
100
+ if (!provenance || typeof provenance !== 'object') {
101
+ errors.push(
102
+ 'provenance: required object with source/source_line/sha1/write/trust/at',
103
+ );
104
+ return errors;
105
+ }
106
+
107
+ for (const f of REQUIRED_PROVENANCE_FIELDS) {
108
+ const v = provenance[f];
109
+ if (v === undefined || v === null || v === '') {
110
+ errors.push(`provenance.${f}: required, non-empty`);
111
+ }
112
+ }
113
+
114
+ if (
115
+ provenance.source_line !== undefined &&
116
+ provenance.source_line !== null &&
117
+ provenance.source_line !== ''
118
+ ) {
119
+ if (
120
+ typeof provenance.source_line !== 'number' ||
121
+ !Number.isInteger(provenance.source_line) ||
122
+ provenance.source_line < 1
123
+ ) {
124
+ errors.push(
125
+ 'provenance.source_line: must be a positive integer (number type)',
126
+ );
127
+ }
128
+ }
129
+
130
+ if (provenance.trust && !VALID_TRUST.has(provenance.trust)) {
131
+ errors.push(
132
+ `provenance.trust: must be one of high/medium/low (got ${JSON.stringify(provenance.trust)})`,
133
+ );
134
+ }
135
+
136
+ if (provenance.write && !VALID_WRITE_SOURCES.has(provenance.write)) {
137
+ errors.push(
138
+ `provenance.write: must be one of user-explicit/auto-extract/compressor/manual-edit/imported (got ${JSON.stringify(provenance.write)})`,
139
+ );
140
+ }
141
+
142
+ // B3 defense: scalar string fields that land in the comment must not contain
143
+ // `,` / `\n` / `\r`. A `,` would silently spawn a fake field on read; a
144
+ // newline would break the single-line comment shape.
145
+ for (const f of FIELDS_TO_SANITIZE) {
146
+ const v = provenance[f];
147
+ if (typeof v === 'string' && UNSAFE_FOR_COMMENT.test(v)) {
148
+ errors.push(
149
+ `provenance.${f}: must not contain commas, newlines, or carriage returns ` +
150
+ '(comment-format injection risk; see review finding B3)',
151
+ );
152
+ }
153
+ }
154
+
155
+ return errors;
156
+ }
157
+
158
+ export function writeBullet(opts = {}) {
159
+ const errors = validateBulletInput(opts);
160
+ if (errors.length > 0) {
161
+ return errorResult({
162
+ category: ERROR_CATEGORIES.SCHEMA,
163
+ errors,
164
+ });
165
+ }
166
+
167
+ const { id, text, provenance: p } = opts;
168
+ const bullet = `- (${id}) ${text}`;
169
+ // Canonical field order (matches Task 13.2 enumeration):
170
+ // source, source_line, sha1, write, trust, at
171
+ const comment =
172
+ ` <!-- source: ${p.source}, source_line: ${p.source_line},` +
173
+ ` sha1: ${p.sha1}, write: ${p.write}, trust: ${p.trust},` +
174
+ ` at: ${p.at} -->`;
175
+ return {
176
+ action: 'formatted',
177
+ id,
178
+ text,
179
+ bullet,
180
+ comment,
181
+ lines: `${bullet}\n${comment}`,
182
+ };
183
+ }
184
+
185
+ export function parseBulletProvenance(line) {
186
+ if (typeof line !== 'string') return null;
187
+ if (!COMMENT_RE.test(line)) return null;
188
+
189
+ const inner = line.replace(/^\s*<!--/, '').replace(/-->\s*$/, '');
190
+ const fields = {};
191
+ for (const part of inner.split(',')) {
192
+ const idx = part.indexOf(':');
193
+ if (idx === -1) continue;
194
+ const k = part.slice(0, idx).trim();
195
+ const v = part.slice(idx + 1).trim();
196
+ if (!k) continue;
197
+ fields[k] = v;
198
+ }
199
+ if (Object.keys(fields).length === 0) return null;
200
+
201
+ // Coerce numeric fields back to numbers for symmetric round-trip.
202
+ if (fields.source_line && /^\d+$/.test(fields.source_line)) {
203
+ fields.source_line = parseInt(fields.source_line, 10);
204
+ }
205
+ return fields;
206
+ }
207
+
208
+ export function readBullet(opts = {}) {
209
+ const { bulletLine, commentLine } = opts;
210
+ if (typeof bulletLine !== 'string') return null;
211
+ const m = bulletLine.match(BULLET_RE);
212
+ if (!m) return null;
213
+ const [, id, text] = m;
214
+ const provenance = parseBulletProvenance(commentLine);
215
+ if (!provenance) return null;
216
+ return { id, text, provenance };
217
+ }
@@ -0,0 +1,354 @@
1
+ // Cross-platform host-scheduler registration (Task 33.2, T-028).
2
+ //
3
+ // Composes the platform-native scheduler primitive on each OS so the
4
+ // daily-distill bin wrapper runs at 23:00 local time without users
5
+ // learning crontab / launchd / schtasks themselves.
6
+ //
7
+ // Per design §8.6.2:
8
+ // Linux → crontab pipe pattern (idempotent via grep -v + re-add)
9
+ // macOS → launchd plist + launchctl bootstrap
10
+ // Windows → schtasks /Create /F (force-overwrite for idempotency)
11
+ //
12
+ // Per design §8.6.3:
13
+ // Node, not Python. The kit is already Node-only; adding Python means
14
+ // new test infra + install dep. spawnSync to the platform-native
15
+ // scheduler binary is the established kit pattern (compressor.mjs,
16
+ // capture-turn.mjs).
17
+ //
18
+ // Public boundary:
19
+ // registerCron({command, options?}) → {action, platform, command,
20
+ // executed, output, error?}
21
+ // unregisterCron({options?}) → same shape
22
+ // detectPlatform() → 'linux' | 'darwin' | 'win32'
23
+ //
24
+ // `options.dryRun: true` returns the platform-detected command WITHOUT
25
+ // executing — used by tests + by users who want to inspect before
26
+ // granting host permissions. Per the kit's autopilot stop boundary
27
+ // (CLAUDE.md Workflow): "anything that touches the user's system beyond
28
+ // the repo" requires user input. Defaults to dryRun=false; tests
29
+ // always pass dryRun=true.
30
+
31
+ import { spawnSync } from 'node:child_process';
32
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { dirname, join } from 'node:path';
35
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
36
+
37
+ // Canonical entry name across platforms. Used as the grep filter on
38
+ // Linux, the LaunchAgent label on macOS, the Task Scheduler name on
39
+ // Windows. Single source of truth — never construct ad-hoc names.
40
+ export const CRON_ENTRY_NAME = 'cmk-daily-distill';
41
+
42
+ // Task 34: second entry for weekly curate. Same naming convention.
43
+ export const WEEKLY_ENTRY_NAME = 'cmk-weekly-curate';
44
+
45
+ // Default schedule: 23:00 local time. Matches design §1.4 ("Daily 23:00
46
+ // scripts/run-daily-distill.sh") + tasks.md 33.
47
+ export const DEFAULT_SCHEDULE = { hour: 23, minute: 0 };
48
+
49
+ // Default weekly schedule: Sunday 09:00 local time. Matches design §1.4
50
+ // + tasks.md 34. dayOfWeek: 0=Sunday, 1=Monday, ..., 6=Saturday (cron + launchd convention).
51
+ export const DEFAULT_WEEKLY_SCHEDULE = { hour: 9, minute: 0, dayOfWeek: 0 };
52
+
53
+ // Map dayOfWeek (0-6, Sun=0) to schtasks /D abbreviation.
54
+ const WIN_DAY_MAP = { 0: 'SUN', 1: 'MON', 2: 'TUE', 3: 'WED', 4: 'THU', 5: 'FRI', 6: 'SAT' };
55
+
56
+ export function detectPlatform() {
57
+ return process.platform; // 'linux' | 'darwin' | 'win32' (other: bsd etc.)
58
+ }
59
+
60
+ function buildLinuxCronLine({ command, entryName, hour, minute, dayOfWeek }) {
61
+ // Standard 5-field cron syntax: minute hour day-of-month month day-of-week
62
+ // The trailing `# <entry-name>` comment is what makes the entry
63
+ // grep-able for idempotency + unregistration.
64
+ // Task 34: dayOfWeek (0-6, Sun=0) optional. When set, restricts the
65
+ // job to that weekday; when omitted, runs every day (`*`).
66
+ const dow = dayOfWeek === undefined || dayOfWeek === null ? '*' : String(dayOfWeek);
67
+ return `${minute} ${hour} * * ${dow} ${command} # ${entryName}`;
68
+ }
69
+
70
+ function macOsPlistPath(entryName) {
71
+ return join(homedir(), 'Library', 'LaunchAgents', `com.cmk.${entryName}.plist`);
72
+ }
73
+
74
+ function buildMacOsPlist({ command, entryName, hour, minute, dayOfWeek }) {
75
+ // Split command on whitespace for the ProgramArguments array.
76
+ // launchd doesn't honor shell quoting — each arg is its own element.
77
+ const args = command.split(/\s+/).filter(Boolean);
78
+ const argXml = args
79
+ .map((a) => ` <string>${escapeXml(a)}</string>`)
80
+ .join('\n');
81
+ const calendarLines = [
82
+ ` <key>Hour</key><integer>${hour}</integer>`,
83
+ ` <key>Minute</key><integer>${minute}</integer>`,
84
+ ];
85
+ if (dayOfWeek !== undefined && dayOfWeek !== null) {
86
+ // launchd Weekday: 0=Sunday, 1=Monday, ..., 6=Saturday (same as cron).
87
+ calendarLines.push(` <key>Weekday</key><integer>${dayOfWeek}</integer>`);
88
+ }
89
+ return [
90
+ '<?xml version="1.0" encoding="UTF-8"?>',
91
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyLists-1.0.dtd">',
92
+ '<plist version="1.0">',
93
+ '<dict>',
94
+ ` <key>Label</key><string>com.cmk.${entryName}</string>`,
95
+ ' <key>ProgramArguments</key>',
96
+ ' <array>',
97
+ argXml,
98
+ ' </array>',
99
+ ' <key>StartCalendarInterval</key>',
100
+ ' <dict>',
101
+ ...calendarLines,
102
+ ' </dict>',
103
+ ' <key>RunAtLoad</key><false/>',
104
+ '</dict>',
105
+ '</plist>',
106
+ '',
107
+ ].join('\n');
108
+ }
109
+
110
+ function escapeXml(s) {
111
+ return String(s)
112
+ .replace(/&/g, '&amp;')
113
+ .replace(/</g, '&lt;')
114
+ .replace(/>/g, '&gt;')
115
+ .replace(/"/g, '&quot;')
116
+ .replace(/'/g, '&apos;');
117
+ }
118
+
119
+ function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
120
+ // schtasks accepts /ST as HH:mm. /F forces re-create if the task
121
+ // already exists (idempotency primitive). /RL LIMITED (not HIGHEST)
122
+ // because daily distill doesn't need admin.
123
+ const time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
124
+ // Escape inner double-quotes per schtasks /TR convention: `\"`.
125
+ // Task 33 B2 fix — pre-fix `/TR "${command}"` produced malformed
126
+ // nested quotes for any command containing `"` (typical when the
127
+ // command points at a Windows path with spaces). Skill-review caught
128
+ // this; the test fixture (cli-register-crons.test.js) didn't surface
129
+ // it because earlier tests only passed `'node bin.mjs'` (no quotes).
130
+ const escapedCommand = command.replace(/"/g, '\\"');
131
+ // Task 34: /SC WEEKLY /D <SUN|MON|...> for weekly cadence; /SC DAILY otherwise.
132
+ let scheduleFlags;
133
+ if (dayOfWeek !== undefined && dayOfWeek !== null) {
134
+ const day = WIN_DAY_MAP[dayOfWeek];
135
+ if (!day) {
136
+ throw new Error(`buildWindowsSchtasks: invalid dayOfWeek ${dayOfWeek}`);
137
+ }
138
+ scheduleFlags = `/SC WEEKLY /D ${day}`;
139
+ } else {
140
+ scheduleFlags = '/SC DAILY';
141
+ }
142
+ return `schtasks /Create /TN "${entryName}" ${scheduleFlags} /ST ${time} /TR "${escapedCommand}" /RL LIMITED /F`;
143
+ }
144
+
145
+ /**
146
+ * Register a cron entry on the current platform.
147
+ *
148
+ * @param {object} opts
149
+ * @param {string} opts.command the command to run (typically a PATH-resolved bin name)
150
+ * @param {string} [opts.entryName] the entry identifier — defaults to CRON_ENTRY_NAME ('cmk-daily-distill')
151
+ * @param {object} [opts.schedule] {hour, minute, dayOfWeek?} — defaults to {23,0}; dayOfWeek (0-6, Sun=0) restricts to that weekday
152
+ * @param {boolean} [opts.dryRun] if true, return the command(s) without executing
153
+ * @returns {object} {action, platform, executed, command, output, error?}
154
+ */
155
+ export function registerCron(opts = {}) {
156
+ const errors = [];
157
+ if (!opts.command || typeof opts.command !== 'string') {
158
+ errors.push('command: required, non-empty string');
159
+ } else if (opts.command.includes("'")) {
160
+ // Task 33 I1 fix — the Linux cron line interpolates `command`
161
+ // into a single-quoted shell string (`echo '...'`). A command
162
+ // with an embedded single quote would break the quoting + open
163
+ // a shell-injection vector. Reject at the boundary; document
164
+ // the contract. Future caller wanting single quotes in their
165
+ // cron command needs to either escape POSIX-style ('\'') or
166
+ // we extend this helper with a sanitizer (v0.1.x candidate).
167
+ errors.push("command: must not contain single quotes (Linux cron-line shell-quoting contract)");
168
+ }
169
+ const entryName = opts.entryName ?? CRON_ENTRY_NAME;
170
+ if (!entryName || typeof entryName !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(entryName)) {
171
+ errors.push("entryName: must match /^[a-zA-Z0-9_.-]+$/ (used in shell + plist + schtasks identifiers)");
172
+ }
173
+ const {
174
+ hour = DEFAULT_SCHEDULE.hour,
175
+ minute = DEFAULT_SCHEDULE.minute,
176
+ dayOfWeek,
177
+ } = opts.schedule ?? {};
178
+ if (
179
+ !Number.isInteger(hour) || hour < 0 || hour > 23 ||
180
+ !Number.isInteger(minute) || minute < 0 || minute > 59
181
+ ) {
182
+ errors.push('schedule: {hour: 0-23, minute: 0-59}');
183
+ }
184
+ if (dayOfWeek !== undefined && dayOfWeek !== null) {
185
+ if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
186
+ errors.push('schedule.dayOfWeek: must be integer 0-6 (Sun=0)');
187
+ }
188
+ }
189
+ if (errors.length > 0) {
190
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
191
+ }
192
+
193
+ const platform = detectPlatform();
194
+ const dryRun = opts.dryRun === true;
195
+
196
+ if (platform === 'linux') {
197
+ const line = buildLinuxCronLine({ command: opts.command, entryName, hour, minute, dayOfWeek });
198
+ // Idempotent: list current crontab, strip any pre-existing entry
199
+ // by name, append the new line, pipe back.
200
+ const shellCmd = `(crontab -l 2>/dev/null | grep -v '${entryName}' ; echo '${line}') | crontab -`;
201
+ if (dryRun) {
202
+ return {
203
+ action: 'dry-run',
204
+ platform,
205
+ executed: false,
206
+ command: shellCmd,
207
+ output: '',
208
+ };
209
+ }
210
+ // timeout: 10s — scheduler operations are fast; a hung crontab
211
+ // command points at a broken host config and should fail loud.
212
+ const r = spawnSync('bash', ['-c', shellCmd], { encoding: 'utf8', timeout: 10_000 });
213
+ return {
214
+ action: r.status === 0 ? 'registered' : 'error',
215
+ platform,
216
+ executed: true,
217
+ command: shellCmd,
218
+ output: (r.stdout || '') + (r.stderr || ''),
219
+ ...(r.status === 0 ? {} : { error: `crontab exit ${r.status}` }),
220
+ };
221
+ }
222
+
223
+ if (platform === 'darwin') {
224
+ const plistPath = macOsPlistPath(entryName);
225
+ const plistContent = buildMacOsPlist({ command: opts.command, entryName, hour, minute, dayOfWeek });
226
+ if (dryRun) {
227
+ return {
228
+ action: 'dry-run',
229
+ platform,
230
+ executed: false,
231
+ command: `write ${plistPath} (${plistContent.length} bytes) + launchctl bootstrap gui/$UID ${plistPath}`,
232
+ output: plistContent,
233
+ };
234
+ }
235
+ mkdirSync(dirname(plistPath), { recursive: true });
236
+ writeFileSync(plistPath, plistContent, 'utf8');
237
+ // bootout first (in case a stale entry exists), then bootstrap.
238
+ // bootout exit code is non-zero if no entry is loaded — that's
239
+ // fine, we ignore it.
240
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}/com.cmk.${entryName}`], { encoding: 'utf8', timeout: 10_000 });
241
+ const r = spawnSync('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? ''}`, plistPath], { encoding: 'utf8', timeout: 10_000 });
242
+ return {
243
+ action: r.status === 0 ? 'registered' : 'error',
244
+ platform,
245
+ executed: true,
246
+ command: `launchctl bootstrap gui/$UID ${plistPath}`,
247
+ output: (r.stdout || '') + (r.stderr || ''),
248
+ ...(r.status === 0 ? {} : { error: `launchctl exit ${r.status}` }),
249
+ };
250
+ }
251
+
252
+ if (platform === 'win32') {
253
+ const cmd = buildWindowsSchtasks({ command: opts.command, entryName, hour, minute, dayOfWeek });
254
+ if (dryRun) {
255
+ return {
256
+ action: 'dry-run',
257
+ platform,
258
+ executed: false,
259
+ command: cmd,
260
+ output: '',
261
+ };
262
+ }
263
+ // schtasks is a .exe; spawnSync handles it directly via shell:true
264
+ // (per the kit's Windows .cmd shim pattern in compressor.mjs).
265
+ const r = spawnSync(cmd, { shell: true, encoding: 'utf8', windowsHide: true, timeout: 10_000 });
266
+ return {
267
+ action: r.status === 0 ? 'registered' : 'error',
268
+ platform,
269
+ executed: true,
270
+ command: cmd,
271
+ output: (r.stdout || '') + (r.stderr || ''),
272
+ ...(r.status === 0 ? {} : { error: `schtasks exit ${r.status}` }),
273
+ };
274
+ }
275
+
276
+ return errorResult({
277
+ category: ERROR_CATEGORIES.SCHEMA,
278
+ errors: [`unsupported platform: ${platform}`],
279
+ });
280
+ }
281
+
282
+ /**
283
+ * Remove a cron entry on the current platform.
284
+ *
285
+ * @param {object} [opts]
286
+ * @param {string} [opts.entryName] the entry to remove — defaults to CRON_ENTRY_NAME
287
+ * @param {boolean} [opts.dryRun]
288
+ */
289
+ export function unregisterCron(opts = {}) {
290
+ const entryName = opts.entryName ?? CRON_ENTRY_NAME;
291
+ if (!entryName || typeof entryName !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(entryName)) {
292
+ return errorResult({
293
+ category: ERROR_CATEGORIES.SCHEMA,
294
+ errors: ["entryName: must match /^[a-zA-Z0-9_.-]+$/"],
295
+ });
296
+ }
297
+ const platform = detectPlatform();
298
+ const dryRun = opts.dryRun === true;
299
+
300
+ if (platform === 'linux') {
301
+ const shellCmd = `(crontab -l 2>/dev/null | grep -v '${entryName}') | crontab -`;
302
+ if (dryRun) {
303
+ return { action: 'dry-run', platform, executed: false, command: shellCmd, output: '' };
304
+ }
305
+ // timeout: 10s — scheduler operations are fast; a hung crontab
306
+ // command points at a broken host config and should fail loud.
307
+ const r = spawnSync('bash', ['-c', shellCmd], { encoding: 'utf8', timeout: 10_000 });
308
+ return {
309
+ action: r.status === 0 ? 'unregistered' : 'error',
310
+ platform, executed: true, command: shellCmd,
311
+ output: (r.stdout || '') + (r.stderr || ''),
312
+ ...(r.status === 0 ? {} : { error: `crontab exit ${r.status}` }),
313
+ };
314
+ }
315
+
316
+ if (platform === 'darwin') {
317
+ const plistPath = macOsPlistPath(entryName);
318
+ if (dryRun) {
319
+ return {
320
+ action: 'dry-run', platform, executed: false,
321
+ command: `launchctl bootout + rm ${plistPath}`, output: '',
322
+ };
323
+ }
324
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}/com.cmk.${entryName}`], { encoding: 'utf8', timeout: 10_000 });
325
+ if (existsSync(plistPath)) {
326
+ try { unlinkSync(plistPath); } catch { /* best-effort */ }
327
+ }
328
+ return {
329
+ action: 'unregistered', platform, executed: true,
330
+ command: `launchctl bootout + rm`, output: '',
331
+ };
332
+ }
333
+
334
+ if (platform === 'win32') {
335
+ const cmd = `schtasks /Delete /TN "${entryName}" /F`;
336
+ if (dryRun) {
337
+ return { action: 'dry-run', platform, executed: false, command: cmd, output: '' };
338
+ }
339
+ const r = spawnSync(cmd, { shell: true, encoding: 'utf8', windowsHide: true, timeout: 10_000 });
340
+ return {
341
+ // schtasks /Delete returns non-zero if the task didn't exist;
342
+ // we treat that as "already unregistered" (idempotent) since
343
+ // unregistering a non-existent entry is the intended end-state.
344
+ action: 'unregistered',
345
+ platform, executed: true, command: cmd,
346
+ output: (r.stdout || '') + (r.stderr || ''),
347
+ };
348
+ }
349
+
350
+ return errorResult({
351
+ category: ERROR_CATEGORIES.SCHEMA,
352
+ errors: [`unsupported platform: ${platform}`],
353
+ });
354
+ }