@aiwayds/dsh-topics-memory 0.18.0 → 0.18.2

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/README.md CHANGED
@@ -97,7 +97,7 @@ Purge everything: back up `~/.dsh/topics` first, then `rm -rf ~/.dsh/topics`.
97
97
 
98
98
  ## Configuration
99
99
 
100
- First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set <key> <value>` — on dsh 0.1.7+ it writes the settings-page `dsh-topics-memory` entry (the profile patch) and every key is volatile: edits take effect immediately, no restart. All keys and defaults:
100
+ First-time setup belongs to `/topics onboard`; day-to-day tuning is `/topics set <key> <value>` — on dsh 0.1.7+ it writes the settings-page `dsh-topics-memory` entry (the profile patch) and every key is volatile: edits take effect immediately, no restart. Upgrading from a pre-0.1.7 install: a legacy top-level `topics:` section in the old `settings.yaml` is imported once into the new entry automatically at the next plugin boot (the audit record lands in `~/.dsh/storages/dsh-topics-memory/legacy-import.json`). All keys and defaults:
101
101
 
102
102
  | Key | Default | Meaning |
103
103
  |---|---|---|
@@ -142,7 +142,7 @@ This project's shape is directly inspired and supported by:
142
142
  - **Subagents are out of memory by default — one switch to opt in**: by default (`include-subagents` off since 0.7.0) delegated sessions are skipped entirely — no injection, no observation, no distill triggers; `/topics set include-subagents on` applies injection and observation to them too. The topic tools stay on the global layer, so an explicit `topic_save` from a child still lands. Out-of-process subagents (claude-code/codex providers) never load this plugin anyway.
143
143
  - **Exit is local-only (0.10.0)**: the plugin's disposer makes one local git commit of the meta sidecars (observations / injections / distill state) and never waits on the network — no pull, no push, no model call, so host exit no longer pays a git round-trip or a bounded distill wait (the old 90s cap is gone; only a 10s guard against a pathological git stall remains). The exit distill trigger is fired fire-and-forget and is skipped entirely while a session-end run is still in flight (the same pool head would otherwise be fed to the model twice). Nothing is lost by skipping: observations are write-through on disk, the deferred push is replayed by the next boot's pull, and the skipped distill is replayed there too (boot-replay). `meta/distill-state.json` records each lane's outcome, checkable via `/topics status`.
144
144
  - **Observation GC (three strikes)**: an observation the model actually evaluated (parseable answer, however useless) but no op consumed accrues one failed attempt; the third failed attempt physically deletes it — explicitly authorized cleanup of raw data the lane demonstrably cannot process. Runs that never evaluated the batch never count: infrastructure failures (network errors, unconfigured distill route → readable `no-model` short-circuit) and unparseable output (`invalid-output`) are exempt, and a batch still mid-shrink on output-limit retries is only counted once a verdict is reached (success, floor stop, stall, or an explicit skip). Deletions are committed immediately (data destruction stays git-traceable); pure attempt counters follow the usual flush cadence.
145
- - **Config read timing**: `/topics set` and `settings.yaml` edits take effect most reliably from the next session start.
145
+ - **Config read timing**: `/topics set` and profile-patch edits take effect most reliably from the next session start (dsh 0.1.7: the settings document is the profile patch; the old `settings.yaml` is imported once and renamed).
146
146
  - **Picking a distill model**: `/topics onboard` splits the distill decision into two dependent questions (provider first, then that provider's model catalog), pre-validated with `resolveModelInfo` — a provider with no live route blocks and re-asks, an off-catalog model (a non-NO_ADAPTER failure: outside the advisory catalog, possibly still usable) warns but is allowed; hosts without an ask UI or a usable model route fall back to typed input. The same validation backs the `/topics set` picker panels.
147
147
 
148
148
  ## Design docs
package/README.zh.md CHANGED
@@ -97,7 +97,7 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
97
97
 
98
98
  ## 配置
99
99
 
100
- 首次配置交给 `/topics onboard`;日常微调用 `/topics set <key> <value>`——0.1.7+ 宿主上写设置页 `dsh-topics-memory` 条目(profile patch),全部键 volatile 免重启即时生效。全部键与默认值:
100
+ 首次配置交给 `/topics onboard`;日常微调用 `/topics set <key> <value>`——0.1.7+ 宿主上写设置页 `dsh-topics-memory` 条目(profile patch),全部键 volatile 免重启即时生效。从 0.1.7 之前升级:旧 `settings.yaml` 里的 `topics:` 段会在下次插件 boot 时一次性自动迁入新条目(审计档在 `~/.dsh/storages/dsh-topics-memory/legacy-import.json`)。全部键与默认值:
101
101
 
102
102
  | 键 | 默认 | 说明 |
103
103
  |---|---|---|
@@ -142,7 +142,7 @@ dsh plugin --profile <name> remove @aiwayds/dsh-topics-memory
142
142
  - **子代理默认不参与记忆,可整体打开**:默认(`include-subagents` off,0.7.0 起)delegation depth > 0 的子代理会话被整体跳过——不注入、不观察、不触发蒸馏;`/topics set include-subagents on` 后注入与观察同样作用于子代理会话。topic 工具始终在全局层(子代理显式 `topic_save` 不受开关影响)。跨进程子代理(claude-code/codex 等 provider)本就不加载本插件。
143
143
  - **退出路径本地化(0.10.0)**:插件 disposer 只做一步本地 git commit(meta 侧车文件:observations / injections / distill state),不再等待任何网络——没有 pull、没有 push、没有模型调用,宿主退出不再支付 git 双程与有界蒸馏等待(旧的 90s 上限移除,仅保留 10s 兜底以防病理性 git 卡死)。退出蒸馏触发改为 fire-and-forget;已有 session-end run 在跑时整体跳过(否则同一全局队头批次会被双份喂给模型)。跳过不丢活:observations 本就 write-through 落盘,推迟的 push 由下次启动的 pull 补推,被跳过的蒸馏同样由下次启动回放(boot-replay)。`meta/distill-state.json` 记录每次 lane 的结局,`/topics status` 可查。
144
144
  - **观察 GC(三振删除)**:被模型实际评估(返回了可解析应答,无论内容有无价值)却未被任何 op 消费的观察记一次 failed attempt,连续 3 次即物理删除(用户已明确授权删除 lane 确实无法处理的原始观察)。模型从未评估过的批次永不计数:基础设施失败(网络错误、蒸馏路由未配置 → 可读 `no-model` 短路)与输出不可解析(`invalid-output`)豁免;输出上限减半重试中的批次只有到达裁决(成功 / 触底 / stalled / 明确跳过)才计一次。删除立即 commit(数据销毁 git 可追溯),纯计数沿用 flush 节奏。
145
- - **配置读取时机**:`/topics set` 与 settings.yaml 修改在下次会话启动后生效最稳。
145
+ - **配置读取时机**:`/topics set` 与 profile patch 修改在下次会话启动后生效最稳(dsh 0.1.7 起 settings 文档即 profile patch;旧 settings.yaml 导入一次后改名)。
146
146
  - **蒸馏选模型**:`/topics onboard` 的蒸馏一步拆成两问(先 provider 后 model,模型列表取自该 provider 的目录),选完经 `resolveModelInfo` 预校验——provider 无活路由会阻断重选,模型目录校验非 NO_ADAPTER 失败(目录外,可能仍可用)则警告但放行;无 ask UI 或无可用模型路由的环境自动退回文本输入。`/topics set` 的选择面板走同一套校验。
147
147
 
148
148
  ## 设计文档
package/lib/index.d.ts CHANGED
@@ -163,5 +163,5 @@ export type TopicsRuntimeConfig = Partial<{
163
163
  * delimiter semantics of the upstream skill-filesystem provider.
164
164
  */
165
165
  export declare function stripFrontmatter(raw: string): string;
166
- export declare function apply(ctx: Context, config?: TopicsRuntimeConfig): void;
166
+ export declare function apply(ctx: Context, config?: TopicsRuntimeConfig): Promise<void>;
167
167
  export {};
package/lib/index.js CHANGED
@@ -35,6 +35,7 @@ import { Consolidator, dropExpiredDeprecated } from "./consolidate.js";
35
35
  import { SlowLane } from "./quality.js";
36
36
  import { CONFIG_KEYS, TopicsConfig } from "./config.js";
37
37
  import { isDelegated } from "./delegation.js";
38
+ import { runLegacySettingsImport } from "./legacy-import.js";
38
39
  export const name = 'dsh-topics-memory';
39
40
  /**
40
41
  * Bounded wait for the process-exit meta commit. The exit path is
@@ -235,7 +236,7 @@ export function stripFrontmatter(raw) {
235
236
  }
236
237
  return raw;
237
238
  }
238
- export function apply(ctx, config = {}) {
239
+ export async function apply(ctx, config = {}) {
239
240
  // `inject = ['skills']` guarantees the service exists on every real host;
240
241
  // register unconditionally so a missing service fails loud instead of
241
242
  // silently dropping the bundled skill.
@@ -893,6 +894,30 @@ export function apply(ctx, config = {}) {
893
894
  };
894
895
  cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate, resolveAsk, resolveLlm, manualDistill, manualConsolidate)), 'topics: /topics');
895
896
  });
897
+ // ---- One-time legacy settings import (0.1.5 → 0.1.7 upgrade path) ----
898
+ // The 0.1.7 host imports the old settings.yaml ONCE by "section name =
899
+ // entry id" and renames it settings.yaml.imported — this plugin's legacy
900
+ // section was named `topics`, so the host's import silently dropped it.
901
+ // Recover it here (see legacy-import.ts). Wired through
902
+ // ctx.inject(['settings'], …) instead of a direct `ctx.settings` read: a
903
+ // real boot can run apply() before the settings service is mounted, the
904
+ // direct read came up undefined, and the import then took the silent
905
+ // no-settings exit EVERY boot (no marker, nothing ever recovered). The
906
+ // inject callback fires the moment the service exists (immediately when
907
+ // it already does — fake-ctx tests stay synchronous), fire-and-forget so
908
+ // activation never waits on it. Fully contained: any failure only warns
909
+ // and leaves the audit marker unwritten, so the next boot retries;
910
+ // activation never depends on this.
911
+ ctx.inject(['settings'], (sctx) => {
912
+ void runLegacySettingsImport({
913
+ home: paths.resolveDshHome(),
914
+ settings: sctx.settings,
915
+ logger: ctx.logger,
916
+ getCurrent: (key) => cfgNow()[key],
917
+ }).catch((error) => {
918
+ warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`);
919
+ });
920
+ });
896
921
  }
