@ddtcorex/dsh-maestro-memory 1.0.1

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 (109) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +448 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +756 -0
  6. package/lib/index.d.ts +10 -0
  7. package/lib/index.d.ts.map +1 -0
  8. package/lib/index.js +627 -0
  9. package/lib/index.js.map +1 -0
  10. package/lib/memory/batch.d.ts +44 -0
  11. package/lib/memory/batch.d.ts.map +1 -0
  12. package/lib/memory/batch.js +75 -0
  13. package/lib/memory/batch.js.map +1 -0
  14. package/lib/memory/feedback.d.ts +18 -0
  15. package/lib/memory/feedback.d.ts.map +1 -0
  16. package/lib/memory/feedback.js +29 -0
  17. package/lib/memory/feedback.js.map +1 -0
  18. package/lib/memory/store.d.ts +154 -0
  19. package/lib/memory/store.d.ts.map +1 -0
  20. package/lib/memory/store.js +606 -0
  21. package/lib/memory/store.js.map +1 -0
  22. package/lib/migration/cli.d.ts +18 -0
  23. package/lib/migration/cli.d.ts.map +1 -0
  24. package/lib/migration/cli.js +143 -0
  25. package/lib/migration/cli.js.map +1 -0
  26. package/lib/migration/fixture.d.ts +45 -0
  27. package/lib/migration/fixture.d.ts.map +1 -0
  28. package/lib/migration/fixture.js +177 -0
  29. package/lib/migration/fixture.js.map +1 -0
  30. package/lib/migration/service.d.ts +75 -0
  31. package/lib/migration/service.d.ts.map +1 -0
  32. package/lib/migration/service.js +713 -0
  33. package/lib/migration/service.js.map +1 -0
  34. package/lib/prompt/snapshot.d.ts +26 -0
  35. package/lib/prompt/snapshot.d.ts.map +1 -0
  36. package/lib/prompt/snapshot.js +74 -0
  37. package/lib/prompt/snapshot.js.map +1 -0
  38. package/lib/review/queue.d.ts +72 -0
  39. package/lib/review/queue.d.ts.map +1 -0
  40. package/lib/review/queue.js +306 -0
  41. package/lib/review/queue.js.map +1 -0
  42. package/lib/skills-browser.d.ts +67 -0
  43. package/lib/skills-browser.d.ts.map +1 -0
  44. package/lib/skills-browser.js +185 -0
  45. package/lib/skills-browser.js.map +1 -0
  46. package/lib/storage/atomic-store.d.ts +211 -0
  47. package/lib/storage/atomic-store.d.ts.map +1 -0
  48. package/lib/storage/atomic-store.js +582 -0
  49. package/lib/storage/atomic-store.js.map +1 -0
  50. package/lib/storage/layout.d.ts +81 -0
  51. package/lib/storage/layout.d.ts.map +1 -0
  52. package/lib/storage/layout.js +162 -0
  53. package/lib/storage/layout.js.map +1 -0
  54. package/lib/storage/legacy-format.d.ts +104 -0
  55. package/lib/storage/legacy-format.d.ts.map +1 -0
  56. package/lib/storage/legacy-format.js +262 -0
  57. package/lib/storage/legacy-format.js.map +1 -0
  58. package/lib/sync/config.d.ts +22 -0
  59. package/lib/sync/config.d.ts.map +1 -0
  60. package/lib/sync/config.js +57 -0
  61. package/lib/sync/config.js.map +1 -0
  62. package/lib/sync/git.d.ts +97 -0
  63. package/lib/sync/git.d.ts.map +1 -0
  64. package/lib/sync/git.js +282 -0
  65. package/lib/sync/git.js.map +1 -0
  66. package/lib/sync/index.d.ts +6 -0
  67. package/lib/sync/index.d.ts.map +1 -0
  68. package/lib/sync/index.js +6 -0
  69. package/lib/sync/index.js.map +1 -0
  70. package/lib/sync/layout.d.ts +15 -0
  71. package/lib/sync/layout.d.ts.map +1 -0
  72. package/lib/sync/layout.js +33 -0
  73. package/lib/sync/layout.js.map +1 -0
  74. package/lib/sync/merge.d.ts +42 -0
  75. package/lib/sync/merge.d.ts.map +1 -0
  76. package/lib/sync/merge.js +159 -0
  77. package/lib/sync/merge.js.map +1 -0
  78. package/lib/sync/service.d.ts +70 -0
  79. package/lib/sync/service.d.ts.map +1 -0
  80. package/lib/sync/service.js +508 -0
  81. package/lib/sync/service.js.map +1 -0
  82. package/lib/todo/store.d.ts +142 -0
  83. package/lib/todo/store.d.ts.map +1 -0
  84. package/lib/todo/store.js +452 -0
  85. package/lib/todo/store.js.map +1 -0
  86. package/lib/types/client/index.d.ts +7 -0
  87. package/lib/types/client/index.d.ts.map +1 -0
  88. package/package.json +63 -0
  89. package/src/client/index.tsx +994 -0
  90. package/src/host/index.ts +591 -0
  91. package/src/host/memory/batch.ts +107 -0
  92. package/src/host/memory/feedback.ts +36 -0
  93. package/src/host/memory/store.ts +650 -0
  94. package/src/host/migration/cli.ts +139 -0
  95. package/src/host/migration/fixture.ts +218 -0
  96. package/src/host/migration/service.ts +786 -0
  97. package/src/host/prompt/snapshot.ts +95 -0
  98. package/src/host/review/queue.ts +325 -0
  99. package/src/host/skills-browser.ts +191 -0
  100. package/src/host/storage/atomic-store.ts +605 -0
  101. package/src/host/storage/layout.ts +191 -0
  102. package/src/host/storage/legacy-format.ts +308 -0
  103. package/src/host/sync/config.ts +67 -0
  104. package/src/host/sync/git.ts +280 -0
  105. package/src/host/sync/index.ts +5 -0
  106. package/src/host/sync/layout.ts +39 -0
  107. package/src/host/sync/merge.ts +188 -0
  108. package/src/host/sync/service.ts +494 -0
  109. package/src/host/todo/store.ts +480 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * storage/layout.ts — path resolution for five memory files, four todo files,
