@elvisw/dsh-magic-context 0.0.0-stage → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +23 -0
  2. package/NOTICE +93 -0
  3. package/README.md +501 -2
  4. package/README.zh-CN.md +398 -0
  5. package/cordis.patch.yml +20 -0
  6. package/lib/client.js +2753 -0
  7. package/lib/core/compartments.js +424 -0
  8. package/lib/core/corpus-store.js +176 -0
  9. package/lib/core/directive-detect.js +147 -0
  10. package/lib/core/domain-reconcile.js +394 -0
  11. package/lib/core/domains.js +127 -0
  12. package/lib/core/embedding-local.js +247 -0
  13. package/lib/core/global-autostage.js +223 -0
  14. package/lib/core/global-domains.js +302 -0
  15. package/lib/core/journal.js +248 -0
  16. package/lib/core/llm-deadline.js +53 -0
  17. package/lib/core/machine-domain.js +106 -0
  18. package/lib/core/maintenance.js +380 -0
  19. package/lib/core/memory-bridge.js +818 -0
  20. package/lib/core/memory-categories.js +84 -0
  21. package/lib/core/memory-scoring.js +65 -0
  22. package/lib/core/memory-search.js +414 -0
  23. package/lib/core/memory-write.js +1176 -0
  24. package/lib/core/policy.js +111 -0
  25. package/lib/core/profile-confirmation.js +55 -0
  26. package/lib/core/profile-review.js +375 -0
  27. package/lib/core/profile-write.js +283 -0
  28. package/lib/core/reference-seeds.js +63 -0
  29. package/lib/core/route-health.js +106 -0
  30. package/lib/core/stores.js +1021 -0
  31. package/lib/core/tagger.js +7 -0
  32. package/lib/core/types.js +15 -0
  33. package/lib/core/vector-backfill.js +140 -0
  34. package/lib/core/vector-lane.js +184 -0
  35. package/lib/dsh/command-surface.js +376 -0
  36. package/lib/dsh/compaction-engine.js +231 -0
  37. package/lib/dsh/config-switches.js +56 -0
  38. package/lib/dsh/config.js +13 -0
  39. package/lib/dsh/context-pressure.js +36 -0
  40. package/lib/dsh/drop-landing.js +42 -0
  41. package/lib/dsh/emergency.js +89 -0
  42. package/lib/dsh/flush-command.js +148 -0
  43. package/lib/dsh/global-review.js +412 -0
  44. package/lib/dsh/historian.js +907 -0
  45. package/lib/dsh/live-journal.js +81 -0
  46. package/lib/dsh/maintenance-wiring.js +229 -0
  47. package/lib/dsh/management-pass.js +564 -0
  48. package/lib/dsh/memory-lane.js +535 -0
  49. package/lib/dsh/pre-step.js +675 -0
  50. package/lib/dsh/project-drift.js +116 -0
  51. package/lib/dsh/recomp.js +192 -0
  52. package/lib/dsh/reduce-guidance.js +188 -0
  53. package/lib/dsh/seams.js +588 -0
  54. package/lib/dsh/search-hint.js +186 -0
  55. package/lib/dsh/session-notice.js +100 -0
  56. package/lib/dsh/status-command.js +75 -0
  57. package/lib/dsh/telemetry.js +349 -0
  58. package/lib/dsh/tool-bridge.js +1009 -0
  59. package/lib/dsh/volatile.js +89 -0
  60. package/lib/index.js +931 -0
  61. package/package.json +108 -4
  62. package/tools/migrate-profile-package-name.mjs +317 -0
  63. package/vendor/onnx/LICENSE +27 -0
  64. package/vendor/onnx/ort-wasm-simd-threaded.mjs +59 -0
  65. package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
  66. package/vendor/onnx/ort.node.min.mjs +8 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Directive-shape detection for profile candidates.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * A user-profile entry that *describes* the person is safe to maintain