897
922
  /** Config defaults for harnesses that skip schemastery's default application. */
898
923
  const DEFAULTS = {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * One-time legacy settings import for the 0.1.5 → 0.1.7 upgrade: the dsh
3
+ * 0.1.7 host renames the old `~/.dsh/settings.yaml` to
4
+ * `settings.yaml.imported` and imports its sections as profile patches keyed
5
+ * by "section name = entry id". This plugin's old section was named `topics`,
6
+ * NOT `dsh-topics-memory` — the host's one-shot import silently drops it.
7
+ * This module recovers it once:
8
+ *
9
+ * 1. read `<home>/settings.yaml.imported` (preferred) →
10
+ * `<home>/settings.yaml` (fallback);
11
+ * 2. flat-parse the `topics:` section (scalars only, conservative);
12
+ * 3. keep keys whose legacy value differs from the current effective value;
13
+ * 4. `settings.update('dsh-topics-memory', patch)` → on success write the
14
+ * audit marker `<home>/storages/dsh-topics-memory/legacy-import.json`;
15
+ * on failure warn and write nothing (the next boot retries);
16
+ * 5. marker present → the whole migration is skipped (idempotent — a
17
+ * later manual edit of the imported file must not resurrect old values).
18
+ *
19
+ * A marker is written for EVERY settled outcome (imported / no-op /
20
+ * no-legacy / no-section) so this runs at most once per home. Never throws
21
+ * into the caller beyond what the apply()-side catch contains: plugin
22
+ * activation must not depend on this.
23
+ *
24
+ * @module legacy-import
25
+ */
26
+ /** Profile entry id the recovered values are written to. */
27
+ export declare const ENTRY_ID = "dsh-topics-memory";
28
+ /** Legacy top-level section name in the old settings.yaml. */
29
+ export declare const LEGACY_SECTION = "topics";
30
+ /** Storage dir name for the audit marker (repo dir name, host convention). */
31
+ export declare const STORAGE_DIR_NAME = "dsh-topics-memory";
32
+ /** Legacy keys worth recovering, mapped 1:1 onto Config keys of the same name. */
33
+ export declare const LEGACY_KEYS: readonly ["repo", "distillProvider", "distillModel", "topK", "totalBudget", "autoObserve"];
34
+ export type LegacyKey = (typeof LEGACY_KEYS)[number];
35
+ /** The settings seam the import writes through (`ctx.settings.update`). */
36
+ export interface SettingsUpdateSeam {
37
+ update(ns: string, patch: object): Promise<void>;
38
+ }
39
+ /** Minimal logger surface (both sinks optional — bare harnesses have none). */
40
+ export interface LegacyImportLogger {
41
+ info?(message: string): void;
42
+ warn?(message: string): void;
43
+ }
44
+ export interface LegacyImportInput {
45
+ /** Resolved dsh home (`paths.resolveDshHome()` at the call site). */
46
+ home: string;
47
+ /** `ctx.settings` — absent/unusable seams skip the import without a marker. */
48
+ settings?: SettingsUpdateSeam | undefined;
49
+ /** Host logger (optional). */
50
+ logger?: LegacyImportLogger | undefined;
51
+ /** Current effective value for one legacy key (Volatile ref `.get()` at the call site). */
52
+ getCurrent(key: LegacyKey): unknown;
53
+ /**
54
+ * Retry budget for `settings.update`. The host serializes profile writes
55
+ * (settings.update → dsh-config-editor) behind the `<profile>/package.json`
56
+ * writer lock with a 2s per-call deadline, and during BOOT the host itself
57
+ * holds that lock while preparing the profile — a single bare call almost
58
+ * always times out (2026-09-25 incident: the import failed EVERY boot and
59
+ * the migration never converged). Defaults to 5 attempts 2s apart; a
60
+ * permanently orphaned lock (a SIGKILLed host never releases it — orphan
61
+ * recovery is an operator action) still exhausts the budget and stays
62
+ * retry-worthy. Tests shrink this to keep the suite fast.
63
+ */
64
+ updateRetry?: {
65
+ attempts: number;
66
+ delayMs: number;
67
+ } | undefined;
68
+ }
69
+ export type LegacyImportOutcome = 'imported' | 'no-op' | 'no-legacy' | 'no-section' | 'marker-exists' | 'no-settings' | 'update-failed';
70
+ export interface LegacyImportResult {
71
+ outcome: LegacyImportOutcome;
72
+ /** Keys written through settings.update (also in the marker). */
73
+ imported: Record<string, unknown>;
74
+ /** Keys not imported, with the reason (audit trail). */
75
+ skipped: Record<string, string>;
76
+ }
77
+ /** Candidates in priority order: the host's renamed document first. */
78
+ export declare function legacySettingsCandidates(home: string): string[];
79
+ /** Audit marker path: `<home>/storages/<dir>/legacy-import.json`. */
80
+ export declare function legacyMarkerPath(home: string): string;
81
+ /**
82
+ * Scalar inference for one flat `key: value` value. Quoted values are
83
+ * unquoted (mirroring src/yaml.ts); a single-line quoted flow-JSON value
84
+ * (`'["a","b"]'`) is JSON-parsed after unquoting — a malformed one stays a
85
+ * string. Unquoted flow collections are kept as strings (conservative:
86
+ * this is not a full YAML parser). Empty never reaches here (the line
87
+ * parser skips value-less keys — they open a nested block).
88
+ */
89
+ export declare function inferScalar(raw: string): unknown;
90
+ /**
91
+ * Flat section parser: locate the top-level `section:` line and collect its
92
+ * DIRECT children (`key: value` at one common indent) until the next
93
+ * top-level key. Deliberately conservative — NOT a YAML parser:
94
+ * - `key:` with an empty value (opens a nested map/sequence) is skipped, and
95
+ * the deeper lines under it never match the child indent, so folded values
96
+ * are skipped whole;
97
+ * - lines at any other indent are ignored;
98
+ * - an inline value on the header line itself (`section: x`) is not a flat
99
+ * map → `{}`.
100
+ * Pure function — tests run it against literal strings.
101
+ */
102
+ export declare function parseFlatSection(raw: string, section: string): Record<string, unknown>;
103
+ /**
104
+ * Run the one-time import. Resolves with the outcome; only throws on truly
105
+ * unexpected I/O errors (the apply()-side catch contains those). The marker
106
+ * is written for every settled outcome EXCEPT the retry-worthy failures
107
+ * (`no-settings`, `update-failed`) — those must re-run at the next boot.
108
+ */
109
+ export declare function runLegacySettingsImport(input: LegacyImportInput): Promise<LegacyImportResult>;
@@ -0,0 +1,243 @@
1
+ /**
2
+ * One-time legacy settings import for the 0.1.5 → 0.1.7 upgrade: the dsh
3
+ * 0.1.7 host renames the old `~/.dsh/settings.yaml` to
4
+ * `settings.yaml.imported` and imports its sections as profile patches keyed
5
+ * by "section name = entry id". This plugin's old section was named `topics`,
6
+ * NOT `dsh-topics-memory` — the host's one-shot import silently drops it.
7
+ * This module recovers it once:
8
+ *
9
+ * 1. read `<home>/settings.yaml.imported` (preferred) →
10
+ * `<home>/settings.yaml` (fallback);
11
+ * 2. flat-parse the `topics:` section (scalars only, conservative);
12
+ * 3. keep keys whose legacy value differs from the current effective value;
13
+ * 4. `settings.update('dsh-topics-memory', patch)` → on success write the
14
+ * audit marker `<home>/storages/dsh-topics-memory/legacy-import.json`;
15
+ * on failure warn and write nothing (the next boot retries);
16
+ * 5. marker present → the whole migration is skipped (idempotent — a
17
+ * later manual edit of the imported file must not resurrect old values).
18
+ *
19
+ * A marker is written for EVERY settled outcome (imported / no-op /
20
+ * no-legacy / no-section) so this runs at most once per home. Never throws
21
+ * into the caller beyond what the apply()-side catch contains: plugin
22
+ * activation must not depend on this.
23
+ *
24
+ * @module legacy-import
25
+ */
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ /** Profile entry id the recovered values are written to. */
29
+ export const ENTRY_ID = 'dsh-topics-memory';
30
+ /** Legacy top-level section name in the old settings.yaml. */
31
+ export const LEGACY_SECTION = 'topics';
32
+ /** Storage dir name for the audit marker (repo dir name, host convention). */
33
+ export const STORAGE_DIR_NAME = 'dsh-topics-memory';
34
+ /** Legacy keys worth recovering, mapped 1:1 onto Config keys of the same name. */
35
+ export const LEGACY_KEYS = ['repo', 'distillProvider', 'distillModel', 'topK', 'totalBudget', 'autoObserve'];
36
+ /** Candidates in priority order: the host's renamed document first. */
37
+ export function legacySettingsCandidates(home) {
38
+ return [join(home, 'settings.yaml.imported'), join(home, 'settings.yaml')];
39
+ }
40
+ /** Audit marker path: `<home>/storages/<dir>/legacy-import.json`. */
41
+ export function legacyMarkerPath(home) {
42
+ return join(home, 'storages', STORAGE_DIR_NAME, 'legacy-import.json');
43
+ }
44
+ function escapeRegExp(s) {
45
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
+ }
47
+ /** A fully quoted scalar: balanced matching quote pair. */
48
+ function isQuoted(s) {
49
+ return s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")));
50
+ }
51
+ function unquote(s) {
52
+ const inner = s.slice(1, -1);
53
+ // Double quotes: unescape \" and \\; single quotes: '' is a literal '.
54
+ if (s.startsWith('"'))
55
+ return inner.replaceAll('\\"', '"').replaceAll('\\\\', '\\');
56
+ return inner.replaceAll("''", "'");
57
+ }
58
+ /**
59
+ * Scalar inference for one flat `key: value` value. Quoted values are
60
+ * unquoted (mirroring src/yaml.ts); a single-line quoted flow-JSON value
61
+ * (`'["a","b"]'`) is JSON-parsed after unquoting — a malformed one stays a
62
+ * string. Unquoted flow collections are kept as strings (conservative:
63
+ * this is not a full YAML parser). Empty never reaches here (the line
64
+ * parser skips value-less keys — they open a nested block).
65
+ */
66
+ export function inferScalar(raw) {
67
+ const s = raw.trim();
68
+ if (isQuoted(s)) {
69
+ const inner = unquote(s);
70
+ const t = inner.trim();
71
+ if (t.startsWith('[') || t.startsWith('{')) {
72
+ try {
73
+ return JSON.parse(t);
74
+ }
75
+ catch {
76
+ // not JSON after all — keep the unquoted string
77
+ }
78
+ }
79
+ return inner;
80
+ }
81
+ if (s === 'true' || s === 'True' || s === 'TRUE')
82
+ return true;
83
+ if (s === 'false' || s === 'False' || s === 'FALSE')
84
+ return false;
85
+ if (/^-?\d+$/.test(s) || /^-?\d+\.\d+$/.test(s))
86
+ return Number(s);
87
+ return s;
88
+ }
89
+ /**
90
+ * Flat section parser: locate the top-level `section:` line and collect its
91
+ * DIRECT children (`key: value` at one common indent) until the next
92
+ * top-level key. Deliberately conservative — NOT a YAML parser:
93
+ * - `key:` with an empty value (opens a nested map/sequence) is skipped, and
94
+ * the deeper lines under it never match the child indent, so folded values
95
+ * are skipped whole;
96
+ * - lines at any other indent are ignored;
97
+ * - an inline value on the header line itself (`section: x`) is not a flat
98
+ * map → `{}`.
99
+ * Pure function — tests run it against literal strings.
100
+ */
101
+ export function parseFlatSection(raw, section) {
102
+ const lines = raw.split('\n').map((l) => l.replace(/\r$/, ''));
103
+ const header = new RegExp(`^${escapeRegExp(section)}:(.*)$`);
104
+ const start = lines.findIndex((l) => header.test(l));
105
+ if (start < 0)
106
+ return {};
107
+ // An inline header value means the section is not a block map of children.
108
+ if (lines[start].replace(header, '$1').trim() !== '')
109
+ return {};
110
+ const out = {};
111
+ let childIndent;
112
+ for (let i = start + 1; i < lines.length; i++) {
113
+ const line = lines[i];
114
+ if (line.trim() === '' || line.trimStart().startsWith('#'))
115
+ continue;
116
+ const indent = line.length - line.trimStart().length;
117
+ if (indent === 0)
118
+ break; // next top-level key — section over
119
+ if (childIndent === undefined)
120
+ childIndent = indent;
121
+ if (indent !== childIndent)
122
+ continue; // nested block content — skipped
123
+ const match = /^([^\s:][^:]*):(?:[ \t]+(.*))?$/.exec(line.trim());
124
+ if (match === null)
125
+ continue; // not a `key: value` line — skipped
126
+ if (match[2] === undefined || match[2].trim() === '')
127
+ continue; // opens a nested block
128
+ out[match[1].trim()] = inferScalar(match[2]);
129
+ }
130
+ return out;
131
+ }
132
+ /**
133
+ * Run the one-time import. Resolves with the outcome; only throws on truly
134
+ * unexpected I/O errors (the apply()-side catch contains those). The marker
135
+ * is written for every settled outcome EXCEPT the retry-worthy failures
136
+ * (`no-settings`, `update-failed`) — those must re-run at the next boot.
137
+ */
138
+ export async function runLegacySettingsImport(input) {
139
+ const skipped = {};
140
+ const imported = {};
141
+ const warn = (message) => {
142
+ try {
143
+ input.logger?.warn?.(message);
144
+ }
145
+ catch {
146
+ // contained — a broken logger must not break the import
147
+ }
148
+ };
149
+ const info = (message) => {
150
+ try {
151
+ input.logger?.info?.(message);
152
+ }
153
+ catch {
154
+ // contained
155
+ }
156
+ };
157
+ const markerPath = legacyMarkerPath(input.home);
158
+ if (existsSync(markerPath))
159
+ return { outcome: 'marker-exists', imported, skipped };
160
+ // A settings seam that cannot accept the patch is a retry-worthy state:
161
+ // return WITHOUT reading/marking, so a host that mounts the service later
162
+ // (and a test harness pointing at a real home) neither imports nor writes.
163
+ // Not silent: the 2026-09-24 incident hid exactly behind this quiet exit —
164
+ // the warn is what makes a never-mounting seam observable in a boot log.
165
+ const settings = input.settings;
166
+ if (settings === undefined || typeof settings.update !== 'function') {
167
+ warn(`${ENTRY_ID}: legacy settings import deferred: settings seam unusable (no-settings; will retry next boot)`);
168
+ return { outcome: 'no-settings', imported, skipped };
169
+ }
170
+ // Locate the legacy document.
171
+ const source = legacySettingsCandidates(input.home).find((p) => existsSync(p));
172
+ if (source === undefined) {
173
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-legacy', source: undefined, imported, skipped });
174
+ return { outcome: 'no-legacy', imported, skipped };
175
+ }
176
+ const section = parseFlatSection(readFileSync(source, 'utf8'), LEGACY_SECTION);
177
+ // The marker records the source BASENAME: the marker lives inside the same
178
+ // home, so the directory part is implied.
179
+ const sourceName = source.slice(source.lastIndexOf('/') + 1);
180
+ const present = Object.keys(section);
181
+ if (present.length === 0) {
182
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-section', source: sourceName, imported, skipped });
183
+ return { outcome: 'no-section', imported, skipped };
184
+ }
185
+ // Key-level rule: import only when the legacy value differs from the
186
+ // current effective value; keys this plugin does not know are recorded and
187
+ // left alone (an unknown key would be rejected by the host's schema).
188
+ for (const key of present) {
189
+ if (!LEGACY_KEYS.includes(key)) {
190
+ skipped[key] = 'unknown-key';
191
+ continue;
192
+ }
193
+ const legacyValue = section[key];
194
+ if (legacyValue === input.getCurrent(key))
195
+ skipped[key] = 'equal';
196
+ else
197
+ imported[key] = legacyValue;
198
+ }
199
+ if (Object.keys(imported).length === 0) {
200
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-op', source: sourceName, imported, skipped });
201
+ info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): nothing to import (all values equal or unknown)`);
202
+ return { outcome: 'no-op', imported, skipped };
203
+ }
204
+ // The update contends on the host's profile writer lock (see
205
+ // {@link LegacyImportInput.updateRetry}): retry with a fixed gap before
206
+ // conceding. Every failed attempt warns — a silent loss here cost a full
207
+ // day of "the migration never runs" debugging (2026-09-25).
208
+ const retry = input.updateRetry ?? { attempts: 5, delayMs: 2_000 };
209
+ let updateFailed;
210
+ for (let attempt = 1; attempt <= retry.attempts; attempt++) {
211
+ try {
212
+ await settings.update(ENTRY_ID, imported);
213
+ updateFailed = undefined;
214
+ break;
215
+ }
216
+ catch (error) {
217
+ updateFailed = error;
218
+ warn(`${ENTRY_ID}: legacy settings import: settings.update attempt ${attempt}/${retry.attempts} failed: ${error instanceof Error ? error.message : String(error)}`);
219
+ if (attempt < retry.attempts)
220
+ await new Promise((resolve) => setTimeout(resolve, retry.delayMs));
221
+ }
222
+ }
223
+ if (updateFailed !== undefined) {
224
+ warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${updateFailed instanceof Error ? updateFailed.message : String(updateFailed)}`);
225
+ // No marker: the next boot retries the import.
226
+ return { outcome: 'update-failed', imported, skipped };
227
+ }
228
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'imported', source: sourceName, imported, skipped });
229
+ info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): ${Object.keys(imported).join(', ')}`);
230
+ return { outcome: 'imported', imported, skipped };
231
+ }
232
+ /** Persist the audit marker; a failed write leaves no marker (harmless — the
233
+ * next boot re-runs and converges, every step after the marker check is
234
+ * idempotent). */
235
+ function writeMarker(path, marker) {
236
+ try {
237
+ mkdirSync(join(path, '..'), { recursive: true });
238
+ writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`);
239
+ }
240
+ catch {
241
+ // contained — see above
242
+ }
243
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-topics-memory",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc -p tsconfig.json",
43
43
  "check": "tsc --noEmit -p tsconfig.json",
