@quolu/lattice 0.64.1 → 0.64.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.64.1",
3
+ "version": "0.64.3",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
package/src/cli-help.mjs CHANGED
@@ -86,6 +86,7 @@ Read commands:
86
86
  phase status --plan <key>
87
87
 
88
88
  Write commands:
89
+ repair-eol --json # Git checkoutでCRLF化された既存storeをcanonical LFへ戻し、EOL保護を追加する
89
90
  dashboard adopt --json # 衝突したproject_idの配信元rootを現在repoへ明示的に移す
90
91
  dashboard remove <project_id> --json # 登録簿から1件外す(対象repoの外からも叩ける)
91
92
  note --plan <key> [--task <id>] (--message <text>|--input <file>)
@@ -251,6 +252,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
251
252
  'todo seam-profile': 'todo seam-profile --plan <key> --file <path> [--json]',
252
253
  'todo seam-proposal': 'todo seam-proposal [--plan <key>] [--json] | compile --plan <key>',
253
254
  'todo verify': 'todo verify [--plan <key>] [--json]',
255
+ 'todo repair-eol': 'todo repair-eol --json',
254
256
  'todo snapshot': 'todo snapshot --rebuild --plan <key>',
255
257
  'todo gantt': 'todo gantt serve --port <port> [--scope live|all] # 動的表示のみ。静的HTML生成は廃止',
256
258
  'todo gantt serve': 'todo gantt serve --port <0..65535> [--scope live|all] # loopback動的viewer',
@@ -325,7 +325,14 @@ async function resolveProjectState({ cwd, cliVersion, diagnoseStructureArtifacts
325
325
  if (error?.code === 'STRUCTURE_ARTIFACT_INVALID') throw error;
326
326
  const reason = error instanceof TodoStoreError
327
327
  ? `${error.code}:${error.detail?.reason ?? error.message}` : 'store_validation_failed';
328
- return invalid(reason);
328
+ const nextAction = error instanceof TodoStoreError
329
+ && error.detail?.next_action === 'lattice todo repair-eol --json'
330
+ ? { command: error.detail.next_action, reason: `${reason}:${error.detail.ref}` }
331
+ : null;
332
+ return {
333
+ exitCode: 1, repoRoot, store: null, todo: null,
334
+ result: invalidStatus({ cliVersion, repoRoot, reason, nextAction }),
335
+ };
329
336
  }
330
337
  }
331
338
 
package/src/todo-cli.mjs CHANGED
@@ -177,6 +177,7 @@ import {
177
177
  } from './todo-note-store.mjs';
178
178
  import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
179
179
  import { commitTodoStoreMutation } from './todo-store-git-transaction.mjs';
180
+ import { repairTodoStoreWorktreeEol } from './todo-store-worktree-eol.mjs';
180
181
 
181
182
  const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
182
183
  const DEFAULT_GANTT_SCOPE = 'live';
