@try-works/dsh-memory 0.1.19 → 0.1.21

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 (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +294 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/scripts/inspect-log.ts +40 -0
  8. package/scripts/repair-ignorable.ts +130 -0
  9. package/scripts/scan-sessions.ts +43 -0
  10. package/scripts/verify-log.ts +26 -0
  11. package/src/SYSTEM.template.md +311 -311
  12. package/src/capture.js +242 -242
  13. package/src/client/index.js +886 -886
  14. package/src/config.js +57 -57
  15. package/src/consolidate-run.js +132 -132
  16. package/src/consolidate.js +330 -330
  17. package/src/context.js +88 -88
  18. package/src/decisions.js +106 -106
  19. package/src/defaults.js +93 -93
  20. package/src/digest.js +255 -255
  21. package/src/entry.js +179 -179
  22. package/src/eval.js +72 -72
  23. package/src/files.js +60 -60
  24. package/src/fixture.js +56 -56
  25. package/src/forget.js +71 -71
  26. package/src/freshness.js +65 -65
  27. package/src/fts.js +166 -166
  28. package/src/git.js +53 -53
  29. package/src/history.js +226 -226
  30. package/src/host.js +884 -866
  31. package/src/index.js +115 -94
  32. package/src/inject.js +108 -108
  33. package/src/layers.js +178 -178
  34. package/src/ledger.js +19 -19
  35. package/src/map.js +184 -184
  36. package/src/models.js +136 -136
  37. package/src/queue.js +602 -602
  38. package/src/rank.js +31 -31
  39. package/src/recover.js +91 -91
  40. package/src/refresh.js +80 -80
  41. package/src/remember.js +58 -58
  42. package/src/rerank.js +90 -90
  43. package/src/search.js +75 -75
  44. package/src/select.js +57 -57
  45. package/src/session.js +60 -60
  46. package/src/state.js +240 -240
  47. package/src/store.js +128 -128
  48. package/src/summarize.js +195 -195
  49. package/src/system-doc.js +171 -171
  50. package/src/tasks.js +617 -617
  51. package/src/telemetry.js +122 -122
  52. package/src/tools.js +828 -818
  53. package/src/working.js +288 -288
  54. package/src/workspace-key.js +68 -68
  55. package/src/workspace.js +87 -87
package/src/state.js CHANGED
@@ -1,240 +1,240 @@
1
- // Workspace state: a short, readable overview of what this repository IS right now.
2
- //
3
- // This is the one layer whose value is entirely in being CURRENT. A fact can be a month old and
4
- // still true; a description of "what is running" or "what is in flight" cannot. So state is
5
- // treated differently from every other layer in three ways:
6
- //
7
- // 1. It is REPLACED per section, not appended. State describes now, so a newer statement about
8
- // the same section supersedes the old one instead of piling up beside it.
9
- // 2. Every section carries an explicit **Verified:** timestamp. Freshness is not inferred from
10
- // an entry date - it is part of the section, written by the machine that touched it.
11
- // 3. Freshness is graded and always surfaced. A stale section is shown WITH its staleness, so
12
- // an agent reads it as a hypothesis to check rather than a fact to trust.
13
- //
14
- // It is workspace-only: a repository state file in the global layer would describe no repository.
15
- // It is a single file, not a layered kind - an overview that needs an index is not an overview.
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
- import { join } from 'node:path'
18
- import { workspaceDir } from './store.js'
19
-
20
- /** A section verified within this window is current. */
21
- export const STATE_FRESH_MS = 6 * 3600_000
22
- /** Past this, a section describes a repository that has probably moved on. */
23
- export const STATE_STALE_MS = 72 * 3600_000
24
-
25
- /** Suggested sections. Any name is allowed; these keep separate agents consistent. */
26
- export const STATE_SECTIONS = ['Summary', 'Layout', 'Running', 'Pending', 'Health']
27
-
28
- /** Absolute path of a workspace state file. */
29
- export function statePath(root, key) { return join(workspaceDir(root, key), 'state.md') }
30
-
31
- /** True when a section name is usable as a heading. */
32
- export function isValidSectionName(name) {
33
- const text = String(name ?? '').trim()
34
- return text !== '' && text.length <= 60 && !/[\r\n]/.test(text) && !text.startsWith('#')
35
- }
36
-
37
- /** Canonical display form of a section name: trimmed, first letter upper-cased. */
38
- export function sectionTitle(name) {
39
- const text = String(name ?? '').trim().replace(/\s+/g, ' ')
40
- if (text === '') return ''
41
- return text[0].toUpperCase() + text.slice(1)
42
- }
43
-
44
- /** Matching key for a section name, so "summary" and "Summary" are the same section. */
45
- export function sectionKey(name) { return String(name ?? '').trim().toLowerCase() }
46
-
47
- /**
48
- * Make content safe to embed: a line starting with one or two hashes would be read as a new
49
- * section heading on the next parse, so such lines are demoted. The text survives; the
50
- * structure of the file does not break.
51
- */
52
- /** A line that would parse as a heading, escaped so it survives as text. */
53
- const HEADING_ESCAPE = /^(#{1,6}\s)/
54
-
55
- export function sanitizeSectionContent(content) {
56
- return String(content ?? '')
57
- .replace(/\r\n/g, '\n')
58
- .split('\n')
59
- .map((line) => line.replace(HEADING_ESCAPE, '\\$1'))
60
- .join('\n')
61
- .trim()
62
- }
63
-
64
- /** Undo the heading escape when reading a section back. */
65
- function unsanitizeSectionContent(content) {
66
- return String(content ?? '').split('\n').map((line) => line.replace(/^\\(#{1,6}\s)/, '$1')).join('\n')
67
- }
68
-
69
- /**
70
- * Freshness of one section, graded rather than binary.
71
- * @param verified - ISO timestamp the section was last confirmed.
72
- * @param now - epoch ms.
73
- * @param options.freshMs / options.staleMs - thresholds.
74
- * @returns {{verdict: 'fresh'|'aging'|'stale'|'unverified', ageMs: number|undefined, days: number|undefined}}
75
- */
76
- export function stateVerdict(verified, now = Date.now(), options = {}) {
77
- const freshMs = options.freshMs ?? STATE_FRESH_MS
78
- const staleMs = options.staleMs ?? STATE_STALE_MS
79
- const parsed = Date.parse(String(verified ?? ''))
80
- if (!Number.isFinite(parsed)) return { verdict: 'unverified', ageMs: undefined, days: undefined }
81
- const ageMs = Math.max(0, now - parsed)
82
- const days = Math.round((ageMs / 86400000) * 10) / 10
83
- if (ageMs <= freshMs) return { verdict: 'fresh', ageMs, days }
84
- if (ageMs <= staleMs) return { verdict: 'aging', ageMs, days }
85
- return { verdict: 'stale', ageMs, days }
86
- }
87
-
88
- /** Human age: 20m, 5h, 3d. */
89
- export function humanAge(ageMs) {
90
- if (!Number.isFinite(ageMs)) return 'unknown'
91
- if (ageMs < 60_000) return 'just now'
92
- const minutes = Math.round(ageMs / 60_000)
93
- if (minutes < 60) return minutes + 'm'
94
- const hours = Math.round(ageMs / 3600_000)
95
- if (hours < 48) return hours + 'h'
96
- return Math.round(ageMs / 86400_000) + 'd'
97
- }
98
-
99
- /** The marker shown wherever a non-fresh section appears. */
100
- export function freshnessMarker(verdict, ageMs) {
101
- if (verdict === 'fresh') return ''
102
- if (verdict === 'unverified') return ' [UNVERIFIED]'
103
- return ' [' + verdict.toUpperCase() + ' ' + humanAge(ageMs) + ']'
104
- }
105
-
106
- /**
107
- * Parse a state file.
108
- * @returns {{ updated: string|undefined, sections: {name: string, verified: string|undefined, source: string|undefined, content: string}[] }}
109
- */
110
- export function parseState(text) {
111
- const lines = String(text ?? '').split('\n')
112
- const sections = []
113
- let updated
114
- let current = null
115
- const push = () => { if (current) { current.content = unsanitizeSectionContent(current.content.join('\n').trim()); sections.push(current) } }
116
- for (const line of lines) {
117
- const stamp = /^Last updated:\s*(.+)$/.exec(line.trim())
118
- if (stamp && updated === undefined) { updated = stamp[1].trim(); continue }
119
- const heading = /^##\s+(.+)$/.exec(line)
120
- if (heading) {
121
- push()
122
- current = { name: heading[1].trim(), verified: undefined, source: undefined, content: [] }
123
- continue
124
- }
125
- if (!current) continue
126
- const verified = /^\*\*Verified:\*\*\s*(.*)$/.exec(line.trim())
127
- if (verified) { current.verified = verified[1].trim() || undefined; continue }
128
- const source = /^\*\*Source:\*\*\s*(.*)$/.exec(line.trim())
129
- if (source) { current.source = source[1].trim() || undefined; continue }
130
- current.content.push(line)
131
- }
132
- push()
133
- return { updated, sections }
134
- }
135
-
136
- /** Render a whole state file from its sections. */
137
- export function renderState(title, sections, options = {}) {
138
- const now = options.now ?? new Date().toISOString()
139
- const out = [
140
- '# ' + title + ' — state',
141
- '',
142
- 'What this repository is right now. Each section carries when it was last verified:',
143
- 'treat a stale section as a hypothesis to check, not a fact to trust.',
144
- '',
145
- 'Last updated: ' + now,
146
- '',
147
- ]
148
- for (const section of sections) {
149
- out.push('## ' + section.name, '')
150
- if (section.verified) out.push('**Verified:** ' + section.verified)
151
- if (section.source) out.push('**Source:** ' + section.source)
152
- out.push('', section.content.trim(), '')
153
- }
154
- return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
155
- }
156
-
157
- /** Read and grade a workspace state file. Returns undefined when there is none. */
158
- export function readState(root, key, options = {}) {
159
- const path = statePath(root, key)
160
- if (!existsSync(path)) return undefined
161
- let text
162
- try { text = readFileSync(path, 'utf8') } catch { return undefined }
163
- const parsed = parseState(text)
164
- const now = options.now ?? Date.now()
165
- const sections = parsed.sections.map((section) => ({
166
- ...section,
167
- ...stateVerdict(section.verified, now, options),
168
- }))
169
- return { path, updated: parsed.updated, sections }
170
- }
171
-
172
- /**
173
- * Write one section, replacing any existing section with the same name.
174
- *
175
- * The replacement is the point: state describes now, so the newer statement wins. Other
176
- * sections are preserved untouched, including their own timestamps - editing one area of a
177
- * repository must not make an unrelated section look freshly verified.
178
- *
179
- * @returns {{ section: string, verified: string, created: boolean, sections: string[] }}
180
- */
181
- export function writeStateSection(root, key, section, content, options = {}) {
182
- const name = sectionTitle(section)
183
- if (!isValidSectionName(name)) throw new Error('invalid state section name: ' + String(section))
184
- const body = sanitizeSectionContent(content)
185
- if (body === '') throw new Error('state section content must not be empty')
186
- const now = options.now ?? new Date().toISOString()
187
- const existing = readState(root, key, { now: Date.parse(now) }) ?? { sections: [] }
188
- const wanted = sectionKey(name)
189
- const kept = existing.sections
190
- .filter((s) => sectionKey(s.name) !== wanted)
191
- .map((s) => ({ name: s.name, verified: s.verified, source: s.source, content: s.content }))
192
- const created = kept.length === existing.sections.length
193
- // Keep the casing already in use for this section, so re-writing "RUNNING" does not
194
- // silently restyle a heading another agent established.
195
- const prior = existing.sections.find((s) => sectionKey(s.name) === wanted)
196
- const written = { name: prior ? prior.name : name, verified: now, source: options.source, content: body }
197
- // Keep the suggested order when the sections are familiar, else append in insertion order.
198
- const next = [...kept, written].sort((a, b) => {
199
- const ia = STATE_SECTIONS.findIndex((s) => sectionKey(s) === sectionKey(a.name))
200
- const ib = STATE_SECTIONS.findIndex((s) => sectionKey(s) === sectionKey(b.name))
201
- if (ia === -1 && ib === -1) return 0
202
- if (ia === -1) return 1
203
- if (ib === -1) return -1
204
- return ia - ib
205
- })
206
- mkdirSync(workspaceDir(root, key), { recursive: true })
207
- writeFileSync(statePath(root, key), renderState(key, next, { now }), 'utf8')
208
- // Report the name that was actually written, not the one computed from this call, or the
209
- // return value would disagree with the file whenever the casing is reused.
210
- return { section: written.name, verified: now, created, sections: next.map((s) => s.name) }
211
- }
212
-
213
- /**
214
- * What is safe to trust right now, and what needs re-checking.
215
- * @returns {{ sections: object[], stale: string[], missing: string[], fresh: number }}
216
- */
217
- export function summarizeState(root, key, options = {}) {
218
- const state = readState(root, key, options)
219
- const sections = state ? state.sections : []
220
- const present = new Set(sections.map((s) => sectionKey(s.name)))
221
- return {
222
- sections,
223
- stale: sections.filter((s) => s.verdict !== 'fresh').map((s) => s.name),
224
- missing: STATE_SECTIONS.filter((s) => !present.has(sectionKey(s))),
225
- fresh: sections.filter((s) => s.verdict === 'fresh').length,
226
- }
227
- }
228
-
229
- /** The state template seeded for a new workspace. */
230
- export function stateTemplate(title) {
231
- return [
232
- '# ' + title + ' — state',
233
- '',
234
- 'What this repository is right now. Each section carries when it was last verified:',
235
- 'treat a stale section as a hypothesis to check, not a fact to trust.',
236
- '',
237
- 'No sections recorded yet. Use memory_state to describe the current state.',
238
- '',
239
- ].join('\n')
240
- }
1
+ // Workspace state: a short, readable overview of what this repository IS right now.
2
+ //
3
+ // This is the one layer whose value is entirely in being CURRENT. A fact can be a month old and
4
+ // still true; a description of "what is running" or "what is in flight" cannot. So state is
5
+ // treated differently from every other layer in three ways:
6
+ //
7
+ // 1. It is REPLACED per section, not appended. State describes now, so a newer statement about
8
+ // the same section supersedes the old one instead of piling up beside it.
9
+ // 2. Every section carries an explicit **Verified:** timestamp. Freshness is not inferred from
10
+ // an entry date - it is part of the section, written by the machine that touched it.
11
+ // 3. Freshness is graded and always surfaced. A stale section is shown WITH its staleness, so
12
+ // an agent reads it as a hypothesis to check rather than a fact to trust.
13
+ //
14
+ // It is workspace-only: a repository state file in the global layer would describe no repository.
15
+ // It is a single file, not a layered kind - an overview that needs an index is not an overview.
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { workspaceDir } from './store.js'
19
+
20
+ /** A section verified within this window is current. */
21
+ export const STATE_FRESH_MS = 6 * 3600_000
22
+ /** Past this, a section describes a repository that has probably moved on. */
23
+ export const STATE_STALE_MS = 72 * 3600_000
24
+
25
+ /** Suggested sections. Any name is allowed; these keep separate agents consistent. */
26
+ export const STATE_SECTIONS = ['Summary', 'Layout', 'Running', 'Pending', 'Health']
27
+
28
+ /** Absolute path of a workspace state file. */
29
+ export function statePath(root, key) { return join(workspaceDir(root, key), 'state.md') }
30
+
31
+ /** True when a section name is usable as a heading. */
32
+ export function isValidSectionName(name) {
33
+ const text = String(name ?? '').trim()
34
+ return text !== '' && text.length <= 60 && !/[\r\n]/.test(text) && !text.startsWith('#')
35
+ }
36
+
37
+ /** Canonical display form of a section name: trimmed, first letter upper-cased. */
38
+ export function sectionTitle(name) {
39
+ const text = String(name ?? '').trim().replace(/\s+/g, ' ')
40
+ if (text === '') return ''
41
+ return text[0].toUpperCase() + text.slice(1)
42
+ }
43
+
44
+ /** Matching key for a section name, so "summary" and "Summary" are the same section. */
45
+ export function sectionKey(name) { return String(name ?? '').trim().toLowerCase() }
46
+
47
+ /**
48
+ * Make content safe to embed: a line starting with one or two hashes would be read as a new
49
+ * section heading on the next parse, so such lines are demoted. The text survives; the
50
+ * structure of the file does not break.
51
+ */
52
+ /** A line that would parse as a heading, escaped so it survives as text. */
53
+ const HEADING_ESCAPE = /^(#{1,6}\s)/
54
+
55
+ export function sanitizeSectionContent(content) {
56
+ return String(content ?? '')
57
+ .replace(/\r\n/g, '\n')
58
+ .split('\n')
59
+ .map((line) => line.replace(HEADING_ESCAPE, '\\$1'))
60
+ .join('\n')
61
+ .trim()
62
+ }
63
+
64
+ /** Undo the heading escape when reading a section back. */
65
+ function unsanitizeSectionContent(content) {
66
+ return String(content ?? '').split('\n').map((line) => line.replace(/^\\(#{1,6}\s)/, '$1')).join('\n')
67
+ }
68
+
69
+ /**
70
+ * Freshness of one section, graded rather than binary.
71
+ * @param verified - ISO timestamp the section was last confirmed.
72
+ * @param now - epoch ms.
73
+ * @param options.freshMs / options.staleMs - thresholds.
74
+ * @returns {{verdict: 'fresh'|'aging'|'stale'|'unverified', ageMs: number|undefined, days: number|undefined}}
75
+ */
76
+ export function stateVerdict(verified, now = Date.now(), options = {}) {
77
+ const freshMs = options.freshMs ?? STATE_FRESH_MS
78
+ const staleMs = options.staleMs ?? STATE_STALE_MS
79
+ const parsed = Date.parse(String(verified ?? ''))
80
+ if (!Number.isFinite(parsed)) return { verdict: 'unverified', ageMs: undefined, days: undefined }
81
+ const ageMs = Math.max(0, now - parsed)
82
+ const days = Math.round((ageMs / 86400000) * 10) / 10
83
+ if (ageMs <= freshMs) return { verdict: 'fresh', ageMs, days }
84
+ if (ageMs <= staleMs) return { verdict: 'aging', ageMs, days }
85
+ return { verdict: 'stale', ageMs, days }
86
+ }
87
+
88
+ /** Human age: 20m, 5h, 3d. */
89
+ export function humanAge(ageMs) {
90
+ if (!Number.isFinite(ageMs)) return 'unknown'
91
+ if (ageMs < 60_000) return 'just now'
92
+ const minutes = Math.round(ageMs / 60_000)
93
+ if (minutes < 60) return minutes + 'm'
94
+ const hours = Math.round(ageMs / 3600_000)
95
+ if (hours < 48) return hours + 'h'
96
+ return Math.round(ageMs / 86400_000) + 'd'
97
+ }
98
+
99
+ /** The marker shown wherever a non-fresh section appears. */
100
+ export function freshnessMarker(verdict, ageMs) {
101
+ if (verdict === 'fresh') return ''
102
+ if (verdict === 'unverified') return ' [UNVERIFIED]'
103
+ return ' [' + verdict.toUpperCase() + ' ' + humanAge(ageMs) + ']'
104
+ }
105
+
106
+ /**
107
+ * Parse a state file.
108
+ * @returns {{ updated: string|undefined, sections: {name: string, verified: string|undefined, source: string|undefined, content: string}[] }}
109
+ */
110
+ export function parseState(text) {
111
+ const lines = String(text ?? '').split('\n')
112
+ const sections = []
113
+ let updated
114
+ let current = null
115
+ const push = () => { if (current) { current.content = unsanitizeSectionContent(current.content.join('\n').trim()); sections.push(current) } }
116
+ for (const line of lines) {
117
+ const stamp = /^Last updated:\s*(.+)$/.exec(line.trim())
118
+ if (stamp && updated === undefined) { updated = stamp[1].trim(); continue }
119
+ const heading = /^##\s+(.+)$/.exec(line)
120
+ if (heading) {
121
+ push()
122
+ current = { name: heading[1].trim(), verified: undefined, source: undefined, content: [] }
123
+ continue
124
+ }
125
+ if (!current) continue
126
+ const verified = /^\*\*Verified:\*\*\s*(.*)$/.exec(line.trim())
127
+ if (verified) { current.verified = verified[1].trim() || undefined; continue }
128
+ const source = /^\*\*Source:\*\*\s*(.*)$/.exec(line.trim())
129
+ if (source) { current.source = source[1].trim() || undefined; continue }
130
+ current.content.push(line)
131
+ }
132
+ push()
133
+ return { updated, sections }
134
+ }
135
+
136
+ /** Render a whole state file from its sections. */
137
+ export function renderState(title, sections, options = {}) {
138
+ const now = options.now ?? new Date().toISOString()
139
+ const out = [
140
+ '# ' + title + ' — state',
141
+ '',
142
+ 'What this repository is right now. Each section carries when it was last verified:',
143
+ 'treat a stale section as a hypothesis to check, not a fact to trust.',
144
+ '',
145
+ 'Last updated: ' + now,
146
+ '',
147
+ ]
148
+ for (const section of sections) {
149
+ out.push('## ' + section.name, '')
150
+ if (section.verified) out.push('**Verified:** ' + section.verified)
151
+ if (section.source) out.push('**Source:** ' + section.source)
152
+ out.push('', section.content.trim(), '')
153
+ }
154
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
155
+ }
156
+
157
+ /** Read and grade a workspace state file. Returns undefined when there is none. */
158
+ export function readState(root, key, options = {}) {
159
+ const path = statePath(root, key)
160
+ if (!existsSync(path)) return undefined
161
+ let text
162
+ try { text = readFileSync(path, 'utf8') } catch { return undefined }
163
+ const parsed = parseState(text)
164
+ const now = options.now ?? Date.now()
165
+ const sections = parsed.sections.map((section) => ({
166
+ ...section,
167
+ ...stateVerdict(section.verified, now, options),
168
+ }))
169
+ return { path, updated: parsed.updated, sections }
170
+ }
171
+
172
+ /**
173
+ * Write one section, replacing any existing section with the same name.
174
+ *
175
+ * The replacement is the point: state describes now, so the newer statement wins. Other
176
+ * sections are preserved untouched, including their own timestamps - editing one area of a
177
+ * repository must not make an unrelated section look freshly verified.
178
+ *
179
+ * @returns {{ section: string, verified: string, created: boolean, sections: string[] }}
180
+ */
181
+ export function writeStateSection(root, key, section, content, options = {}) {
182
+ const name = sectionTitle(section)
183
+ if (!isValidSectionName(name)) throw new Error('invalid state section name: ' + String(section))
184
+ const body = sanitizeSectionContent(content)
185
+ if (body === '') throw new Error('state section content must not be empty')
186
+ const now = options.now ?? new Date().toISOString()
187
+ const existing = readState(root, key, { now: Date.parse(now) }) ?? { sections: [] }
188
+ const wanted = sectionKey(name)
189
+ const kept = existing.sections
190
+ .filter((s) => sectionKey(s.name) !== wanted)
191
+ .map((s) => ({ name: s.name, verified: s.verified, source: s.source, content: s.content }))
192
+ const created = kept.length === existing.sections.length
193
+ // Keep the casing already in use for this section, so re-writing "RUNNING" does not
194
+ // silently restyle a heading another agent established.
195
+ const prior = existing.sections.find((s) => sectionKey(s.name) === wanted)
196
+ const written = { name: prior ? prior.name : name, verified: now, source: options.source, content: body }
197
+ // Keep the suggested order when the sections are familiar, else append in insertion order.
198
+ const next = [...kept, written].sort((a, b) => {
199
+ const ia = STATE_SECTIONS.findIndex((s) => sectionKey(s) === sectionKey(a.name))
200
+ const ib = STATE_SECTIONS.findIndex((s) => sectionKey(s) === sectionKey(b.name))
201
+ if (ia === -1 && ib === -1) return 0
202
+ if (ia === -1) return 1
203
+ if (ib === -1) return -1
204
+ return ia - ib
205
+ })
206
+ mkdirSync(workspaceDir(root, key), { recursive: true })
207
+ writeFileSync(statePath(root, key), renderState(key, next, { now }), 'utf8')
208
+ // Report the name that was actually written, not the one computed from this call, or the
209
+ // return value would disagree with the file whenever the casing is reused.
210
+ return { section: written.name, verified: now, created, sections: next.map((s) => s.name) }
211
+ }
212
+
213
+ /**
214
+ * What is safe to trust right now, and what needs re-checking.
215
+ * @returns {{ sections: object[], stale: string[], missing: string[], fresh: number }}
216
+ */
217
+ export function summarizeState(root, key, options = {}) {
218
+ const state = readState(root, key, options)
219
+ const sections = state ? state.sections : []
220
+ const present = new Set(sections.map((s) => sectionKey(s.name)))
221
+ return {
222
+ sections,
223
+ stale: sections.filter((s) => s.verdict !== 'fresh').map((s) => s.name),
224
+ missing: STATE_SECTIONS.filter((s) => !present.has(sectionKey(s))),
225
+ fresh: sections.filter((s) => s.verdict === 'fresh').length,
226
+ }
227
+ }
228
+
229
+ /** The state template seeded for a new workspace. */
230
+ export function stateTemplate(title) {
231
+ return [
232
+ '# ' + title + ' — state',
233
+ '',
234
+ 'What this repository is right now. Each section carries when it was last verified:',
235
+ 'treat a stale section as a hypothesis to check, not a fact to trust.',
236
+ '',
237
+ 'No sections recorded yet. Use memory_state to describe the current state.',
238
+ '',
239
+ ].join('\n')
240
+ }