44
44
  "pretest": "tsc -p tsconfig.json",
45
- "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs",
45
+ "test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs test/legacy-import.test.mjs",
46
46
  "smoke": "node scripts/smoke-boot.mjs"
47
47
  },
48
48
  "dependencies": {},
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ import type { SlowDelivery } from './service.ts'
52
52
  import { CONFIG_KEYS, TopicsConfig, type TopicsConfigValue } from './config.ts'
53
53
  import type { AskServiceResolver, AskServiceShape, LlmDirectoryResolver, LlmDirectoryShape } from './onboard.ts'
54
54
  import { isDelegated } from './delegation.ts'
55
+ import { runLegacySettingsImport, type LegacyImportLogger, type SettingsUpdateSeam } from './legacy-import.ts'
55
56
 
56
57
  export const name = 'dsh-topics-memory'
57
58
 
@@ -354,7 +355,7 @@ export function stripFrontmatter(raw: string): string {
354
355
  return raw
355
356
  }
356
357
 
357
- export function apply(ctx: Context, config: TopicsRuntimeConfig = {}): void {
358
+ export async function apply(ctx: Context, config: TopicsRuntimeConfig = {}): Promise<void> {
358
359
  // `inject = ['skills']` guarantees the service exists on every real host;
359
360
  // register unconditionally so a missing service fails loud instead of
360
361
  // silently dropping the bundled skill.
@@ -1010,6 +1011,31 @@ export function apply(ctx: Context, config: TopicsRuntimeConfig = {}): void {
1010
1011
  }
1011
1012
  cmdCtx.effect(() => commands.register(buildTopicsCommand(service, mutate as never, resolveAsk, resolveLlm, manualDistill, manualConsolidate)), 'topics: /topics')
1012
1013
  })