3
+ * archives, metadata, and legacy project hash.
4
+ * Pure, no Cordis import. Uses node:path + node:crypto.
5
+ */
6
+
7
+ import { createHash } from 'node:crypto'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ /** Resolve the canonical memories root: config.memoryDir or ~/.dsh/memories */
12
+ export function resolveMemoryRoot(memoryDir: string | null | undefined): string {
13
+ if (memoryDir) return memoryDir
14
+ return join(homedir(), '.dsh', 'memories')
15
+ }
16
+
17
+ /** 12-hex project hash for a cwd (sha1(cwd).slice(0,12)) */
18
+ export function projectHash(cwd: string): string {
19
+ if (!cwd) throw new Error('projectHash: cwd is required')
20
+ return createHash('sha1').update(cwd).digest('hex').slice(0, 12)
21
+ }
22
+
23
+ /** Legacy alias — same as projectHash, explicit for spec compliance. */
24
+ export const legacyProjectHash = projectHash
25
+
26
+ export function globalMemoryPath(root: string): string {
27
+ return join(root, 'MEMORY.md')
28
+ }
29
+ export function userMemoryPath(root: string): string {
30
+ return join(root, 'USER.md')
31
+ }
32
+ export function globalArchivePath(root: string): string {
33
+ return join(root, 'MEMORY-archive.md')
34
+ }
35
+ export function userArchivePath(root: string): string {
36
+ return join(root, 'USER-archive.md')
37
+ }
38
+ export function suggestionsPath(root: string): string {
39
+ return join(root, 'SUGGESTIONS.jsonl')
40
+ }
41
+ export function dailyDir(root: string): string {
42
+ return join(root, 'daily')
43
+ }
44
+ /** Reject non-YYYY-MM-DD values before they are interpolated into a path (path-traversal guard). */
45
+ function assertDate(date: string): void {
46
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
47
+ throw new Error(`invalid date "${date}" (expected YYYY-MM-DD)`)
48
+ }
49
+ }
50
+ export function dailyPath(root: string, date: string): string {
51
+ assertDate(date)
52
+ return join(root, 'daily', `${date}.md`)
53
+ }
54
+ export function dailyTodoPath(root: string, date: string): string {
55
+ assertDate(date)
56
+ return join(root, 'daily', `${date}.todo.md`)
57
+ }
58
+ export function projectDir(root: string, cwd: string): string {
59
+ return join(root, 'projects', projectHash(cwd))
60
+ }
61
+ export function projectMemoryPath(root: string, cwd: string): string {
62
+ return join(projectDir(root, cwd), 'MEMORY.md')
63
+ }
64
+ export function projectKeyPath(root: string, cwd: string): string {
65
+ return join(projectDir(root, cwd), 'KEY.md')
66
+ }
67
+ export function projectKeyArchivePath(root: string, cwd: string): string {
68
+ return join(projectDir(root, cwd), 'KEY-archive.md')
69
+ }
70
+ export function projectTodoPath(root: string, cwd: string): string {
71
+ return join(projectDir(root, cwd), 'TODOS.md')
72
+ }
73
+ export function lifeTodoPath(root: string): string {
74
+ return join(root, 'TODOS-life.md')
75
+ }
76
+ export function workTodoPath(root: string): string {
77
+ return join(root, 'TODOS-work.md')
78
+ }
79
+ export function maestroMetaDir(root: string): string {
80
+ return join(root, '.maestro-memory')
81
+ }
82
+ export function schemaPath(root: string): string {
83
+ return join(maestroMetaDir(root), 'schema.json')
84
+ }
85
+ export function journalPath(root: string): string {
86
+ return join(maestroMetaDir(root), 'migration-journal.jsonl')
87
+ }
88
+ export function backupsDir(root: string): string {
89
+ return join(maestroMetaDir(root), 'backups')
90
+ }
91
+ export function todoArchivePath(root: string): string {
92
+ return join(root, 'TODO-archive.md')
93
+ }
94
+ export function backupManifestPath(root: string, runId: string): string {
95
+ if (!runId) throw new Error('backupManifestPath: runId is required')
96
+ return join(backupsDir(root), runId, 'manifest.json')
97
+ }
98
+ export function backupFilesDirPath(root: string, runId: string): string {
99
+ if (!runId) throw new Error('backupFilesDirPath: runId is required')
100
+ return join(backupsDir(root), runId, 'files')
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Aggregated pure resolvers — convenience for inspectors/migration
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Resolve all five memory files at once (pure).
109
+ */
110
+ export function allMemoryPaths(root: string, cwd: string, date: string): {
111
+ memory: string
112
+ user: string
113
+ daily: string
114
+ project: string
115
+ key: string
116
+ } {
117
+ return {
118
+ memory: globalMemoryPath(root),
119
+ user: userMemoryPath(root),
120
+ daily: dailyPath(root, date),
121
+ project: projectMemoryPath(root, cwd),
122
+ key: projectKeyPath(root, cwd),
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Resolve all four todo files at once (pure).
128
+ */
129
+ export function allTodoPaths(root: string, cwd: string, date: string): {
130
+ life: string
131
+ work: string
132
+ project: string
133
+ daily: string
134
+ } {
135
+ return {
136
+ life: lifeTodoPath(root),
137
+ work: workTodoPath(root),
138
+ project: projectTodoPath(root, cwd),
139
+ daily: dailyTodoPath(root, date),
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Resolve all archive files at once (pure).
145
+ */
146
+ export function allArchivePaths(root: string, cwd: string): {
147
+ memory: string
148
+ user: string
149
+ key: string
150
+ todo: string
151
+ } {
152
+ return {
153
+ memory: globalArchivePath(root),
154
+ user: userArchivePath(root),
155
+ key: projectKeyArchivePath(root, cwd),
156
+ todo: todoArchivePath(root),
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Resolve metadata files at once (pure).
162
+ */
163
+ export function allMetadataPaths(root: string, runId?: string): {
164
+ metaDir: string
165
+ schema: string
166
+ journal: string
167
+ backupsDir: string
168
+ backupManifest: string | undefined
169
+ backupFilesDir: string | undefined
170
+ } {
171
+ const meta = maestroMetaDir(root)
172
+ const bDir = backupsDir(root)
173
+ return {
174
+ metaDir: meta,
175
+ schema: schemaPath(root),
176
+ journal: journalPath(root),
177
+ backupsDir: bDir,
178
+ backupManifest: runId ? backupManifestPath(root, runId) : undefined,
179
+ backupFilesDir: runId ? backupFilesDirPath(root, runId) : undefined,
180
+ }
181
+ }
182
+
183
+ // Aliases for spec compatibility (different naming conventions)
184
+ export const resolveMemoryFiles = allMemoryPaths
185
+ export const resolveTodoFiles = allTodoPaths
186
+ export const resolveArchiveFiles = allArchivePaths
187
+ export const resolveMetadataFiles = allMetadataPaths
188
+ export const getMemoryPaths = allMemoryPaths
189
+ export const getTodoPaths = allTodoPaths
190
+ export const getArchivePaths = allArchivePaths
191
+ export const getMetadataPaths = allMetadataPaths
@@ -0,0 +1,308 @@
1
+ /**
2
+ * storage/legacy-format.ts — Pure §-entry and legacy todo-tag parsing/serialization.
3
+ *
4
+ * Pure module — no framework imports. All functions are deterministic and side-effect free.
5
+ * Covers:
6
+ * - § delimiter (ENTRY_DELIMITER) parse/serialize/canonical
7
+ * - branch tags [branch:...]
8
+ * - summaries [summary:...] (header-anchored)
9
+ * - DSH-only marker
10
+ * - legacy todo-tag grammar (first-line tags + multiline content)
11
+ *
12
+ * Behaviour is byte-compatible with legacy memory files
13
+ * (previous store.js + todo.js).
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // § delimiter
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Entry delimiter, byte-compatible with MEMORY.md / USER.md. */
21
+ export const ENTRY_DELIMITER = '\n§\n'
22
+
23
+ /**
24
+ * Split raw file text into trimmed, non-empty entries.
25
+ */
26
+ export function parseEntries(text: string): string[] {
27
+ return String(text ?? '')
28
+ .split(ENTRY_DELIMITER)
29
+ .map((entry) => entry.trim())
30
+ .filter((entry) => entry.length > 0)
31
+ }
32
+
33
+ /**
34
+ * Serialize entries into canonical file text (entries joined by the
35
+ * delimiter plus a trailing newline).
36
+ */
37
+ export function serializeEntries(entries: string[]): string {
38
+ return entries.join(ENTRY_DELIMITER) + '\n'
39
+ }
40
+
41
+ /**
42
+ * Whether raw text is the canonical serialization of its own entries.
43
+ * Blank text counts as canonical (an empty store).
44
+ */
45
+ export function isCanonical(text: string): boolean {
46
+ return String(text ?? '').trim() === '' || serializeEntries(parseEntries(text)) === text
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Entry ID helpers (minimal, for header stripping)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const ENTRY_ID_RE = /^\[id:\s*[0-9a-f]{8}\]\s*/i
54
+
55
+ function stripEntryId(entry: string): string {
56
+ return String(entry ?? '').replace(ENTRY_ID_RE, '')
57
+ }
58
+
59
+ /** Extract the YYYY-MM-DD date from an entry's stamp prefix; null when absent. */
60
+ export function extractEntryDate(entry: string): string | null {
61
+ const match = /^\[(\d{4}-\d{2}-\d{2})/.exec(stripEntryId(String(entry ?? '')))
62
+ return match ? match[1] : null
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Branch tags
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Branch-scope tag inside a KEY entry: `[2026-08-06] [branch:main,dev] content`.
71
+ * Absent = visible in EVERY branch ("all").
72
+ */
73
+ export const BRANCH_TAG_RE = /(?:^\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*)?\[branch:([^\]]*)\]\s*/
74
+
75
+ /**
76
+ * Parse the branch scope of one KEY entry.
77
+ */
78
+ export function parseEntryBranches(entry: string): string[] | null {
79
+ const match = BRANCH_TAG_RE.exec(String(entry ?? ''))
80
+ if (match === null) return null
81
+ const branches = match[1].split(',').map((b) => b.trim()).filter(Boolean)
82
+ return branches.length > 0 ? branches : null
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // DSH-only marker
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export const DSH_ONLY_TAG = '[dsh-only]'
90
+ export const DSH_ONLY_RE = /\[dsh-only\]\s*/
91
+
92
+ export function parseEntryDshOnly(entry: string): boolean {
93
+ return DSH_ONLY_RE.test(String(entry ?? ''))
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Summaries
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export const SUMMARY_TAG_RE = /\[summary:([^\]]*)\]/
101
+
102
+ /**
103
+ * Regex for the entry header (program-metadata prefix): [id:...] -> timestamp
104
+ * -> [git ...] x N -> [branch:...] -> [dsh-only], matched in the known token
105
+ * order of splitEntryHead. Summary parsing and stripping are both anchored
106
+ * to this header.
107
+ */
108
+ const ENTRY_HEAD_RE =
109
+ /^(?:\[id:\s*[0-9a-f]{8}\]\s*)?(?:\[\d{4}-\d{2}-\d{2}(?: \d{1,2}:\d{2}(?::\d{2})?)?\]\s*|\[\d{1,2}:\d{2}(?::\d{2})?\]\s*)?(?:\[git [^\]]+\]\s*)*(?:\[branch:[^\]]*\]\s*)?(?:\[dsh-only\]\s*)?/i
110
+
111
+ /**
112
+ * Parse the summary tag of a memory entry (only recognizes [summary:...] at the header position).
113
+ */
114
+ export function parseEntrySummary(entry: string): string | null {
115
+ const text = String(entry ?? '')
116
+ const head = ENTRY_HEAD_RE.exec(text)
117
+ if (head === null) return null
118
+ const match = /^\[summary:([^\]]*)\]\s*/.exec(text.slice(head[0].length))
119
+ return match ? match[1] : null
120
+ }
121
+
122
+ /**
123
+ * Return the program-metadata prefix ([id]/timestamp/[git]/[branch]/[dsh-only]) of an entry.
124
+ * Useful for compact renderings that keep the header and drop the body.
125
+ */
126
+ export function entryHeadPrefix(entry: string): string {
127
+ const match = ENTRY_HEAD_RE.exec(String(entry ?? ''))
128
+ return match === null ? '' : match[0]
129
+ }
130
+
131
+ /**
132
+ * Strip the "summary" marker for display (only at header position).
133
+ */
134
+ export function stripEntrySummary(entry: string): string {
135
+ const match = ENTRY_HEAD_RE.exec(String(entry ?? ''))
136
+ const prefix = match === null ? '' : match[0]
137
+ const rest = String(entry ?? '').slice(prefix.length)
138
+ return prefix + rest.replace(/^\[summary:[^\]]*\]\s*/, '')
139
+ }
140
+
141
+ /**
142
+ * Auto-generate a summary from the entry body (fallback when no explicit [summary:...] exists).
143
+ */
144
+ export function autoSummary(entry: string, maxLen = 80): string {
145
+ let rest = String(entry ?? '').trim()
146
+ const head = ENTRY_HEAD_RE.exec(rest)
147
+ if (head !== null) rest = rest.slice(head[0].length)
148
+ rest = rest.replace(/^\[summary:[^\]]*\]\s*/, '')
149
+ const firstLine = rest.split('\n')[0].trim()
150
+ if (firstLine.length <= maxLen) return firstLine
151
+ return firstLine.slice(0, maxLen - 1) + '…'
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // splitEntryHead
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Strip all prefix markers from an entry: timestamp + `[git ...]` + `[branch:...]` + `[dsh-only]` + `[summary:...]`,
160
+ * returning the prefix head and the body. Keeps same parsing as Memory Tab pretty view.
161
+ */
162
+ export function splitEntryHead(
163
+ entry: string,
164
+ target: string,
165
+ ): { head: string; body: string } {
166
+ let rest = String(entry ?? '').trim()
167
+ const timeRe =
168
+ target === 'project'
169
+ ? /^\[(\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}(?::\d{2})?)\]\s*/
170
+ : target === 'daily'
171
+ ? /^\[(\d{1,2}:\d{2}(?::\d{2})?)\]\s*/
172
+ : /^\[(\d{4}-\d{2}-\d{2})\]\s*/
173
+ const tokens: string[] = []
174
+ const idMatch = /^\[id:\s*([0-9a-f]{8})\]\s*/i.exec(rest)
175
+ if (idMatch !== null) {
176
+ tokens.push(idMatch[0])
177
+ rest = rest.slice(idMatch[0].length)
178
+ }
179
+ const timeMatch = timeRe.exec(rest)
180
+ if (timeMatch !== null) {
181
+ tokens.push(timeMatch[0])
182
+ rest = rest.slice(timeMatch[0].length)
183
+ }
184
+ for (;;) {
185
+ const gitMatch = /^\[git [^\]]+\]\s*/.exec(rest)
186
+ if (gitMatch === null) break
187
+ tokens.push(gitMatch[0])
188
+ rest = rest.slice(gitMatch[0].length)
189
+ }
190
+ const branchMatch = /^\[branch:[^\]]*\]\s*/.exec(rest)
191
+ if (branchMatch !== null) {
192
+ tokens.push(branchMatch[0])
193
+ rest = rest.slice(branchMatch[0].length)
194
+ }
195
+ const dshOnlyMatch = /^\[dsh-only\]\s*/.exec(rest)
196
+ if (dshOnlyMatch !== null) {
197
+ tokens.push(dshOnlyMatch[0])
198
+ rest = rest.slice(dshOnlyMatch[0].length)
199
+ }
200
+ const summaryMatch = /^\[summary:[^\]]*\]\s*/.exec(rest)
201
+ if (summaryMatch !== null) {
202
+ tokens.push(summaryMatch[0])
203
+ rest = rest.slice(summaryMatch[0].length)
204
+ }
205
+ if (target === 'daily') {
206
+ const tagMatch = /^\[([^\]]+)\]\s*/.exec(rest)
207
+ if (tagMatch !== null) {
208
+ tokens.push(tagMatch[0])
209
+ rest = rest.slice(tagMatch[0].length)
210
+ }
211
+ }
212
+ return { head: tokens.join(''), body: rest }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Legacy todo grammar
217
+ // ---------------------------------------------------------------------------
218
+
219
+ export const TODO_HEADER = `<!--
220
+ Todo entry format (auto-maintained by the program, do not edit the structure manually):
221
+ - Entries are delimited by §; the comment block before the first § is the format note, not a todo
222
+ - The first line of each todo is the metadata tag line (fixed order, optional parts may be omitted):
223
+ [created time] auto-stamped by the program (e.g. [2026-08-06 21:30])
224
+ [id: 8-hex] unique identifier for the entry, operated by the dtodo tool
225
+ [q1] important & urgent [q2] important not urgent [q3] urgent not important [q4] not important not urgent (default = unclassified)
226
+ [due: YYYY-MM-DD] due date (default = none)
227
+ [status: pending|doing|done|blocked|cancelled] status (default pending)
228
+ [done: YYYY-MM-DD HH:MM] completion time (auto-stamped, only for done status)
229
+ [cat: category] optional (life/work/study...)
230
+ - Todo content follows the first tag line and may span multiple lines
231
+ -->
232
+ `
233
+
234
+ export const TODO_TARGETS = ['life', 'work', 'project', 'daily'] as const
235
+ export const TODO_STATUSES = ['pending', 'doing', 'done', 'blocked', 'cancelled'] as const
236
+
237
+ const TODO_TIME_RE = /^\[(\d{4}-\d{2}-\d{2}(?:\s\d{2}:\d{2})?)\]\s*/
238
+ const TODO_ID_RE = /\[id:\s*([0-9a-f]{8})\]/i
239
+ const TODO_QUAD_RE = /\[q([1-4])\]/i
240
+ const TODO_DUE_RE = /\[due:\s*(\d{4}-\d{2}-\d{2})\]/i
241
+ const TODO_STATUS_RE = /\[status:\s*(pending|doing|done|blocked|cancelled)\]/i
242
+ const TODO_DONE_RE = /\[done:\s*(\d{4}-\d{2}-\d{2}(?:\s\d{2}:\d{2})?)\]/i
243
+ const TODO_CAT_RE = /\[cat:\s*([^\]]+)\]/i
244
+
245
+ export interface TodoEntry {
246
+ id: string | null
247
+ time: string
248
+ quadrant: string | null
249
+ due: string | null
250
+ status: string
251
+ doneAt: string | null
252
+ cat: string | null
253
+ text: string
254
+ raw: string
255
+ }
256
+
257
+ export interface TodoMeta {
258
+ time: string
259
+ id: string
260
+ quadrant: string | null
261
+ due: string | null
262
+ status: string
263
+ cat: string | null
264
+ doneAt: string | null
265
+ }
266
+
267
+ /**
268
+ * Parse one raw todo entry into its structured form. The first line is the
269
+ * tag line (time stamp + tags); the rest is the todo's text (may be empty).
270
+ * Returns null when it has no timestamp (not a valid entry).
271
+ */
272
+ export function parseTodoEntry(raw: string): TodoEntry | null {
273
+ const text = String(raw ?? '')
274
+ const first = text.split('\n', 1)[0]
275
+ const time = TODO_TIME_RE.exec(first)
276
+ if (time === null) return null
277
+ const idMatch = TODO_ID_RE.exec(first)
278
+ const quadMatch = TODO_QUAD_RE.exec(first)
279
+ const dueMatch = TODO_DUE_RE.exec(first)
280
+ const statusMatch = TODO_STATUS_RE.exec(first)
281
+ const doneMatch = TODO_DONE_RE.exec(first)
282
+ const catMatch = TODO_CAT_RE.exec(first)
283
+ const body = text.slice(first.length).trim()
284
+ return {
285
+ id: idMatch?.[1]?.toLowerCase() ?? null,
286
+ time: time[1],
287
+ quadrant: quadMatch?.[1] ? `q${quadMatch[1].toLowerCase()}` : null,
288
+ due: dueMatch?.[1] ?? null,
289
+ status: statusMatch?.[1]?.toLowerCase() ?? 'pending',
290
+ doneAt: doneMatch?.[1] ?? null,
291
+ cat: catMatch?.[1]?.trim() || null,
292
+ text: body,
293
+ raw: text,
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Build one entry's tag line + content.
299
+ */
300
+ export function stampTodoLine(meta: TodoMeta, content: string): string {
301
+ const parts: string[] = [`[${meta.time}]`, `[id: ${meta.id}]`]
302
+ if (meta.quadrant) parts.push(`[${meta.quadrant}]`)
303
+ if (meta.due) parts.push(`[due: ${meta.due}]`)
304
+ parts.push(`[status: ${meta.status ?? 'pending'}]`)
305
+ if (meta.cat) parts.push(`[cat: ${meta.cat}]`)
306
+ if (meta.doneAt) parts.push(`[done: ${meta.doneAt}]`)
307
+ return `${parts.join(' ')}\n${String(content).trim()}`
308
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { syncConfigPath, syncDir, syncMetaPath } from './layout.ts'
4
+
5
+ export interface SyncConfig {
6
+ enabled: boolean
7
+ remoteUrl: string
8
+ branch: string
9
+ // optional override identity not used in MVP
10
+ }
11
+
12
+ export interface SyncMeta {
13
+ hash: string
14
+ cwd: string
15
+ branch: string
16
+ remoteUrl: string
17
+ pushedAt: string
18
+ // snapshot of ids per track at last sync
19
+ entryIds: Record<string, string[]>
20
+ version: number
21
+ }
22
+
23
+ export function readConfig(root: string, hash: string): SyncConfig | null {
24
+ const p = syncConfigPath(root, hash)
25
+ if (!existsSync(p)) return null
26
+ try {
27
+ const data = JSON.parse(readFileSync(p, 'utf8'))
28
+ if (data.enabled !== true) return null
29
+ if (typeof data.remoteUrl !== 'string' || typeof data.branch !== 'string') return null
30
+ return data as SyncConfig
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ export function isEnabled(root: string, hash: string): boolean {
37
+ const cfg = readConfig(root, hash)
38
+ return cfg !== null && cfg.enabled === true
39
+ }
40
+
41
+ export function writeConfig(root: string, hash: string, cfg: SyncConfig): void {
42
+ const p = syncConfigPath(root, hash)
43
+ mkdirSync(dirname(p), { recursive: true })
44
+ writeFileSync(p, JSON.stringify(cfg, null, 2), 'utf8')
45
+ }
46
+
47
+ export function clearConfig(root: string, hash: string): void {
48
+ const dir = syncDir(root, hash)
49
+ try { rmSync(dir, { recursive: true, force: true }) } catch {}
50
+ }
51
+
52
+ export function readMeta(root: string, hash: string): SyncMeta | null {
53
+ const p = syncMetaPath(root, hash)
54
+ if (!existsSync(p)) return null
55
+ try { return JSON.parse(readFileSync(p, 'utf8')) as SyncMeta } catch { return null }
56
+ }
57
+
58
+ export function writeMeta(root: string, hash: string, meta: SyncMeta): void {
59
+ const p = syncMetaPath(root, hash)
60
+ mkdirSync(dirname(p), { recursive: true })
61
+ writeFileSync(p, JSON.stringify(meta, null, 2), 'utf8')
62
+ }
63
+
64
+ export function baseIdsFromMeta(meta: SyncMeta | null, track: string): Set<string> {
65
+ if (!meta || !meta.entryIds || !meta.entryIds[track]) return new Set()
66
+ return new Set(meta.entryIds[track])
67
+ }