@@ -254,7 +255,7 @@ function internalFailure(stderr, error) {
254
255
 
255
256
  const TODO_COMMAND_NAMES = Object.freeze([
256
257
  'status', 'show', 'note', 'bindings', 'independence', 'structure', 'seam-profile', 'seam-proposal',
257
- 'verify', 'snapshot', 'gantt', 'dashboard', 'phase', 'migrate', 'start', 'block',
258
+ 'verify', 'repair-eol', 'snapshot', 'gantt', 'dashboard', 'phase', 'migrate', 'start', 'block',
258
259
  'unblock', 'done', 'reopen', 'evidence', 'split', 'revise', 'revise-phase', 'revise-set',
259
260
  ]);
260
261
 
@@ -3579,6 +3580,20 @@ async function rebuildSnapshot({ repoRoot, planKey }) {
3579
3580
  return result;
3580
3581
  }
3581
3582
 
3583
+ async function repairStoreEol({ repoRoot }) {
3584
+ const repair = await repairTodoStoreWorktreeEol({ repoRoot });
3585
+ const store = await readTodoStore({ repoRoot });
3586
+ const result = {
3587
+ schema: 'lattice.todo_eol_repair_result.v1',
3588
+ project_id: store.project_id,
3589
+ ...repair,
3590
+ repaired_count: repair.repaired_refs.length,
3591
+ result_digest: '',
3592
+ };
3593
+ result.result_digest = todoSelfDigest(result, 'result_digest');
3594
+ return result;
3595
+ }
3596
+
3582
3597
  /**
3583
3598
  * `lattice todo` namespace. Exact position, order, and argument count are part of
3584
3599
  * the public contract; usage failures never use a JSON envelope.
@@ -3633,7 +3648,10 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3633
3648
 
3634
3649
  const automatedStructureRealize = parseAutomatedStructureRealizeArgs(argv);
3635
3650
  let action = null;
3636
- if ((argv.length === 1 && argv[0] === 'status')
3651
+ if (argv.length === 2 && argv[0] === 'repair-eol' && argv[1] === '--json') {
3652
+ // 壊れたstoreを読めない時の入口なので、dashboard所有権pre-hookを通さない。
3653
+ action = (repoRoot) => repairStoreEol({ repoRoot });
3654
+ } else if ((argv.length === 1 && argv[0] === 'status')
3637
3655
  || (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
3638
3656
  action = (repoRoot) => status({ repoRoot });
3639
3657
  } else if (argv.length === 3 && argv[0] === 'dashboard'
@@ -0,0 +1,220 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import {
3
+ lstat, readFile, readdir, rename, rm, writeFile,
4
+ } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ import { gitCatFileBatch, gitSpawnSync } from './git-process.mjs';
8
+ import { TodoStoreError } from './todo-store.mjs';
9
+
10
+ const STORE_REF = '.lattice/todo';
11
+ const ATTRIBUTES_REF = '.lattice/.gitattributes';
12
+ const EOL_PROTECTION = '# Lattice store artifacts are canonical JSON+LF bytes; EOL conversion corrupts the store.\n* -text\n';
13
+ const INDEX_REFRESH_BATCH_SIZE = 40;
14
+
15
+ function fail(reason, detail = {}) {
16
+ throw new TodoStoreError('STORE_EOL_REPAIR_UNSAFE', reason, undefined, detail);
17
+ }
18
+
19
+ async function regularFileState(absolute, ref) {
20
+ let stats;
21
+ try { stats = await lstat(absolute); } catch (error) {
22
+ if (error?.code === 'ENOENT') return null;
23
+ fail('artifact_unreadable', { ref, message: error.message });
24
+ }
25
+ if (stats.isSymbolicLink() || !stats.isFile()) fail('unsafe_artifact_path', { ref });
26
+ return stats;
27
+ }
28
+
29
+ async function assertSafeDirectoryChain(repoRoot, relative) {
30
+ let absolute = repoRoot;
31
+ let ref = '';
32
+ for (const segment of relative.replaceAll('\\', '/').split('/')) {
33
+ ref = path.posix.join(ref, segment);
34
+ absolute = path.join(absolute, segment);
35
+ let stats;
36
+ try { stats = await lstat(absolute); } catch (error) {
37
+ fail(error?.code === 'ENOENT' ? 'store_missing' : 'store_unreadable', {
38
+ ref, message: error.message,
39
+ });
40
+ }
41
+ if (stats.isSymbolicLink() || !stats.isDirectory()) fail('unsafe_artifact_path', { ref });
42
+ }
43
+ return absolute;
44
+ }
45
+
46
+ async function safeRegularFileState(repoRoot, ref) {
47
+ await assertSafeDirectoryChain(repoRoot, path.posix.dirname(ref));
48
+ return regularFileState(path.join(repoRoot, ref), ref);
49
+ }
50
+
51
+ async function collectArtifactRefs(repoRoot, relative = STORE_REF) {
52
+ const absolute = await assertSafeDirectoryChain(repoRoot, relative);
53
+ let entries;
54
+ try { entries = await readdir(absolute, { withFileTypes: true }); } catch (error) {
55
+ fail(error?.code === 'ENOENT' ? 'store_missing' : 'store_unreadable', {
56
+ ref: relative, message: error.message,
57
+ });
58
+ }
59
+ const refs = [];
60
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
61
+ const ref = path.posix.join(relative.replaceAll('\\', '/'), entry.name);
62
+ if (entry.isSymbolicLink()) fail('unsafe_artifact_path', { ref });
63
+ if (entry.isDirectory()) refs.push(...await collectArtifactRefs(repoRoot, ref));
64
+ else if (entry.isFile() && /\.(?:json|jsonl)$/u.test(entry.name)) refs.push(ref);
65
+ else if (!entry.isFile()) fail('unsafe_artifact_path', { ref });
66
+ }
67
+ return refs;
68
+ }
69
+
70
+ function normalizePureCrlf(bytes, ref) {
71
+ if (!bytes.includes(13)) return null;
72
+ const normalized = [];
73
+ for (let index = 0; index < bytes.length; index += 1) {
74
+ const byte = bytes[index];
75
+ if (byte !== 13) {
76
+ normalized.push(byte);
77
+ continue;
78
+ }
79
+ if (bytes[index + 1] !== 10) fail('artifact_not_pure_crlf_conversion', { ref });
80
+ }
81
+ return Buffer.from(normalized);
82
+ }
83
+
84
+ async function atomicReplace({ repoRoot, ref, absolute, sourceBytes, bytes, mode }) {
85
+ await safeRegularFileState(repoRoot, ref);
86
+ const currentBytes = await readFile(absolute);
87
+ if (!currentBytes.equals(sourceBytes)) fail('artifact_changed_during_repair', { ref });
88
+ const temporary = `${absolute}.eol-repair-${process.pid}-${randomBytes(6).toString('hex')}.tmp`;
89
+ try {
90
+ await writeFile(temporary, bytes, { flag: 'wx', mode: mode & 0o777 });
91
+ await rename(temporary, absolute);
92
+ } catch (error) {
93
+ try { await rm(temporary, { force: true }); } catch { /* noop */ }
94
+ fail('artifact_write_failed', { ref: path.basename(absolute), message: error.message });
95
+ }
96
+ }
97
+
98
+ async function prepareProtection(repoRoot) {
99
+ const absolute = path.join(repoRoot, ATTRIBUTES_REF);
100
+ const stats = await safeRegularFileState(repoRoot, ATTRIBUTES_REF);
101
+ if (stats === null) return { absolute, create: true };
102
+ const text = await readFile(absolute, 'utf8');
103
+ if (!text.split(/\r?\n/u).includes('* -text')) {
104
+ fail('eol_protection_conflict', { ref: ATTRIBUTES_REF, required_rule: '* -text' });
105
+ }
106
+ return { absolute, create: false };
107
+ }
108
+
109
+ function stagedArtifactRefs(repoRoot, refs) {
110
+ const staged = new Set();
111
+ for (let offset = 0; offset < refs.length; offset += INDEX_REFRESH_BATCH_SIZE) {
112
+ const batch = refs.slice(offset, offset + INDEX_REFRESH_BATCH_SIZE);
113
+ const result = gitSpawnSync(['diff-index', '--cached', '--name-only', '-z', 'HEAD', '--', ...batch], {
114
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
115
+ });
116
+ if (result.error || result.signal || result.status !== 0) {
117
+ fail('git_index_artifacts_unreadable', { refs: batch, status: result.status ?? null });
118
+ }
119
+ for (const ref of (result.stdout ?? '').split('\0')) {
120
+ if (ref !== '') staged.add(ref.replaceAll('\\', '/'));
121
+ }
122
+ }
123
+ return staged;
124
+ }
125
+
126
+ function refreshGitIndex(repoRoot, refs) {
127
+ for (let offset = 0; offset < refs.length; offset += INDEX_REFRESH_BATCH_SIZE) {
128
+ const batch = refs.slice(offset, offset + INDEX_REFRESH_BATCH_SIZE);
129
+ const refresh = gitSpawnSync(['update-index', '--refresh', '--', ...batch], {
130
+ cwd: repoRoot, stdio: 'ignore',
131
+ });
132
+ // 対象は呼出前にindex blob=HEADを実証済み。update-index --refreshで内容をstageせずstatだけを揃える。
133
+ if (refresh.error || refresh.signal || ![0, 1].includes(refresh.status)) {
134
+ fail('git_index_refresh_failed', { refs: batch, status: refresh.status ?? null });
135
+ }
136
+ const verified = gitSpawnSync(['diff-files', '--quiet', '--', ...batch], {
137
+ cwd: repoRoot, stdio: 'ignore',
138
+ });
139
+ if (verified.error || verified.signal || verified.status !== 0) {
140
+ fail('git_index_refresh_failed', { refs: batch, status: verified.status ?? null });
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Gitのcheckoutがcanonical LF artifactをCRLFへ変換した既存storeだけを修復する。
147
+ * JSONの意味・順序・空白には触れず、CRLF除去後にcanonical byte列へ完全一致するfileだけを書く。
148
+ */
149
+ export async function repairTodoStoreWorktreeEol({ repoRoot }) {
150
+ const protection = await prepareProtection(repoRoot);
151
+ const refs = await collectArtifactRefs(repoRoot);
152
+ const stagedRefs = stagedArtifactRefs(repoRoot, refs);
153
+ const artifacts = [];
154
+ for (const ref of refs) {
155
+ const absolute = path.join(repoRoot, ref);
156
+ const stats = await safeRegularFileState(repoRoot, ref);
157
+ const bytes = await readFile(absolute);
158
+ const normalized = normalizePureCrlf(bytes, ref);
159
+ artifacts.push({
160
+ ref, absolute, sourceBytes: bytes, bytes: normalized ?? bytes, mode: stats.mode,
161
+ converted: normalized !== null,
162
+ });
163
+ }
164
+
165
+ let headArtifacts = [];
166
+ let indexArtifacts = [];
167
+ if (artifacts.length > 0) {
168
+ const maxBodyBytes = Math.max(...artifacts.map(({ bytes }) => bytes.length));
169
+ try {
170
+ headArtifacts = gitCatFileBatch(artifacts.map(({ ref }) => `HEAD:${ref}`), {
171
+ cwd: repoRoot, maxBodyBytes,
172
+ });
173
+ } catch (error) {
174
+ fail('git_head_artifacts_unreadable', { message: error.message });
175
+ }
176
+ try {
177
+ indexArtifacts = gitCatFileBatch(artifacts.map(({ ref }) => `:${ref}`), {
178
+ cwd: repoRoot, maxBodyBytes,
179
+ });
180
+ } catch (error) {
181
+ fail('git_index_artifacts_unreadable', { message: error.message });
182
+ }
183
+ }
184
+ const converted = [];
185
+ const refreshRefs = [];
186
+ for (const [index, artifact] of artifacts.entries()) {
187
+ const head = headArtifacts[index];
188
+ const staged = indexArtifacts[index];
189
+ if (artifact.converted) {
190
+ if (head?.type !== 'blob' || !head.bytes.equals(artifact.bytes)) {
191
+ fail('artifact_not_pure_checkout_conversion', { ref: artifact.ref });
192
+ }
193
+ if (stagedRefs.has(artifact.ref)
194
+ || staged?.type !== 'blob' || !staged.bytes.equals(head.bytes)) {
195
+ fail('artifact_staged_change_present', { ref: artifact.ref });
196
+ }
197
+ converted.push(artifact);
198
+ }
199
+ if (!stagedRefs.has(artifact.ref) && head?.type === 'blob' && staged?.type === 'blob'
200
+ && staged.bytes.equals(head.bytes) && artifact.bytes.equals(head.bytes)) {
201
+ refreshRefs.push(artifact.ref);
202
+ }
203
+ }
204
+
205
+ for (const artifact of converted) {
206
+ await atomicReplace({ repoRoot, ...artifact });
207
+ }
208
+ // 0.64.2がLF書換後のstat refreshで止まった部分状態も、再実行だけで回復させる。
209
+ refreshGitIndex(repoRoot, refreshRefs);
210
+ if (protection.create) {
211
+ await assertSafeDirectoryChain(repoRoot, path.posix.dirname(ATTRIBUTES_REF));
212
+ await writeFile(protection.absolute, EOL_PROTECTION, { flag: 'wx' });
213
+ }
214
+
215
+ return {
216
+ repaired_refs: converted.map(({ ref }) => ref),
217
+ protection_ref: ATTRIBUTES_REF,
218
+ protection_created: protection.create,
219
+ };
220
+ }
@@ -135,18 +135,26 @@ function decodeUtf8(bytes, code, reason) {
135
135
  catch { fail(code, reason); }
136
136
  }
137
137
 
138
- function parseCanonicalJsonLine(bytes, { code, reason, maxBytes, validate }) {
139
- if (bytes.length === 0 || bytes.length > maxBytes) fail(code, bytes.length > maxBytes ? 'size_limit_exceeded' : reason);
138
+ function parseCanonicalJsonLine(bytes, { code, reason, maxBytes, validate, ref = null }) {
139
+ const detail = ref === null ? undefined : { ref };
140
+ if (bytes.length === 0 || bytes.length > maxBytes) {
141
+ fail(code, bytes.length > maxBytes ? 'size_limit_exceeded' : reason, detail);
142
+ }
140
143
  const text = decodeUtf8(bytes, code, 'invalid_utf8');
141
- if (text.includes('\r') || text.startsWith('\uFEFF')) fail(code, reason);
144
+ if (text.includes('\r')) {
145
+ fail(code, 'artifact_eol_converted', {
146
+ ...detail, next_action: 'lattice todo repair-eol --json',
147
+ });
148
+ }
149
+ if (text.startsWith('\uFEFF')) fail(code, reason, detail);
142
150
  const body = text.endsWith('\n') ? text.slice(0, -1) : text;
143
151
  let value;
144
- try { value = JSON.parse(body); } catch { fail(code, reason); }
145
- if (!text.endsWith('\n')) fail(code, reason);
152
+ try { value = JSON.parse(body); } catch { fail(code, reason, detail); }
153
+ if (!text.endsWith('\n')) fail(code, reason, detail);
146
154
  let expected;
147
- try { expected = `${canonicalizeTodoArtifact(value)}\n`; } catch { fail(code, reason); }
148
- if (text !== expected) fail(code, 'non_canonical_or_duplicate_key');
149
- if (!validate(value)) fail(code, 'schema_invalid');
155
+ try { expected = `${canonicalizeTodoArtifact(value)}\n`; } catch { fail(code, reason, detail); }
156
+ if (text !== expected) fail(code, 'non_canonical_or_duplicate_key', detail);
157
+ if (!validate(value)) fail(code, 'schema_invalid', detail);
150
158
  return value;
151
159
  }
152
160
 
@@ -154,7 +162,9 @@ async function readArtifact(repoRoot, ref, { code, maxBytes, validate, missing =
154
162
  const state = await pathState(repoRoot, ref, code, { missing });
155
163
  if (state === null) return null;
156
164
  const bytes = await readFile(state.absolute);
157
- return parseCanonicalJsonLine(bytes, { code, reason: 'artifact_truncated_or_trailing_bytes', maxBytes, validate });
165
+ return parseCanonicalJsonLine(bytes, {
166
+ code, reason: 'artifact_truncated_or_trailing_bytes', maxBytes, validate, ref,
167
+ });
158
168
  }
159
169
 
160
170
  async function readSnapshotArtifact(repoRoot, ref) {
@@ -163,16 +173,23 @@ async function readSnapshotArtifact(repoRoot, ref) {
163
173
  const bytes = await readFile(state.absolute);
164
174
  return parseCanonicalJsonLine(bytes, {
165
175
  code: 'SNAPSHOT_INVALID', reason: 'snapshot_truncated_or_trailing_bytes',
166
- maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoSnapshot,
176
+ maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoSnapshot, ref,
167
177
  });
168
178
  }
169
179
 
170
- function parseJournalSegment(bytes) {
180
+ function parseJournalSegment(bytes, ref = null) {
181
+ const detail = ref === null ? undefined : { ref };
171
182
  if (bytes.length === 0 || bytes.length > TODO_LIMITS.journalSegmentBytes) {
172
- fail('STORE_CORRUPT', bytes.length > TODO_LIMITS.journalSegmentBytes ? 'journal_segment_limit_exceeded' : 'journal_empty');
183
+ fail('STORE_CORRUPT', bytes.length > TODO_LIMITS.journalSegmentBytes
184
+ ? 'journal_segment_limit_exceeded' : 'journal_empty', detail);
173
185
  }
174
186
  const text = decodeUtf8(bytes, 'STORE_CORRUPT', 'journal_invalid_utf8');
175
- if (!text.endsWith('\n') || text.includes('\r') || text.startsWith('\uFEFF')) fail('STORE_CORRUPT', 'journal_byte_contract');
187
+ if (text.includes('\r')) {
188
+ fail('STORE_CORRUPT', 'artifact_eol_converted', {
189
+ ...detail, next_action: 'lattice todo repair-eol --json',
190
+ });
191
+ }
192
+ if (!text.endsWith('\n') || text.startsWith('\uFEFF')) fail('STORE_CORRUPT', 'journal_byte_contract', detail);
176
193
  const lines = text.slice(0, -1).split('\n');
177
194
  if (lines.some((line) => line.length === 0)) fail('STORE_CORRUPT', 'journal_truncated_or_empty_line');
178
195
  return lines.map((line) => {
@@ -205,8 +222,8 @@ async function readJournal(repoRoot, journalRef) {
205
222
  const [, startText, endText, previousDigest, segmentDigest] = name.match(
206
223
  /^(\d{12})-(\d{12})-([0-9a-f]{64})-([0-9a-f]{64})\.jsonl$/u,
207
224
  );
225
+ const events = parseJournalSegment(bytes, ref);
208
226
  if (sha256Bytes(bytes) !== segmentDigest) fail('STORE_CORRUPT', 'sealed_segment_digest_mismatch');
209
- const events = parseJournalSegment(bytes);
210
227
  if (events[0].sequence !== Number(startText) || events.at(-1).sequence !== Number(endText)) {
211
228
  fail('STORE_CORRUPT', 'sealed_segment_range_mismatch');
212
229
  }
@@ -220,7 +237,7 @@ async function readJournal(repoRoot, journalRef) {
220
237
  if (error?.code !== 'ENOENT') fail('STORE_CORRUPT', 'sealed_segment_read_failed');
221
238
  }
222
239
  const activeBytes = await readFile(state.absolute);
223
- segments.push({ ref: journalRef, bytes: activeBytes, events: parseJournalSegment(activeBytes) });
240
+ segments.push({ ref: journalRef, bytes: activeBytes, events: parseJournalSegment(activeBytes, journalRef) });
224
241
  const events = segments.flatMap(({ events: entries }) => entries);
225
242
  const failures = verifyLinearHashChain({
226
243
  entries: events,
@@ -291,7 +308,7 @@ async function readPlanScopedJournal(repoRoot, journalRef) {
291
308
  if (error?.code === 'ENOENT') return { ref, events: [], activeBytes: Buffer.alloc(0) };
292
309
  fail('STORE_CORRUPT', 'plan_scoped_journal_read_failed');
293
310
  }
294
- const events = parseJournalSegment(bytes);
311
+ const events = parseJournalSegment(bytes, ref);
295
312
  if (!events.every(({ kind }) => TODO_PLAN_SCOPED_EVENT_KINDS.includes(kind))) {
296
313
  fail('STORE_CORRUPT', 'plan_scoped_journal_kind_invalid');
297
314
  }
@@ -1571,7 +1588,8 @@ export async function readTodoStore(options = {}) {
1571
1588
  snapshotStale = snapshot === null || canonicalizeTodoArtifact(snapshot) !== canonicalizeTodoArtifact(expectedSnapshot);
1572
1589
  } catch (error) {
1573
1590
  if (error instanceof TodoStoreError && error.code === 'SNAPSHOT_INVALID'
1574
- && !['unsafe_artifact_path', 'path_alias_or_escape', 'path_outside_store'].includes(error.detail.reason)) snapshotStale = true;
1591
+ && !['unsafe_artifact_path', 'path_alias_or_escape', 'path_outside_store',
1592
+ 'artifact_eol_converted'].includes(error.detail.reason)) snapshotStale = true;
1575
1593
  else throw error;
1576
1594
  }
1577
1595
  if (options.forWrite === true && snapshotStale) fail('STORE_WRITE_REFUSED', 'snapshot_stale');