1014
+
1015
+ // ---- One-time legacy settings import (0.1.5 → 0.1.7 upgrade path) ----
1016
+ // The 0.1.7 host imports the old settings.yaml ONCE by "section name =
1017
+ // entry id" and renames it settings.yaml.imported — this plugin's legacy
1018
+ // section was named `topics`, so the host's import silently dropped it.
1019
+ // Recover it here (see legacy-import.ts). Wired through
1020
+ // ctx.inject(['settings'], …) instead of a direct `ctx.settings` read: a
1021
+ // real boot can run apply() before the settings service is mounted, the
1022
+ // direct read came up undefined, and the import then took the silent
1023
+ // no-settings exit EVERY boot (no marker, nothing ever recovered). The
1024
+ // inject callback fires the moment the service exists (immediately when
1025
+ // it already does — fake-ctx tests stay synchronous), fire-and-forget so
1026
+ // activation never waits on it. Fully contained: any failure only warns
1027
+ // and leaves the audit marker unwritten, so the next boot retries;
1028
+ // activation never depends on this.
1029
+ ctx.inject(['settings'], (sctx) => {
1030
+ void runLegacySettingsImport({
1031
+ home: paths.resolveDshHome(),
1032
+ settings: (sctx as unknown as { settings?: SettingsUpdateSeam }).settings,
1033
+ logger: (ctx as unknown as { logger?: LegacyImportLogger }).logger,
1034
+ getCurrent: (key) => cfgNow()[key],
1035
+ }).catch((error) => {
1036
+ warn(`dsh-topics-memory 旧 settings 迁移失败(不影响启动,下次启动重试):${error instanceof Error ? error.message : String(error)}`)
1037
+ })
1038
+ })
1013
1039
  }