7
+ * automatically. An entry that *instructs the agent* is not: it is injected into
8
+ * every session on the machine, and a hallucinated behavioural rule ("accepts
9
+ * commits without tests") changes what the agent does everywhere. Magic
10
+ * Context promoted both kinds automatically — its only gate was a pool-size
11
+ * count, and the "at least N semantically similar variants" requirement lived
12
+ * in the prompt with no code behind it. Of the 17 live profile rows two thirds
13
+ * turned out to be behavioural rules.
14
+ *
15
+ * So: directive-shaped content must be confirmed by a human before it can take
16
+ * effect. This module decides WHICH candidates have to ask.
17
+ *
18
+ * The error asymmetry is the whole design:
19
+ * - a false POSITIVE costs one question (annoying, safe);
20
+ * - a false NEGATIVE lets an unreviewed rule into every session (the failure
21
+ * this exists to prevent).
22
+ * The rule set is therefore built to over-trigger, and the regression suite
23
+ * (tests/directive-detect.spec.ts) requires ZERO misses on the labelled corpus
24
+ * while merely BOUNDING the harmless direction.
25
+ *
26
+ * A second signal keeps the noise down: an obligation is only directive-shaped
27
+ * when it targets something an agent can DO. "Not accepting a downstream
28
+ * fallback for data problems" is an attitude (descriptive); "must discuss in
29
+ * plan mode before writing any file" is an instruction.
30
+ */
31
+ /** An obligation must reach this weight before the shape is directive. */
32
+ export const DIRECTIVE_SCORE_THRESHOLD = 2;
33
+ /** Obligations strong enough to stand alone. */
34
+ const HARD_RULES = [
35
+ { rule: 'obligation:必须', weight: 2, pattern: /必须/ },
36
+ { rule: 'obligation:务必', weight: 2, pattern: /务必/ },
37
+ { rule: 'prohibition:严禁', weight: 2, pattern: /严禁/ },
38
+ { rule: 'prohibition:禁止', weight: 2, pattern: /禁止/ },
39
+ { rule: 'prohibition:不得', weight: 2, pattern: /不得/ },
40
+ { rule: 'prohibition:不允许', weight: 2, pattern: /不允许/ },
41
+ { rule: 'prohibition:不可以', weight: 2, pattern: /不可以/ },
42
+ { rule: 'obligation:一律', weight: 2, pattern: /一律/ },
43
+ { rule: 'obligation:只能', weight: 2, pattern: /只能/ },
44
+ { rule: 'gate:未确认前不', weight: 2, pattern: /未[^。;]{0,12}前不/ },
45
+ { rule: 'gate:放行门禁', weight: 2, pattern: /放行门禁/ },
46
+ { rule: 'obligation:en-must', weight: 2, pattern: /\bmust\b/i },
47
+ { rule: 'prohibition:en-never', weight: 2, pattern: /\bnever\b/i },
48
+ { rule: 'obligation:en-always', weight: 2, pattern: /\balways\b/i },
49
+ { rule: 'prohibition:en-do-not', weight: 2, pattern: /\bdo not\b/i },
50
+ { rule: 'obligation:en-required-to', weight: 2, pattern: /\brequired to\b/i },
51
+ { rule: 'gate:en-before-doing', weight: 2, pattern: /\bbefore (writing|making|committing|pushing|deploying|changing|running)/i },
52
+ ];
53
+ /**
54
+ * Requirements that carry weight only together or with an explicit gate. The
55
+ * `要求…先/再/才` shape is the most valuable one: it describes a process the
56
+ * agent must follow, which is exactly what a directive is.
57
+ */
58
+ const SOFT_RULES = [
59
+ { rule: 'requirement:要求…先/再/才/不要', weight: 2, pattern: /要求[^。;]{0,20}(先|再|才|必须|不要|不得)/ },
60
+ { rule: 'refusal:拒绝…任何/用', weight: 2, pattern: /拒绝[^。;]{0,12}(任何|用)/ },
61
+ { rule: 'refusal:不接受…兜底/妥协/凑数', weight: 2, pattern: /不接受[^。;]{0,12}(兜底|妥协|凑数)/ },
62
+ { rule: 'insistence:坚持…以/把…作为/为准', weight: 1, pattern: /坚持[^。;]{0,12}(以|把)[^。;]{0,24}(作为|为准)/ },
63
+ { rule: 'process:强制', weight: 1, pattern: /强制/ },
64
+ { rule: 'process:严格执行', weight: 1, pattern: /严格执行/ },
65
+ { rule: 'process:严格顺序', weight: 1, pattern: /严格顺序/ },
66
+ ];
67
+ /**
68
+ * Something an agent can do. An obligation that targets none of these is about
69
+ * the world (a standard, a preference, a data-quality stance), not an
70
+ * instruction to the agent.
71
+ */
72
+ const ACTION_RULES = [
73
+ { rule: 'action:写文件/代码', weight: 0, pattern: /写(文件|代码)/ },
74
+ { rule: 'action:推送', weight: 0, pattern: /推送/ },
75
+ { rule: 'action:提交', weight: 0, pattern: /提交/ },
76
+ { rule: 'action:部署', weight: 0, pattern: /部署/ },
77
+ { rule: 'action:修复', weight: 0, pattern: /修复/ },
78
+ { rule: 'action:删除', weight: 0, pattern: /删除/ },
79
+ { rule: 'action:提醒/提示', weight: 0, pattern: /提醒|提示|通知/ },
80
+ { rule: 'action:报告/汇报', weight: 0, pattern: /报告|汇报/ },
81
+ { rule: 'action:运行/执行', weight: 0, pattern: /运行|执行/ },
82
+ { rule: 'action:测试/验证', weight: 0, pattern: /测试|验证/ },
83
+ { rule: 'action:落地/推进', weight: 0, pattern: /落地|推进/ },
84
+ { rule: 'action:确认/核对', weight: 0, pattern: /确认|核对|核实/ },
85
+ { rule: 'action:评估', weight: 0, pattern: /评估/ },
86
+ { rule: 'action:清理/删除', weight: 0, pattern: /清理/ },
87
+ { rule: 'action:保持/避免', weight: 0, pattern: /保持|避免/ },
88
+ { rule: 'action:遵循/遵守', weight: 0, pattern: /遵循|遵守/ },
89
+ { rule: 'action:备份/安装/配置', weight: 0, pattern: /备份|安装|配置/ },
90
+ { rule: 'action:切换/回退', weight: 0, pattern: /切换|回退|切回/ },
91
+ { rule: 'action:en-verb', weight: 0, pattern: /\b(run|push|commit|test|write|delete|deploy|verify|confirm|wait|backup|review|ensure|check|update|refactor)\b/i },
92
+ ];
93
+ const ALL_RULES = [...HARD_RULES, ...SOFT_RULES, ...ACTION_RULES];
94
+ function kindOf(rule) {
95
+ if (HARD_RULES.includes(rule))
96
+ return 'hard';
97
+ if (SOFT_RULES.includes(rule))
98
+ return 'soft';
99
+ return 'action';
100
+ }
101
+ /** Every rule that fires, in declaration order, with the matched text. */
102
+ export function detectDirectiveHits(text) {
103
+ const hits = [];
104
+ for (const rule of ALL_RULES) {
105
+ const match = rule.pattern.exec(text);
106
+ if (match !== null) {
107
+ hits.push({ rule: rule.rule, kind: kindOf(rule), match: match[0] });
108
+ }
109
+ }
110
+ return hits;
111
+ }
112
+ /**
113
+ * Classify one profile candidate. Conservative by construction: the shape is
114
+ * `directive` when the obligation weight reaches the threshold AND at least one
115
+ * action cue is present.
116
+ */
117
+ export function classifyProfileShape(text) {
118
+ const hits = detectDirectiveHits(text);
119
+ const weightOf = (rule) => ALL_RULES.find(candidate => candidate.rule === rule)?.weight ?? 0;
120
+ const score = hits.reduce((total, hit) => total + weightOf(hit.rule), 0);
121
+ const hasAction = hits.some(hit => hit.kind === 'action');
122
+ const shape = score >= DIRECTIVE_SCORE_THRESHOLD && hasAction ? 'directive' : 'descriptive';
123
+ return {
124
+ shape,
125
+ score,
126
+ hits,
127
+ reasons: hits.map(hit => hit.rule),
128
+ };
129
+ }
130
+ export function isDirectiveShaped(text) {
131
+ return classifyProfileShape(text).shape === 'directive';
132
+ }
133
+ /**
134
+ * The question a directive-shaped candidate has to answer before it may enter
135
+ * the profile. It states the blast radius, because that is what the human is
136
+ * actually deciding about.
137
+ */
138
+ export function buildConfirmationQuestion(content, verdict) {
139
+ const why = verdict.hits.filter(hit => hit.kind !== 'action').map(hit => hit.rule).join(', ');
140
+ return [
141
+ '一条画像候选读起来像**行为指令**,而不是对使用者的描述。',
142
+ `候选内容:${content}`,
143
+ why === '' ? '' : `判定依据:${why}`,
144
+ '确认后它会在**所有会话**中生效(不分项目)。',
145
+ '请回复:确认 / 改为描述型 / 丢弃。',
146
+ ].filter(line => line !== '').join('\n');
147
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Domain-row reconciliation — "publishing is a copy, not a mirror".
3
+ *
4
+ * A human approval publishes a candidate into the shared contract store under a
5
+ * domain identity (`dir:global:org` / `dir:global:machine`), and that is where
6
+ * the lane stops: the published row is a COPY. When the source memory is later
7
+ * rewritten or archived, nothing propagates, and nothing notices. Measured on
8
+ * the live store (2026-10-16): 27 candidates, 21 linked to a domain row, 3 of
9
+ * those stale.
10
+ *
11
+ * This module is the missing audit plus the repair PLAN — never the repair's
12
+ * dangerous half:
13
+ *
14
+ * - `source-rewritten` → stage a NEW pending candidate for that domain, so a
15
+ * human can approve republishing the source's current text. Nothing is
16
+ * published here, and the stale row is deliberately left standing.
17
+ * - `source-archived` / `source-missing` → archive the domain row
18
+ * (`status='archived'`, never a DELETE). A withdrawal is as much a human
19
+ * decision as a publish, so it only ever happens behind an explicit
20
+ * `--write`.
21
+ *
22
+ * The red line this file exists to keep: **the reconciler must never publish.**
23
+ * It therefore has no seam for it at all — {@link DomainRepairPort} carries
24
+ * exactly two writes (archive one row, stage one candidate) and `applyRepair`
25
+ * can do nothing else with them. `tests/domain-reconcile.spec.ts` proves the line
26
+ * by handing `applyRepair` a publish function that throws.
27
+ *
28
+ * Everything here is pure: plain arrays in, plain data out, no database, no
29
+ * filesystem, no printing. `scripts/domain-reconcile.mjs` owns the SQL, the
30
+ * backup gate and the Chinese report; the module is what can be tested.
31
+ */
32
+ /**
33
+ * The contract's "still there" vocabulary. `permanent` is a live status (the
34
+ * promotion path can set it), so a predicate named `status !== 'active'` would
35
+ * read every permanent source as dead and propose withdrawing healthy rows.
36
+ * The corpus holds only `active`/`archived` today; the second value is here so
37
+ * the audit cannot misfire the day one appears.
38
+ */
39
+ const ALIVE_STATUSES = new Set(['active', 'permanent']);
40
+ function isAlive(status) {
41
+ return ALIVE_STATUSES.has(status);
42
+ }
43
+ const KEY_SEP = '\u0000';
44
+ /** `(domain_id, normalized_hash)` as one map key. NUL cannot occur in either. */
45
+ function linkKey(domainId, normalizedHash) {
46
+ return `${domainId}${KEY_SEP}${normalizedHash}`;
47
+ }
48
+ /**
49
+ * The one row a candidate is linked to.
50
+ *
51
+ * The contract allows the same hash twice under one domain when the categories
52
+ * differ (`UNIQUE(project_path, category, normalized_hash)`), so "the" row needs
53
+ * a rule: the one whose category matches the candidate's, else the lowest id.
54
+ * Deterministic on purpose — the audit must not depend on read order.
55
+ */
56
+ function pickDomainRow(rows, category) {
57
+ if (rows === undefined || rows.length === 0)
58
+ return undefined;
59
+ const exact = rows.filter(row => row.category === category);
60
+ const pool = exact.length > 0 ? exact : rows;
61
+ return pool.reduce((lowest, row) => (row.id < lowest.id ? row : lowest), pool[0]);
62
+ }
63
+ /**
64
+ * Is the source's CURRENT text already handled under this domain?
65
+ *
66
+ * Three honest forms of coverage: an ALIVE domain row holding that text; a
67
+ * PENDING candidate proposing it (the design's own repair — a rewrite stages a
68
+ * proposal rather than republishing); or a REJECTED candidate for exactly that
69
+ * text, which is a recorded human decision rather than an outstanding task. A
70
+ * merely `approved` candidate proves nothing: approval is a state flag, and the
71
+ * point of this audit is that the copy it produced may since have been
72
+ * withdrawn (live case #1420, archived).
73
+ */
74
+ function findCoverage(domainId, sourceNormalizedHash, rowsByKey, candidatesByKey) {
75
+ const key = linkKey(domainId, sourceNormalizedHash);
76
+ for (const row of rowsByKey.get(key) ?? []) {
77
+ if (isAlive(row.status))
78
+ return { via: 'published', coveredBy: row.id };
79
+ }
80
+ for (const candidate of candidatesByKey.get(key) ?? []) {
81
+ if (candidate.state === 'pending')
82
+ return { via: 'staged', coveredBy: candidate.id };
83
+ }
84
+ for (const candidate of candidatesByKey.get(key) ?? []) {
85
+ if (candidate.state === 'rejected')
86
+ return { via: 'declined', coveredBy: candidate.id };
87
+ }
88
+ return undefined;
89
+ }
90
+ function makeDivergence(kind, candidate, row, sourceMemoryId, source) {
91
+ return {
92
+ kind,
93
+ candidateId: candidate.id,
94
+ domainId: candidate.domainId,
95
+ domainRowId: row.id,
96
+ sourceMemoryId,
97
+ publishedNormalizedHash: candidate.normalizedHash,
98
+ sourceNormalizedHash: source?.normalizedHash ?? null,
99
+ sourceCategory: source?.category ?? null,
100
+ sourceIdentity: source?.projectPath ?? null,
101
+ sourceContent: source?.content ?? null,
102
+ };
103
+ }
104
+ /**
105
+ * Classify one candidate. Exhaustive by construction: `checked` always equals
106
+ * `healthy + unlinked + divergences.length` (the spec asserts it).
107
+ */
108
+ function classifyCandidate(candidate, rowsByKey, sourcesById, candidatesByKey) {
109
+ const row = pickDomainRow(rowsByKey.get(linkKey(candidate.domainId, candidate.normalizedHash)), candidate.category);
110
+ if (row === undefined)
111
+ return { outcome: 'unlinked' };
112
+ // A domain row that is already withdrawn cannot disagree with anything: the
113
+ // reviewer is looking at a fact nobody can see any more (semantics 3). It
114
+ // still counts as a healthy LINK — the number is reported separately so it is
115
+ // never silently padded.
116
+ if (!isAlive(row.status)) {
117
+ return { outcome: 'skipped-archived-row', domainRowId: row.id };
118
+ }
119
+ // Only an approved candidate is the authority for a published row; a rejected
120
+ // or still-pending one has never published anything, so it cannot be stale.
121
+ if (candidate.state !== 'approved')
122
+ return { outcome: 'healthy' };
123
+ const sourceMemoryId = candidate.sourceMemoryId;
124
+ // Machine-detected candidate: the link itself is the whole comparison, and
125
+ // there is no source that could have moved on.
126
+ if (sourceMemoryId === null || sourceMemoryId === undefined)
127
+ return { outcome: 'healthy' };
128
+ const source = sourcesById.get(sourceMemoryId);
129
+ if (source === undefined) {
130
+ return { outcome: 'divergence', divergence: makeDivergence('source-missing', candidate, row, sourceMemoryId, undefined) };
131
+ }
132
+ if (!isAlive(source.status)) {
133
+ return { outcome: 'divergence', divergence: makeDivergence('source-archived', candidate, row, sourceMemoryId, source) };
134
+ }
135
+ if (source.normalizedHash !== candidate.normalizedHash) {
136
+ // The source moved on. Before calling that a divergence, ask whether its
137
+ // CURRENT text is already handled under this domain — otherwise a repaired
138
+ // domain could never settle (the stale pair keeps its old hash forever).
139
+ const coverage = findCoverage(candidate.domainId, source.normalizedHash, rowsByKey, candidatesByKey);
140
+ if (coverage !== undefined) {
141
+ return {
142
+ outcome: 'covered',
143
+ covered: {
144
+ candidateId: candidate.id,
145
+ domainRowId: row.id,
146
+ sourceMemoryId,
147
+ via: coverage.via,
148
+ coveredBy: coverage.coveredBy,
149
+ },
150
+ };
151
+ }
152
+ return { outcome: 'divergence', divergence: makeDivergence('source-rewritten', candidate, row, sourceMemoryId, source) };
153
+ }
154
+ return { outcome: 'healthy' };
155
+ }
156
+ /**
157
+ * The read-only half: what disagrees with what.
158
+ *
159
+ * `checked` counts every candidate given, which is the whole queue — rejected
160
+ * and pending candidates land in `unlinked` because they were never published.
161
+ * That is not a fault (the live store's 6 unlinked candidates are exactly 5
162
+ * rejected + 1 pending), so every caller must print `unlinked` as a note, never
163
+ * as an alarm.
164
+ */
165
+ export function auditDomainDivergences(input) {
166
+ const rowsByKey = new Map();
167
+ for (const row of input.domainRows) {
168
+ const key = linkKey(row.domainId, row.normalizedHash);
169
+ const bucket = rowsByKey.get(key);
170
+ if (bucket === undefined)
171
+ rowsByKey.set(key, [row]);
172
+ else
173
+ bucket.push(row);
174
+ }
175
+ const sourcesById = new Map();
176
+ for (const memory of input.sourceMemories)
177
+ sourcesById.set(memory.id, memory);
178
+ // Every candidate, by key: coverage can come from a DIFFERENT candidate than
179
+ // the one being classified (the proposal staged for this very rewrite).
180
+ const candidatesByKey = new Map();
181
+ for (const candidate of input.candidates) {
182
+ const key = linkKey(candidate.domainId, candidate.normalizedHash);
183
+ const bucket = candidatesByKey.get(key);
184
+ if (bucket === undefined)
185
+ candidatesByKey.set(key, [candidate]);
186
+ else
187
+ bucket.push(candidate);
188
+ }
189
+ let checked = 0;
190
+ let healthy = 0;
191
+ let unlinked = 0;
192
+ const unlinkedCandidateIds = [];
193
+ const skippedArchivedDomainRows = [];
194
+ const covered = [];
195
+ const divergences = [];
196
+ for (const candidate of input.candidates) {
197
+ checked += 1;
198
+ const verdict = classifyCandidate(candidate, rowsByKey, sourcesById, candidatesByKey);
199
+ if (verdict.outcome === 'unlinked') {
200
+ unlinked += 1;
201
+ unlinkedCandidateIds.push(candidate.id);
202
+ }
203
+ else if (verdict.outcome === 'healthy') {
204
+ healthy += 1;
205
+ }
206
+ else if (verdict.outcome === 'skipped-archived-row') {
207
+ healthy += 1;
208
+ skippedArchivedDomainRows.push({ candidateId: candidate.id, domainRowId: verdict.domainRowId });
209
+ }
210
+ else if (verdict.outcome === 'covered') {
211
+ // Nothing to repair, so nothing keeps it out of `healthy`; listed anyway.
212
+ healthy += 1;
213
+ covered.push(verdict.covered);
214
+ }
215
+ else {
216
+ divergences.push(verdict.divergence);
217
+ }
218
+ }
219
+ return {
220
+ checked,
221
+ healthy,
222
+ unlinked,
223
+ unlinkedCandidateIds,
224
+ skippedArchivedDomainRows,
225
+ covered: covered.slice().sort((left, right) => left.candidateId - right.candidateId),
226
+ divergences: divergences.slice().sort((left, right) => left.candidateId - right.candidateId),
227
+ };
228
+ }
229
+ /** `dir:global:machine` → 'machine'. Anything else → null (never guessed). */
230
+ function suggestedDomainFor(domainId) {
231
+ if (domainId.endsWith(':machine'))
232
+ return 'machine';
233
+ if (domainId.endsWith(':org'))
234
+ return 'org';
235
+ return null;
236
+ }
237
+ /** The one-line "why" the review card shows for a staged proposal. */
238
+ export function reconcileReason(divergence) {
239
+ return `domain-reconcile: the published copy under ${divergence.domainId} is stale — source memory `
240
+ + `#${String(divergence.sourceMemoryId)} was rewritten after domain row #${String(divergence.domainRowId)} `
241
+ + `was published from hash ${divergence.publishedNormalizedHash}; approving republishes the current text `
242
+ + '(the stale row is left in place for a human to withdraw).';
243
+ }
244
+ /**
245
+ * Turn a report into the exact writes a repair would perform. Pure, and still
246
+ * not applied: the CLI decides, and only `--write` gets there.
247
+ *
248
+ * Two rules are load-bearing:
249
+ *
250
+ * 1. **A rewrite never archives.** The stale row is a fact somebody approved;
251
+ * the proposal adds a new one and leaves the old decision visible.
252
+ * 2. **One proposal per unique key.** The adapter's unique index is
253
+ * `(domain_id, normalized_hash)`, so a second idempotent write of the same
254
+ * proposal would upsert the first one's row — and that upsert rewrites
255
+ * `content` even for an APPROVED candidate. Collapsing here (and again at
256
+ * the write seam) is what makes a repeated `--write` a no-op.
257
+ */
258
+ export function planRepair(report) {
259
+ const archiveRowIds = [];
260
+ const archiveSeen = new Set();
261
+ const stageCandidates = [];
262
+ const stageIndex = new Map();
263
+ const unrepairable = [];
264
+ const archive = (rowId) => {
265
+ if (archiveSeen.has(rowId))
266
+ return;
267
+ archiveSeen.add(rowId);
268
+ archiveRowIds.push(rowId);
269
+ };
270
+ for (const divergence of report.divergences) {
271
+ if (divergence.kind !== 'source-rewritten') {
272
+ archive(divergence.domainRowId);
273
+ continue;
274
+ }
275
+ const { sourceContent, sourceNormalizedHash, sourceCategory, sourceIdentity } = divergence;
276
+ if (sourceContent === null || sourceNormalizedHash === null || sourceCategory === null || sourceIdentity === null) {
277
+ unrepairable.push({
278
+ candidateId: divergence.candidateId,
279
+ domainId: divergence.domainId,
280
+ kind: divergence.kind,
281
+ reason: 'the audit classified this as a rewrite but carried no source text — refusing to guess a proposal',
282
+ });
283
+ continue;
284
+ }
285
+ const key = linkKey(divergence.domainId, sourceNormalizedHash);
286
+ const existing = stageIndex.get(key);
287
+ const derived = { candidateId: divergence.candidateId, domainRowId: divergence.domainRowId, sourceMemoryId: divergence.sourceMemoryId };
288
+ if (existing !== undefined) {
289
+ const kept = stageCandidates[existing];
290
+ stageCandidates[existing] = { ...kept, derivedFrom: [...kept.derivedFrom, derived] };
291
+ continue;
292
+ }
293
+ stageIndex.set(key, stageCandidates.length);
294
+ stageCandidates.push({
295
+ domainId: divergence.domainId,
296
+ content: sourceContent,
297
+ normalizedHash: sourceNormalizedHash,
298
+ category: sourceCategory,
299
+ sourceMemoryId: divergence.sourceMemoryId,
300
+ sourceIdentity,
301
+ suggestedDomain: suggestedDomainFor(divergence.domainId),
302
+ reason: reconcileReason(divergence),
303
+ derivedFrom: [derived],
304
+ });
305
+ }
306
+ archiveRowIds.sort((left, right) => left - right);
307
+ return { archiveRowIds, stageCandidates, unrepairable };
308
+ }
309
+ /**
310
+ * Apply a plan through the injected port. This is the only function that writes,
311
+ * and the only two things it can write are the two the port exposes.
312
+ *
313
+ * Withdrawals run first, then proposals: a refusal while staging must not leave
314
+ * a half-applied plan looking complete, and the two sets are disjoint by
315
+ * construction (one divergence is either a rewrite or a dead source, never
316
+ * both). Every action is reported individually — a partial run prints as a
317
+ * partial run.
318
+ */
319
+ export function applyRepair(plan, port, options = {}) {
320
+ const at = options.now ?? Date.now();
321
+ const journal = options.journal;
322
+ const archived = [];
323
+ const archiveRefusals = [];
324
+ const staged = [];
325
+ const stageRefusals = [];
326
+ for (const rowId of plan.archiveRowIds) {
327
+ const result = port.archiveDomainRow(rowId);
328
+ if (result.ok) {
329
+ if (result.changed)
330
+ archived.push(rowId);
331
+ else
332
+ archiveRefusals.push({ rowId, error: 'already archived (idempotent re-run)' });
333
+ journal?.({
334
+ at,
335
+ action: result.changed ? 'archive' : 'refuse',
336
+ summary: result.changed
337
+ ? `archived domain row #${String(rowId)} (its source memory is gone)`
338
+ : `domain row #${String(rowId)} was already archived`,
339
+ data: { rowId, changed: result.changed },
340
+ });
341
+ }
342
+ else {
343
+ archiveRefusals.push({ rowId, error: result.error });
344
+ journal?.({ at, action: 'refuse', summary: `archive refused for domain row #${String(rowId)}`, data: { rowId, error: result.error } });
345
+ }
346
+ }
347
+ for (const proposal of plan.stageCandidates) {
348
+ const result = port.stageCandidate(proposal);
349
+ if (result.ok) {
350
+ const reproposed = result.reproposed === true;
351
+ staged.push({
352
+ domainId: proposal.domainId,
353
+ normalizedHash: proposal.normalizedHash,
354
+ sourceMemoryId: proposal.sourceMemoryId,
355
+ candidateId: result.candidateId,
356
+ created: result.created,
357
+ reproposed,
358
+ });
359
+ journal?.({
360
+ at,
361
+ action: 'stage',
362
+ summary: result.created
363
+ ? `staged candidate #${String(result.candidateId)} for ${proposal.domainId} (awaiting human approval)`
364
+ : reproposed
365
+ ? `re-proposed dead candidate #${String(result.candidateId)} for ${proposal.domainId} (its published copy is archived; awaiting human approval again)`
366
+ : `candidate #${String(result.candidateId)} already staged for ${proposal.domainId}`,
367
+ data: {
368
+ candidateId: result.candidateId,
369
+ domainId: proposal.domainId,
370
+ normalizedHash: proposal.normalizedHash,
371
+ sourceMemoryId: proposal.sourceMemoryId,
372
+ derivedFrom: proposal.derivedFrom,
373
+ created: result.created,
374
+ reproposed,
375
+ },
376
+ });
377
+ }
378
+ else {
379
+ stageRefusals.push({
380
+ domainId: proposal.domainId,
381
+ normalizedHash: proposal.normalizedHash,
382
+ sourceMemoryId: proposal.sourceMemoryId,
383
+ error: result.error,
384
+ });
385
+ journal?.({
386
+ at,
387
+ action: 'refuse',
388
+ summary: `staging refused for ${proposal.domainId}`,
389
+ data: { domainId: proposal.domainId, normalizedHash: proposal.normalizedHash, error: result.error },
390
+ });
391
+ }
392
+ }
393
+ return { archived, archiveRefusals, staged, stageRefusals, unrepairable: plan.unrepairable };
394
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The adapter's global domains: `user` (the person), `org` (organisation
3
+ * knowledge), `machine` (this machine's environment).
4
+ *
5
+ * Display naming (2026-10-25, docs/domain-naming-work-global-2026-10-25.md):
6
+ * the axis is AUDIENCE, not distribution — `machine` surfaces as 全局/global
7
+ * (every project on this machine), `org` as 工作/work (a hand-curated member
8
+ * set of work projects). The internal tokens below are storage keys and never
9
+ * rename.
10
+ *
11
+ * Why domains exist at all
12
+ * ------------------------
13
+ * A *global* memory is one that is not owned by any single project. Magic
14
+ * Context ships exactly one such lane — the user profile — because a profile
15
+ * "applies everywhere" and therefore needs no activation rule. Organisation
16
+ * knowledge does not have that property: the company database pointer must be
17
+ * invisible in an open-source project's session. So the domain axis answers
18
+ * exactly one question — **under what circumstances should this be visible?** —
19
+ * and deliberately does not answer "what is it about" (that is `category`).
20
+ *
21
+ * One domain = one activation rule + one budget + one cap derived from it.
22
+ * The kind set is closed at three on purpose: a new kind has to earn its place
23
+ * with an independent activation rule, audience and budget.
24
+ *
25
+ * See docs/dsh-plugin-platform-takeover-and-global-domains-2026-10-09.md.
26
+ */
27
+ import { V2_MEMORY_CATEGORIES } from "./memory-categories.js";
28
+ /** The closed set of global domains. */
29
+ export const DOMAIN_KINDS = ['user', 'org', 'machine'];
30
+ export function isDomainKind(value) {
31
+ return typeof value === 'string' && DOMAIN_KINDS.includes(value);
32
+ }
33
+ /**
34
+ * Per-domain *render* budget in tokens. These are the numbers the composed
35
+ * baseline must respect; they are not the same as the cap's source (below)
36
+ * because `user` renders across two blocks (m[0] profile + m[1] delta) while
37
+ * remaining a curated, small set.
38
+ */
39
+ export const DEFAULT_DOMAIN_BUDGET_TOKENS = {
40
+ user: 5000,
41
+ org: 768,
42
+ machine: 512,
43
+ };
44
+ /**
45
+ * The curation target a domain's cap is derived from. It differs from the
46
+ * render budget for `user` on purpose: the profile renders under a 4000-token
47
+ * m[0] budget, but an unbounded set of behavioural notes is not a curated
48
+ * profile — the cap has to be a product decision expressed as a token target.
49
+ */
50
+ export const DEFAULT_DOMAIN_CAP_BUDGET_TOKENS = {
51
+ user: 750,
52
+ org: 768,
53
+ machine: 500,
54
+ };
55
+ /** Estimated tokens for one rendered memory line (content + list overhead). */
56
+ export const DOMAIN_TOKENS_PER_LINE = 20;
57
+ /** Headroom kept below the cap budget, so a domain never sits exactly at its ceiling. */
58
+ export const DOMAIN_CAP_SAFETY = 0.8;
59
+ /**
60
+ * A domain's cap is DERIVED, never picked: it is the number of lines the cap
61
+ * budget can hold, with headroom. Non-finite and non-positive budgets collapse
62
+ * to the floor of one line rather than to zero (a domain that can hold nothing
63
+ * would silently reject every candidate).
64
+ */
65
+ export function deriveDomainCap(capBudgetTokens) {
66
+ if (!Number.isFinite(capBudgetTokens) || capBudgetTokens <= 0)
67
+ return 1;
68
+ return Math.max(1, Math.floor((capBudgetTokens / DOMAIN_TOKENS_PER_LINE) * DOMAIN_CAP_SAFETY));
69
+ }
70
+ export function defaultDomainCap(kind) {
71
+ return deriveDomainCap(DEFAULT_DOMAIN_CAP_BUDGET_TOKENS[kind]);
72
+ }
73
+ /** The provider identity that owns a domain's rows in the shared store. */
74
+ export function domainIdentity(kind) {
75
+ return `dir:global:${kind}`;
76
+ }
77
+ /** What a missing allowlist means (the contract's own column default). */
78
+ export const DEFAULT_SHARE_CATEGORIES = ['CONSTRAINTS'];
79
+ /**
80
+ * Parse a share allowlist with the contract's fail-closed convention
81
+ * (upstream `normalizeShareCategories`, workspaces.ts:69-97):
82
+ *
83
+ * missing/null -> the default allowlist
84
+ * malformed, not an array, or ANY unknown category -> `[]`, share nothing
85
+ *
86
+ * The asymmetry is deliberate and load-bearing: a corrupt allowlist must not
87
+ * silently widen what becomes visible across projects.
88
+ */
89
+ export function parseShareCategories(raw) {
90
+ if (raw === null || raw === undefined)
91
+ return [...DEFAULT_SHARE_CATEGORIES];
92
+ if (typeof raw !== 'string')
93
+ return [];
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(raw);
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ if (!Array.isArray(parsed))
102
+ return [];
103
+ const valid = new Set(V2_MEMORY_CATEGORIES);
104
+ const categories = [];
105
+ for (const item of parsed) {
106
+ if (typeof item !== 'string' || !valid.has(item))
107
+ return [];
108
+ if (!categories.includes(item))
109
+ categories.push(item);
110
+ }
111
+ return categories.sort((left, right) => left.localeCompare(right));
112
+ }
113
+ /**
114
+ * Serialize an allowlist for storage, rejecting unknown categories. This is
115
+ * also the publish-time pre-check: a category outside the allowlist would be
116
+ * written and then be invisible forever, which is a silent failure — so it is
117
+ * refused here instead.
118
+ */
119
+ export function serializeShareCategories(categories) {
120
+ const valid = new Set(V2_MEMORY_CATEGORIES);
121
+ for (const category of categories) {
122
+ if (!valid.has(category)) {
123
+ throw new Error(`unknown share category '${category}' (allowed: ${V2_MEMORY_CATEGORIES.join(', ')})`);
124
+ }
125
+ }
126
+ return JSON.stringify([...new Set(categories)].sort((left, right) => left.localeCompare(right)));
127
+ }