1014
1040
 
1015
1041
  /** Config defaults for harnesses that skip schemastery's default application. */
@@ -0,0 +1,297 @@
1
+ /**
2
+ * One-time legacy settings import for the 0.1.5 → 0.1.7 upgrade: the dsh
3
+ * 0.1.7 host renames the old `~/.dsh/settings.yaml` to
4
+ * `settings.yaml.imported` and imports its sections as profile patches keyed
5
+ * by "section name = entry id". This plugin's old section was named `topics`,
6
+ * NOT `dsh-topics-memory` — the host's one-shot import silently drops it.
7
+ * This module recovers it once:
8
+ *
9
+ * 1. read `<home>/settings.yaml.imported` (preferred) →
10
+ * `<home>/settings.yaml` (fallback);
11
+ * 2. flat-parse the `topics:` section (scalars only, conservative);
12
+ * 3. keep keys whose legacy value differs from the current effective value;
13
+ * 4. `settings.update('dsh-topics-memory', patch)` → on success write the
14
+ * audit marker `<home>/storages/dsh-topics-memory/legacy-import.json`;
15
+ * on failure warn and write nothing (the next boot retries);
16
+ * 5. marker present → the whole migration is skipped (idempotent — a
17
+ * later manual edit of the imported file must not resurrect old values).
18
+ *
19
+ * A marker is written for EVERY settled outcome (imported / no-op /
20
+ * no-legacy / no-section) so this runs at most once per home. Never throws
21
+ * into the caller beyond what the apply()-side catch contains: plugin
22
+ * activation must not depend on this.
23
+ *
24
+ * @module legacy-import
25
+ */
26
+
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
28
+ import { join } from 'node:path'
29
+
30
+ /** Profile entry id the recovered values are written to. */
31
+ export const ENTRY_ID = 'dsh-topics-memory'
32
+
33
+ /** Legacy top-level section name in the old settings.yaml. */
34
+ export const LEGACY_SECTION = 'topics'
35
+
36
+ /** Storage dir name for the audit marker (repo dir name, host convention). */
37
+ export const STORAGE_DIR_NAME = 'dsh-topics-memory'
38
+
39
+ /** Legacy keys worth recovering, mapped 1:1 onto Config keys of the same name. */
40
+ export const LEGACY_KEYS = ['repo', 'distillProvider', 'distillModel', 'topK', 'totalBudget', 'autoObserve'] as const
41
+
42
+ export type LegacyKey = (typeof LEGACY_KEYS)[number]
43
+
44
+ /** The settings seam the import writes through (`ctx.settings.update`). */
45
+ export interface SettingsUpdateSeam {
46
+ update(ns: string, patch: object): Promise<void>
47
+ }
48
+
49
+ /** Minimal logger surface (both sinks optional — bare harnesses have none). */
50
+ export interface LegacyImportLogger {
51
+ info?(message: string): void
52
+ warn?(message: string): void
53
+ }
54
+
55
+ export interface LegacyImportInput {
56
+ /** Resolved dsh home (`paths.resolveDshHome()` at the call site). */
57
+ home: string
58
+ /** `ctx.settings` — absent/unusable seams skip the import without a marker. */
59
+ settings?: SettingsUpdateSeam | undefined
60
+ /** Host logger (optional). */
61
+ logger?: LegacyImportLogger | undefined
62
+ /** Current effective value for one legacy key (Volatile ref `.get()` at the call site). */
63
+ getCurrent(key: LegacyKey): unknown
64
+ /**
65
+ * Retry budget for `settings.update`. The host serializes profile writes
66
+ * (settings.update → dsh-config-editor) behind the `<profile>/package.json`
67
+ * writer lock with a 2s per-call deadline, and during BOOT the host itself
68
+ * holds that lock while preparing the profile — a single bare call almost
69
+ * always times out (2026-09-25 incident: the import failed EVERY boot and
70
+ * the migration never converged). Defaults to 5 attempts 2s apart; a
71
+ * permanently orphaned lock (a SIGKILLed host never releases it — orphan
72
+ * recovery is an operator action) still exhausts the budget and stays
73
+ * retry-worthy. Tests shrink this to keep the suite fast.
74
+ */
75
+ updateRetry?: { attempts: number; delayMs: number } | undefined
76
+ }
77
+
78
+ export type LegacyImportOutcome =
79
+ | 'imported' // ≥1 key written through settings.update, marker written
80
+ | 'no-op' // section found but every key equal/unknown, marker written
81
+ | 'no-legacy' // neither legacy file exists, marker written
82
+ | 'no-section' // legacy file exists without a `topics:` section, marker written
83
+ | 'marker-exists' // already migrated before — nothing touched
84
+ | 'no-settings' // settings seam unusable — no marker, retried next boot
85
+ | 'update-failed' // settings.update rejected — no marker, retried next boot
86
+
87
+ export interface LegacyImportResult {
88
+ outcome: LegacyImportOutcome
89
+ /** Keys written through settings.update (also in the marker). */
90
+ imported: Record<string, unknown>
91
+ /** Keys not imported, with the reason (audit trail). */
92
+ skipped: Record<string, string>
93
+ }
94
+
95
+ /** Candidates in priority order: the host's renamed document first. */
96
+ export function legacySettingsCandidates(home: string): string[] {
97
+ return [join(home, 'settings.yaml.imported'), join(home, 'settings.yaml')]
98
+ }
99
+
100
+ /** Audit marker path: `<home>/storages/<dir>/legacy-import.json`. */
101
+ export function legacyMarkerPath(home: string): string {
102
+ return join(home, 'storages', STORAGE_DIR_NAME, 'legacy-import.json')
103
+ }
104
+
105
+ function escapeRegExp(s: string): string {
106
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
107
+ }
108
+
109
+ /** A fully quoted scalar: balanced matching quote pair. */
110
+ function isQuoted(s: string): boolean {
111
+ return s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))
112
+ }
113
+
114
+ function unquote(s: string): string {
115
+ const inner = s.slice(1, -1)
116
+ // Double quotes: unescape \" and \\; single quotes: '' is a literal '.
117
+ if (s.startsWith('"')) return inner.replaceAll('\\"', '"').replaceAll('\\\\', '\\')
118
+ return inner.replaceAll("''", "'")
119
+ }
120
+
121
+ /**
122
+ * Scalar inference for one flat `key: value` value. Quoted values are
123
+ * unquoted (mirroring src/yaml.ts); a single-line quoted flow-JSON value
124
+ * (`'["a","b"]'`) is JSON-parsed after unquoting — a malformed one stays a
125
+ * string. Unquoted flow collections are kept as strings (conservative:
126
+ * this is not a full YAML parser). Empty never reaches here (the line
127
+ * parser skips value-less keys — they open a nested block).
128
+ */
129
+ export function inferScalar(raw: string): unknown {
130
+ const s = raw.trim()
131
+ if (isQuoted(s)) {
132
+ const inner = unquote(s)
133
+ const t = inner.trim()
134
+ if (t.startsWith('[') || t.startsWith('{')) {
135
+ try {
136
+ return JSON.parse(t)
137
+ } catch {
138
+ // not JSON after all — keep the unquoted string
139
+ }
140
+ }
141
+ return inner
142
+ }
143
+ if (s === 'true' || s === 'True' || s === 'TRUE') return true
144
+ if (s === 'false' || s === 'False' || s === 'FALSE') return false
145
+ if (/^-?\d+$/.test(s) || /^-?\d+\.\d+$/.test(s)) return Number(s)
146
+ return s
147
+ }
148
+
149
+ /**
150
+ * Flat section parser: locate the top-level `section:` line and collect its
151
+ * DIRECT children (`key: value` at one common indent) until the next
152
+ * top-level key. Deliberately conservative — NOT a YAML parser:
153
+ * - `key:` with an empty value (opens a nested map/sequence) is skipped, and
154
+ * the deeper lines under it never match the child indent, so folded values
155
+ * are skipped whole;
156
+ * - lines at any other indent are ignored;
157
+ * - an inline value on the header line itself (`section: x`) is not a flat
158
+ * map → `{}`.
159
+ * Pure function — tests run it against literal strings.
160
+ */
161
+ export function parseFlatSection(raw: string, section: string): Record<string, unknown> {
162
+ const lines = raw.split('\n').map((l) => l.replace(/\r$/, ''))
163
+ const header = new RegExp(`^${escapeRegExp(section)}:(.*)$`)
164
+ const start = lines.findIndex((l) => header.test(l))
165
+ if (start < 0) return {}
166
+ // An inline header value means the section is not a block map of children.
167
+ if (lines[start].replace(header, '$1').trim() !== '') return {}
168
+ const out: Record<string, unknown> = {}
169
+ let childIndent: number | undefined
170
+ for (let i = start + 1; i < lines.length; i++) {
171
+ const line = lines[i]
172
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue
173
+ const indent = line.length - line.trimStart().length
174
+ if (indent === 0) break // next top-level key — section over
175
+ if (childIndent === undefined) childIndent = indent
176
+ if (indent !== childIndent) continue // nested block content — skipped
177
+ const match = /^([^\s:][^:]*):(?:[ \t]+(.*))?$/.exec(line.trim())
178
+ if (match === null) continue // not a `key: value` line — skipped
179
+ if (match[2] === undefined || match[2].trim() === '') continue // opens a nested block
180
+ out[match[1].trim()] = inferScalar(match[2])
181
+ }
182
+ return out
183
+ }
184
+
185
+ /**
186
+ * Run the one-time import. Resolves with the outcome; only throws on truly
187
+ * unexpected I/O errors (the apply()-side catch contains those). The marker
188
+ * is written for every settled outcome EXCEPT the retry-worthy failures
189
+ * (`no-settings`, `update-failed`) — those must re-run at the next boot.
190
+ */
191
+ export async function runLegacySettingsImport(input: LegacyImportInput): Promise<LegacyImportResult> {
192
+ const skipped: Record<string, string> = {}
193
+ const imported: Record<string, unknown> = {}
194
+ const warn = (message: string): void => {
195
+ try {
196
+ input.logger?.warn?.(message)
197
+ } catch {
198
+ // contained — a broken logger must not break the import
199
+ }
200
+ }
201
+ const info = (message: string): void => {
202
+ try {
203
+ input.logger?.info?.(message)
204
+ } catch {
205
+ // contained
206
+ }
207
+ }
208
+
209
+ const markerPath = legacyMarkerPath(input.home)
210
+ if (existsSync(markerPath)) return { outcome: 'marker-exists', imported, skipped }
211
+
212
+ // A settings seam that cannot accept the patch is a retry-worthy state:
213
+ // return WITHOUT reading/marking, so a host that mounts the service later
214
+ // (and a test harness pointing at a real home) neither imports nor writes.
215
+ // Not silent: the 2026-09-24 incident hid exactly behind this quiet exit —
216
+ // the warn is what makes a never-mounting seam observable in a boot log.
217
+ const settings = input.settings
218
+ if (settings === undefined || typeof settings.update !== 'function') {
219
+ warn(`${ENTRY_ID}: legacy settings import deferred: settings seam unusable (no-settings; will retry next boot)`)
220
+ return { outcome: 'no-settings', imported, skipped }
221
+ }
222
+
223
+ // Locate the legacy document.
224
+ const source = legacySettingsCandidates(input.home).find((p) => existsSync(p))
225
+ if (source === undefined) {
226
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-legacy', source: undefined, imported, skipped })
227
+ return { outcome: 'no-legacy', imported, skipped }
228
+ }
229
+
230
+ const section = parseFlatSection(readFileSync(source, 'utf8'), LEGACY_SECTION)
231
+ // The marker records the source BASENAME: the marker lives inside the same
232
+ // home, so the directory part is implied.
233
+ const sourceName = source.slice(source.lastIndexOf('/') + 1)
234
+ const present = Object.keys(section)
235
+ if (present.length === 0) {
236
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-section', source: sourceName, imported, skipped })
237
+ return { outcome: 'no-section', imported, skipped }
238
+ }
239
+
240
+ // Key-level rule: import only when the legacy value differs from the
241
+ // current effective value; keys this plugin does not know are recorded and
242
+ // left alone (an unknown key would be rejected by the host's schema).
243
+ for (const key of present) {
244
+ if (!(LEGACY_KEYS as readonly string[]).includes(key)) {
245
+ skipped[key] = 'unknown-key'
246
+ continue
247
+ }
248
+ const legacyValue = section[key]
249
+ if (legacyValue === input.getCurrent(key as LegacyKey)) skipped[key] = 'equal'
250
+ else imported[key] = legacyValue
251
+ }
252
+
253
+ if (Object.keys(imported).length === 0) {
254
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'no-op', source: sourceName, imported, skipped })
255
+ info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): nothing to import (all values equal or unknown)`)
256
+ return { outcome: 'no-op', imported, skipped }
257
+ }
258
+
259
+ // The update contends on the host's profile writer lock (see
260
+ // {@link LegacyImportInput.updateRetry}): retry with a fixed gap before
261
+ // conceding. Every failed attempt warns — a silent loss here cost a full
262
+ // day of "the migration never runs" debugging (2026-09-25).
263
+ const retry = input.updateRetry ?? { attempts: 5, delayMs: 2_000 }
264
+ let updateFailed: unknown
265
+ for (let attempt = 1; attempt <= retry.attempts; attempt++) {
266
+ try {
267
+ await settings.update(ENTRY_ID, imported)
268
+ updateFailed = undefined
269
+ break
270
+ } catch (error) {
271
+ updateFailed = error
272
+ warn(`${ENTRY_ID}: legacy settings import: settings.update attempt ${attempt}/${retry.attempts} failed: ${error instanceof Error ? error.message : String(error)}`)
273
+ if (attempt < retry.attempts) await new Promise((resolve) => setTimeout(resolve, retry.delayMs))
274
+ }
275
+ }
276
+ if (updateFailed !== undefined) {
277
+ warn(`${ENTRY_ID}: legacy settings import failed (will retry next boot): ${updateFailed instanceof Error ? updateFailed.message : String(updateFailed)}`)
278
+ // No marker: the next boot retries the import.
279
+ return { outcome: 'update-failed', imported, skipped }
280
+ }
281
+
282
+ writeMarker(markerPath, { at: new Date().toISOString(), outcome: 'imported', source: sourceName, imported, skipped })
283
+ info(`${ENTRY_ID}: legacy settings import (${LEGACY_SECTION} → ${ENTRY_ID}): ${Object.keys(imported).join(', ')}`)
284
+ return { outcome: 'imported', imported, skipped }
285
+ }
286
+
287
+ /** Persist the audit marker; a failed write leaves no marker (harmless — the
288
+ * next boot re-runs and converges, every step after the marker check is
289
+ * idempotent). */
290
+ function writeMarker(path: string, marker: Record<string, unknown>): void {
291
+ try {
292
+ mkdirSync(join(path, '..'), { recursive: true })
293
+ writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`)
294
+ } catch {
295
+ // contained — see above
296
+ }
297
+ }