@dzhechkov/harness-core 0.8.11 → 0.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/.dz-manifest.json +399 -139
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/course-staleness.d.ts +19 -0
  46. package/dist/course-staleness.d.ts.map +1 -0
  47. package/dist/course-staleness.js +95 -0
  48. package/dist/course-staleness.js.map +1 -0
  49. package/dist/destructive-guard-hook.d.ts +40 -0
  50. package/dist/destructive-guard-hook.d.ts.map +1 -0
  51. package/dist/destructive-guard-hook.js +109 -0
  52. package/dist/destructive-guard-hook.js.map +1 -0
  53. package/dist/destructive-guard.d.ts +27 -0
  54. package/dist/destructive-guard.d.ts.map +1 -0
  55. package/dist/destructive-guard.js +2808 -0
  56. package/dist/destructive-guard.js.map +1 -0
  57. package/dist/discrimination-gate.d.ts +28 -3
  58. package/dist/discrimination-gate.d.ts.map +1 -1
  59. package/dist/discrimination-gate.js +76 -16
  60. package/dist/discrimination-gate.js.map +1 -1
  61. package/dist/feature-adr-routing.d.ts +95 -1
  62. package/dist/feature-adr-routing.d.ts.map +1 -1
  63. package/dist/feature-adr-routing.js +193 -27
  64. package/dist/feature-adr-routing.js.map +1 -1
  65. package/dist/guard.d.ts +90 -0
  66. package/dist/guard.d.ts.map +1 -1
  67. package/dist/guard.js +271 -0
  68. package/dist/guard.js.map +1 -1
  69. package/dist/harness-core-location.d.ts +18 -0
  70. package/dist/harness-core-location.d.ts.map +1 -0
  71. package/dist/harness-core-location.js +42 -0
  72. package/dist/harness-core-location.js.map +1 -0
  73. package/dist/index.d.ts +24 -6
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +24 -3
  76. package/dist/index.js.map +1 -1
  77. package/dist/lead-shift-gate.d.ts +95 -0
  78. package/dist/lead-shift-gate.d.ts.map +1 -0
  79. package/dist/lead-shift-gate.js +100 -0
  80. package/dist/lead-shift-gate.js.map +1 -0
  81. package/dist/ledger-backfill.d.ts +11 -1
  82. package/dist/ledger-backfill.d.ts.map +1 -1
  83. package/dist/ledger-backfill.js +19 -0
  84. package/dist/ledger-backfill.js.map +1 -1
  85. package/dist/loop-blobs.generated.d.ts +1 -1
  86. package/dist/loop-blobs.generated.d.ts.map +1 -1
  87. package/dist/loop-blobs.generated.js +12 -3
  88. package/dist/loop-blobs.generated.js.map +1 -1
  89. package/dist/loop-lint.d.ts.map +1 -1
  90. package/dist/loop-lint.js +56 -7
  91. package/dist/loop-lint.js.map +1 -1
  92. package/dist/loop-plan-graph.d.ts +1 -3
  93. package/dist/loop-plan-graph.d.ts.map +1 -1
  94. package/dist/loop-plan-graph.js +70 -1
  95. package/dist/loop-plan-graph.js.map +1 -1
  96. package/dist/loop-trace.d.ts.map +1 -1
  97. package/dist/loop-trace.js +16 -2
  98. package/dist/loop-trace.js.map +1 -1
  99. package/dist/managed-hooks.d.ts +5 -6
  100. package/dist/managed-hooks.d.ts.map +1 -1
  101. package/dist/managed-hooks.js +2 -2
  102. package/dist/managed-hooks.js.map +1 -1
  103. package/dist/model-recommender.d.ts.map +1 -1
  104. package/dist/model-recommender.js +14 -3
  105. package/dist/model-recommender.js.map +1 -1
  106. package/dist/operations.d.ts.map +1 -1
  107. package/dist/operations.js +82 -0
  108. package/dist/operations.js.map +1 -1
  109. package/dist/patterns.d.ts +23 -0
  110. package/dist/patterns.d.ts.map +1 -1
  111. package/dist/patterns.js +10 -0
  112. package/dist/patterns.js.map +1 -1
  113. package/dist/publish.d.ts +11 -0
  114. package/dist/publish.d.ts.map +1 -1
  115. package/dist/publish.js +16 -2
  116. package/dist/publish.js.map +1 -1
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +3 -2
  119. package/dist/registry.js.map +1 -1
  120. package/dist/score.d.ts.map +1 -1
  121. package/dist/score.js +15 -4
  122. package/dist/score.js.map +1 -1
  123. package/dist/session-retro.d.ts +121 -2
  124. package/dist/session-retro.d.ts.map +1 -1
  125. package/dist/session-retro.js +454 -11
  126. package/dist/session-retro.js.map +1 -1
  127. package/dist/setup.d.ts +28 -0
  128. package/dist/setup.d.ts.map +1 -1
  129. package/dist/setup.js +223 -6
  130. package/dist/setup.js.map +1 -1
  131. package/dist/sign.d.ts.map +1 -1
  132. package/dist/sign.js +18 -1
  133. package/dist/sign.js.map +1 -1
  134. package/dist/skills-verify.d.ts +34 -1
  135. package/dist/skills-verify.d.ts.map +1 -1
  136. package/dist/skills-verify.js +82 -0
  137. package/dist/skills-verify.js.map +1 -1
  138. package/dist/stage-line.d.ts +68 -0
  139. package/dist/stage-line.d.ts.map +1 -0
  140. package/dist/stage-line.js +129 -0
  141. package/dist/stage-line.js.map +1 -0
  142. package/dist/statusline.d.ts +99 -0
  143. package/dist/statusline.d.ts.map +1 -1
  144. package/dist/statusline.js +310 -44
  145. package/dist/statusline.js.map +1 -1
  146. package/dist/store-counts.d.ts +26 -0
  147. package/dist/store-counts.d.ts.map +1 -0
  148. package/dist/store-counts.js +125 -0
  149. package/dist/store-counts.js.map +1 -0
  150. package/dist/store-guard.d.ts +106 -0
  151. package/dist/store-guard.d.ts.map +1 -0
  152. package/dist/store-guard.js +294 -0
  153. package/dist/store-guard.js.map +1 -0
  154. package/dist/swarm-brief.d.ts +95 -0
  155. package/dist/swarm-brief.d.ts.map +1 -0
  156. package/dist/swarm-brief.js +660 -0
  157. package/dist/swarm-brief.js.map +1 -0
  158. package/dist/trace-bundle.d.ts +8 -0
  159. package/dist/trace-bundle.d.ts.map +1 -1
  160. package/dist/trace-bundle.js +11 -0
  161. package/dist/trace-bundle.js.map +1 -1
  162. package/package.json +12 -11
  163. package/sbom.json +804 -154
  164. package/src/agents-policy.ts +46 -2
  165. package/src/amendment-trace.ts +441 -19
  166. package/src/backlog-public.ts +503 -0
  167. package/src/backlog-transitions.ts +77 -0
  168. package/src/backlog.ts +12 -0
  169. package/src/claim-check.ts +25 -2
  170. package/src/claude-hooks-assets.ts +227 -0
  171. package/src/clean-room-smoke.ts +195 -0
  172. package/src/cmd-usage.ts +29 -5
  173. package/src/codex-hooks-assets.ts +140 -30
  174. package/src/codex-hooks.ts +21 -1
  175. package/src/course-staleness.ts +125 -0
  176. package/src/destructive-guard-hook.ts +151 -0
  177. package/src/destructive-guard.ts +3027 -0
  178. package/src/discrimination-gate.ts +98 -19
  179. package/src/feature-adr-routing.ts +220 -22
  180. package/src/guard.ts +318 -0
  181. package/src/harness-core-location.ts +44 -0
  182. package/src/index.ts +111 -3
  183. package/src/lead-shift-gate.ts +145 -0
  184. package/src/ledger-backfill.ts +20 -1
  185. package/src/loop-blobs.generated.ts +12 -3
  186. package/src/loop-lint.ts +52 -7
  187. package/src/loop-plan-graph.ts +66 -1
  188. package/src/loop-trace.ts +13 -1
  189. package/src/managed-hooks.ts +5 -6
  190. package/src/model-recommender.ts +14 -3
  191. package/src/operations.ts +75 -0
  192. package/src/patterns.ts +33 -0
  193. package/src/publish.ts +27 -2
  194. package/src/registry.ts +3 -2
  195. package/src/score.ts +16 -4
  196. package/src/session-retro.ts +466 -11
  197. package/src/setup.ts +246 -9
  198. package/src/sign.ts +18 -1
  199. package/src/skills-verify.ts +99 -1
  200. package/src/stage-line.ts +151 -0
  201. package/src/statusline.ts +396 -47
  202. package/src/store-counts.ts +154 -0
  203. package/src/store-guard.ts +388 -0
  204. package/src/swarm-brief.ts +661 -0
  205. package/src/trace-bundle.ts +10 -0
@@ -0,0 +1,154 @@
1
+ /** Fast, readonly row counts for the two learning-store tiers. */
2
+ import { createRequire } from 'node:module';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+
6
+ interface ReadonlyCountDb {
7
+ pragma: (s: string) => void;
8
+ prepare: (q: string) => { get: (...a: unknown[]) => unknown };
9
+ close: () => void;
10
+ }
11
+
12
+ export interface LearningStoreRowCounts {
13
+ readonly lexicalRows: number | 'unreadable';
14
+ /** Exact active quarantine labels in the selected lexical SQLite tier; absent for fallback/error paths. */
15
+ readonly lexicalQuarantinedRows?: number;
16
+ /** Physical lexical population counted; jsonl and SQLite maxima are not comparable. */
17
+ readonly lexicalSource: 'jsonl' | 'sqlite';
18
+ /** Exact selected physical source. SQLite wins when both stores coexist, matching runtime reads. */
19
+ readonly lexicalSourcePath: string;
20
+ /** Coexisting JSONL is deliberately excluded from the selected SQLite population. */
21
+ readonly lexicalIgnoredRows?: number | 'unreadable';
22
+ readonly lexicalIgnoredSourcePath?: string;
23
+ readonly vectorRows: number | 'unreadable';
24
+ /** Exact active quarantine labels on mirrored lesson rows; absent when the vector tier is missing/unreadable. */
25
+ readonly vectorQuarantinedRows?: number;
26
+ /** Present when the vector store file exists, including when its count is unreadable. */
27
+ readonly vectorSourcePath?: string;
28
+ }
29
+
30
+ function countJsonlRowsReadonly(path: string): number | 'unreadable' {
31
+ if (!existsSync(path)) return 0;
32
+ try {
33
+ return readFileSync(path, 'utf-8').split('\n').filter((line) => line.trim() !== '').length;
34
+ } catch {
35
+ return 'unreadable';
36
+ }
37
+ }
38
+
39
+ /** Best-effort readonly SQLite count with a short busy timeout. */
40
+ export function countSqliteRowsReadonly(
41
+ sqlitePath: string,
42
+ table: 'memory_records' | 'reasoning_patterns',
43
+ ): number | undefined {
44
+ try {
45
+ const require = createRequire(import.meta.url);
46
+ const Database = require('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
47
+ const db = new Database(sqlitePath, { readonly: true });
48
+ try {
49
+ db.pragma('busy_timeout = 100');
50
+ const row = db.prepare(`SELECT COUNT(*) as cnt FROM ${table}`).get() as { cnt?: unknown };
51
+ return typeof row?.cnt === 'number' ? row.cnt : undefined;
52
+ } finally {
53
+ db.close();
54
+ }
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ interface SqliteRowsWithQuarantine {
61
+ readonly rows: number;
62
+ readonly quarantinedRows?: number;
63
+ }
64
+
65
+ /** One aggregate query on the healthy path; an unsupported metadata shape falls back to total-only. */
66
+ function countSqliteRowsWithQuarantineReadonly(
67
+ sqlitePath: string,
68
+ table: 'memory_records' | 'reasoning_patterns',
69
+ ): SqliteRowsWithQuarantine | undefined {
70
+ try {
71
+ const require = createRequire(import.meta.url);
72
+ const Database = require('better-sqlite3') as new (p: string, o?: object) => ReadonlyCountDb;
73
+ const db = new Database(sqlitePath, { readonly: true });
74
+ try {
75
+ db.pragma('busy_timeout = 100');
76
+ try {
77
+ const sql = table === 'memory_records'
78
+ ? `SELECT COALESCE(SUM(n), 0) AS cnt,
79
+ COALESCE(SUM(CASE WHEN q_status = 'quarantined' THEN n ELSE 0 END), 0) AS quarantined
80
+ FROM (
81
+ SELECT CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.qStatus') END AS q_status,
82
+ COUNT(*) AS n
83
+ FROM memory_records GROUP BY 1
84
+ )`
85
+ : `WITH lesson_statuses AS (
86
+ SELECT CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.qStatus') END AS q_status,
87
+ COUNT(*) AS n
88
+ FROM reasoning_patterns
89
+ WHERE task_type IN ('dz-teach', 'dz-learning')
90
+ GROUP BY 1
91
+ )
92
+ SELECT (SELECT COUNT(*) FROM reasoning_patterns) AS cnt,
93
+ COALESCE(SUM(CASE WHEN q_status = 'quarantined' THEN n ELSE 0 END), 0) AS quarantined
94
+ FROM lesson_statuses`;
95
+ const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown };
96
+ if (typeof row?.cnt !== 'number' || typeof row.quarantined !== 'number') return undefined;
97
+ return { rows: row.cnt, quarantinedRows: row.quarantined };
98
+ } catch {
99
+ const row = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get() as { cnt?: unknown };
100
+ return typeof row?.cnt === 'number' ? { rows: row.cnt } : undefined;
101
+ }
102
+ } finally {
103
+ db.close();
104
+ }
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Count both protected store populations without opening either store writable.
112
+ * Missing tiers count as zero; an existing tier that cannot be counted is
113
+ * explicitly `unreadable`.
114
+ */
115
+ export function countLearningStoreRowsReadonly(projectRoot: string): LearningStoreRowCounts {
116
+ const root = resolve(projectRoot);
117
+ const lexicalPath = join(root, '.dz', 'memory', 'patterns.sqlite');
118
+ const jsonlPath = join(root, '.dz', 'patterns.jsonl');
119
+ const vectorPath = join(root, '.dz', 'agentdb.db');
120
+ const vectorExists = existsSync(vectorPath);
121
+ const lexicalSource = existsSync(lexicalPath) ? 'sqlite' : 'jsonl';
122
+ const lexicalSqlite = lexicalSource === 'sqlite'
123
+ ? countSqliteRowsWithQuarantineReadonly(lexicalPath, 'memory_records')
124
+ : undefined;
125
+ let lexicalRows: number | 'unreadable';
126
+ if (lexicalSource === 'sqlite') {
127
+ lexicalRows = lexicalSqlite?.rows ?? 'unreadable';
128
+ } else {
129
+ lexicalRows = countJsonlRowsReadonly(jsonlPath);
130
+ }
131
+ const vectorSqlite = vectorExists
132
+ ? countSqliteRowsWithQuarantineReadonly(vectorPath, 'reasoning_patterns')
133
+ : undefined;
134
+ const ignoredJsonl = lexicalSource === 'sqlite' && existsSync(jsonlPath)
135
+ ? countJsonlRowsReadonly(jsonlPath)
136
+ : undefined;
137
+ return {
138
+ lexicalRows,
139
+ ...(lexicalSqlite?.quarantinedRows === undefined ? {} : {
140
+ lexicalQuarantinedRows: lexicalSqlite.quarantinedRows,
141
+ }),
142
+ lexicalSource,
143
+ lexicalSourcePath: lexicalSource === 'sqlite' ? lexicalPath : jsonlPath,
144
+ ...(ignoredJsonl === undefined ? {} : {
145
+ lexicalIgnoredRows: ignoredJsonl,
146
+ lexicalIgnoredSourcePath: jsonlPath,
147
+ }),
148
+ vectorRows: vectorExists ? (vectorSqlite?.rows ?? 'unreadable') : 0,
149
+ ...(vectorSqlite?.quarantinedRows === undefined ? {} : {
150
+ vectorQuarantinedRows: vectorSqlite.quarantinedRows,
151
+ }),
152
+ ...(vectorExists ? { vectorSourcePath: vectorPath } : {}),
153
+ };
154
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Durable high-water mark for the two dz learning stores.
3
+ *
4
+ * The mark deliberately lives under the user's home directory, outside the
5
+ * project: deleting `<project>/.dz` must not delete the evidence that a store
6
+ * existed there.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import { createHash } from 'node:crypto';
12
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { basename, dirname, join, resolve } from 'node:path';
15
+
16
+ import { withNamedLockSync } from './named-lock.js';
17
+
18
+ export const STORE_GUARD_VERSION = 1 as const;
19
+
20
+ /**
21
+ * A fall of more than 10% from the lifetime maximum is anomalous. A second,
22
+ * absolute threshold below catches a sharp recent loss before it reaches 10%.
23
+ * Basis: across 55 days and 9 measured snapshots the counter never decreased
24
+ * (190 -> 1385), measured 2026-09-06.
25
+ */
26
+ export const STORE_COLLAPSE_MAX_FRACTION = 0.10;
27
+
28
+ /** See {@link STORE_COLLAPSE_MAX_FRACTION} for the measured basis. */
29
+ export const STORE_COLLAPSE_LAST_ROWS = 50;
30
+
31
+ /** Fractional collapse detection is too noisy below this observed population. */
32
+ export const STORE_COLLAPSE_MIN_ROWS = 10;
33
+
34
+ export interface AcceptedStoreShrink {
35
+ readonly at: string;
36
+ readonly reason: string;
37
+ /** Required on every receipt written by the source-aware guard; absent only on legacy marks. */
38
+ readonly command?: string;
39
+ readonly kind?: 'measured-deletion' | 'source-transition' | 'operator-reset' | 'cold-start-override';
40
+ readonly before?: StoreCountSnapshot;
41
+ readonly after?: StoreCountSnapshot;
42
+ }
43
+
44
+ export interface StoreResetReceipt {
45
+ readonly at: string;
46
+ readonly command: string;
47
+ readonly decision: 'manual operator decision';
48
+ readonly before: StoreCountSnapshot;
49
+ readonly after: StoreCountSnapshot;
50
+ readonly reason: string;
51
+ }
52
+
53
+ export type StoreCountSource = 'jsonl' | 'sqlite';
54
+
55
+ export interface StoreCountSnapshot {
56
+ readonly lexicalRows: number;
57
+ readonly vectorRows: number;
58
+ readonly lexicalSource: StoreCountSource;
59
+ }
60
+
61
+ export interface StoreMark {
62
+ readonly project: string;
63
+ /** `unknown` is read-only compatibility for marks written before source tracking existed. */
64
+ readonly lexicalSource: StoreCountSource | 'unknown';
65
+ readonly lexicalMax: number;
66
+ readonly vectorMax: number;
67
+ readonly lexicalLast: number;
68
+ readonly vectorLast: number;
69
+ /** First lexical backend transition since the last explicit reset. */
70
+ readonly sourceChangedAt?: string;
71
+ /** Most recent explicit operator reset. */
72
+ readonly resetAt?: StoreResetReceipt;
73
+ /** Legacy v1 audit field. Readable for compatibility, never re-emitted or treated as authorization. */
74
+ readonly acceptedShrinkAt?: AcceptedStoreShrink;
75
+ readonly updatedAt: string;
76
+ readonly version: number;
77
+ }
78
+
79
+ export interface StoreMarkObservation {
80
+ readonly lexicalRows: number;
81
+ readonly vectorRows: number;
82
+ readonly lexicalSource: StoreCountSource;
83
+ readonly observedAt: string;
84
+ /** Command that produced the observation; diagnostic only, never authorization. */
85
+ readonly command?: string;
86
+ }
87
+
88
+ export interface StoreMarkWriteOptions {
89
+ /** Zero makes a reader's opportunistic mark refresh a single non-blocking acquisition attempt. */
90
+ readonly timeoutMs?: number;
91
+ /** Writer-preflight compare-and-set used to consume a source transition exactly once. */
92
+ readonly expectedPreviousLexicalSource?: StoreCountSource | 'unknown';
93
+ }
94
+
95
+ export type StoreRowCount = number | 'unreadable';
96
+
97
+ export type StoreHealthVerdict = 'ok' | 'collapsed' | 'cold-start-over-existing' | 'source-changed' | 'unreadable' | 'no-mark';
98
+
99
+ export interface StoreHealth {
100
+ readonly verdict: StoreHealthVerdict;
101
+ readonly reason: string;
102
+ }
103
+
104
+ export interface StoreHealthInput {
105
+ readonly projectRoot: string;
106
+ readonly lexicalRows: StoreRowCount;
107
+ readonly lexicalSource: StoreCountSource;
108
+ readonly vectorRows: StoreRowCount;
109
+ readonly mark: StoreMark | undefined;
110
+ }
111
+
112
+ function canonicalProjectRoot(projectRoot: string): string {
113
+ const absolute = resolve(projectRoot);
114
+ return existsSync(absolute) ? realpathSync(absolute) : absolute;
115
+ }
116
+
117
+ function projectHash(projectRoot: string): string {
118
+ return createHash('sha256').update(canonicalProjectRoot(projectRoot)).digest('hex').slice(0, 16);
119
+ }
120
+
121
+ /** External high-water-mark path for one canonical project root. */
122
+ export function storeGuardPath(projectRoot: string): string {
123
+ return join(homedir(), '.dz-store-guard', `${projectHash(projectRoot)}.json`);
124
+ }
125
+
126
+ /** External directory populated by `scripts/dz-store-snapshot.sh` for this project. */
127
+ export function storeSnapshotPath(projectRoot: string): string {
128
+ return join(homedir(), '.dz-store-snapshots', projectHash(projectRoot));
129
+ }
130
+
131
+ function isCount(value: unknown): value is number {
132
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
133
+ }
134
+
135
+ function isStoreCountSource(value: unknown): value is StoreCountSource {
136
+ return value === 'jsonl' || value === 'sqlite';
137
+ }
138
+
139
+ function isStoreCountSnapshot(value: unknown): value is StoreCountSnapshot {
140
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
141
+ const candidate = value as Partial<StoreCountSnapshot>;
142
+ return isCount(candidate.lexicalRows) && isCount(candidate.vectorRows)
143
+ && isStoreCountSource(candidate.lexicalSource);
144
+ }
145
+
146
+ function parseStoreMark(raw: string, path: string, projectRoot: string): StoreMark {
147
+ let value: unknown;
148
+ try {
149
+ value = JSON.parse(raw);
150
+ } catch {
151
+ throw new Error(`store guard mark is not valid JSON: ${path}`);
152
+ }
153
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
154
+ throw new Error(`store guard mark is not an object: ${path}`);
155
+ }
156
+ const candidate = value as Partial<StoreMark>;
157
+ const canonical = canonicalProjectRoot(projectRoot);
158
+ if (candidate.project !== canonical) throw new Error(`store guard project mismatch in ${path}`);
159
+ if (!isCount(candidate.lexicalMax) || !isCount(candidate.vectorMax)
160
+ || !isCount(candidate.lexicalLast) || !isCount(candidate.vectorLast)) {
161
+ throw new Error(`store guard mark has invalid row counts: ${path}`);
162
+ }
163
+ if (typeof candidate.updatedAt !== 'string' || Number.isNaN(Date.parse(candidate.updatedAt))) {
164
+ throw new Error(`store guard mark has invalid updatedAt: ${path}`);
165
+ }
166
+ if (candidate.version !== STORE_GUARD_VERSION) {
167
+ throw new Error(`unsupported store guard version ${String(candidate.version)} in ${path}`);
168
+ }
169
+ if (candidate.lexicalSource !== undefined
170
+ && candidate.lexicalSource !== 'jsonl' && candidate.lexicalSource !== 'sqlite') {
171
+ throw new Error(`store guard mark has invalid lexicalSource: ${path}`);
172
+ }
173
+ if (candidate.acceptedShrinkAt !== undefined) {
174
+ const accepted = candidate.acceptedShrinkAt;
175
+ if (typeof accepted !== 'object' || accepted === null || Array.isArray(accepted)
176
+ || typeof accepted.at !== 'string' || Number.isNaN(Date.parse(accepted.at))
177
+ || typeof accepted.reason !== 'string' || accepted.reason.trim() === '') {
178
+ throw new Error(`store guard mark has invalid acceptedShrinkAt: ${path}`);
179
+ }
180
+ const structured = accepted.command !== undefined || accepted.kind !== undefined
181
+ || accepted.before !== undefined || accepted.after !== undefined;
182
+ if (structured && (typeof accepted.command !== 'string' || accepted.command.trim() === ''
183
+ || !['measured-deletion', 'source-transition', 'operator-reset', 'cold-start-override'].includes(accepted.kind ?? '')
184
+ || !isStoreCountSnapshot(accepted.before) || !isStoreCountSnapshot(accepted.after))) {
185
+ throw new Error(`store guard mark has invalid reconciliation receipt: ${path}`);
186
+ }
187
+ }
188
+ if (candidate.sourceChangedAt !== undefined
189
+ && (typeof candidate.sourceChangedAt !== 'string' || Number.isNaN(Date.parse(candidate.sourceChangedAt)))) {
190
+ throw new Error(`store guard mark has invalid sourceChangedAt: ${path}`);
191
+ }
192
+ if (candidate.resetAt !== undefined) {
193
+ const reset = candidate.resetAt;
194
+ if (typeof reset !== 'object' || reset === null || Array.isArray(reset)
195
+ || typeof reset.at !== 'string' || Number.isNaN(Date.parse(reset.at))
196
+ || typeof reset.command !== 'string' || reset.command.trim() === ''
197
+ || reset.decision !== 'manual operator decision'
198
+ || !isStoreCountSnapshot(reset.before) || !isStoreCountSnapshot(reset.after)
199
+ || typeof reset.reason !== 'string' || reset.reason.trim() === '') {
200
+ throw new Error(`store guard mark has invalid resetAt receipt: ${path}`);
201
+ }
202
+ }
203
+ return {
204
+ ...(candidate as StoreMark),
205
+ lexicalSource: candidate.lexicalSource ?? 'unknown',
206
+ };
207
+ }
208
+
209
+ /** Read the external mark; absence is the only condition represented by `undefined`. */
210
+ export function readStoreMark(projectRoot: string): StoreMark | undefined {
211
+ const path = storeGuardPath(projectRoot);
212
+ if (!existsSync(path)) return undefined;
213
+ return parseStoreMark(readFileSync(path, 'utf8'), path, projectRoot);
214
+ }
215
+
216
+ function validateObservation(observation: StoreMarkObservation): void {
217
+ if (!isCount(observation.lexicalRows) || !isCount(observation.vectorRows)) {
218
+ throw new Error('store guard observation has invalid row counts');
219
+ }
220
+ if (typeof observation.observedAt !== 'string' || Number.isNaN(Date.parse(observation.observedAt))) {
221
+ throw new Error('store guard observation has invalid observedAt');
222
+ }
223
+ if (observation.lexicalSource !== 'jsonl' && observation.lexicalSource !== 'sqlite') {
224
+ throw new Error('store guard observation has invalid lexical source');
225
+ }
226
+ if (observation.command !== undefined && observation.command.trim() === '') {
227
+ throw new Error('store guard observation command must not be empty');
228
+ }
229
+ }
230
+
231
+ function persistStoreMark(path: string, mark: StoreMark): StoreMark {
232
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
233
+ try {
234
+ writeFileSync(temp, `${JSON.stringify(mark, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
235
+ renameSync(temp, path);
236
+ } finally {
237
+ try { unlinkSync(temp); } catch { /* renamed or never created */ }
238
+ }
239
+ return mark;
240
+ }
241
+
242
+ /**
243
+ * Atomically record an ordinary observation. Both maxima are monotonic by
244
+ * construction: no command label, measured deletion, override, or source
245
+ * transition can lower them. A first source transition is recorded as
246
+ * metadata only; a later low-population transition requires an explicit reset.
247
+ */
248
+ export function writeStoreMark(
249
+ projectRoot: string,
250
+ observation: StoreMarkObservation,
251
+ options: StoreMarkWriteOptions = {},
252
+ ): StoreMark {
253
+ const project = canonicalProjectRoot(projectRoot);
254
+ const path = storeGuardPath(project);
255
+ const guardRoot = dirname(path);
256
+ validateObservation(observation);
257
+ mkdirSync(guardRoot, { recursive: true, mode: 0o700 });
258
+
259
+ return withNamedLockSync(guardRoot, `store-guard-${basename(path, '.json')}`, () => {
260
+ const previous = readStoreMark(project);
261
+ if (options.expectedPreviousLexicalSource !== undefined
262
+ && previous?.lexicalSource !== options.expectedPreviousLexicalSource) {
263
+ throw new Error(`lexical source-change allowance was already consumed; expected ${options.expectedPreviousLexicalSource}, found ${previous?.lexicalSource ?? 'no mark'}`);
264
+ }
265
+ const sourceChanged = previous !== undefined && previous.lexicalSource !== 'unknown'
266
+ && previous.lexicalSource !== observation.lexicalSource;
267
+ if (sourceChanged && previous.sourceChangedAt !== undefined
268
+ && observation.lexicalRows < previous.lexicalMax) {
269
+ throw new Error('lexical source changed again below the recorded maximum; run dz store-guard --reset');
270
+ }
271
+ const next: StoreMark = {
272
+ project,
273
+ lexicalSource: observation.lexicalSource,
274
+ lexicalMax: Math.max(previous?.lexicalMax ?? 0, observation.lexicalRows),
275
+ vectorMax: Math.max(previous?.vectorMax ?? 0, observation.vectorRows),
276
+ lexicalLast: observation.lexicalRows,
277
+ vectorLast: observation.vectorRows,
278
+ ...(sourceChanged ? { sourceChangedAt: previous?.sourceChangedAt ?? observation.observedAt }
279
+ : previous?.sourceChangedAt === undefined ? {} : { sourceChangedAt: previous.sourceChangedAt }),
280
+ ...(previous?.resetAt === undefined ? {} : { resetAt: previous.resetAt }),
281
+ updatedAt: observation.observedAt,
282
+ version: STORE_GUARD_VERSION,
283
+ };
284
+ return persistStoreMark(path, next);
285
+ }, { ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) });
286
+ }
287
+
288
+ /** Explicit lowering primitive. Callers must obtain operator confirmation first. */
289
+ export function resetStoreMark(projectRoot: string, observation: StoreMarkObservation): StoreMark {
290
+ const project = canonicalProjectRoot(projectRoot);
291
+ const path = storeGuardPath(project);
292
+ const guardRoot = dirname(path);
293
+ validateObservation(observation);
294
+ if (observation.command?.trim() !== 'dz store-guard --reset') {
295
+ throw new Error('store guard reset requires command dz store-guard --reset');
296
+ }
297
+ mkdirSync(guardRoot, { recursive: true, mode: 0o700 });
298
+ return withNamedLockSync(guardRoot, `store-guard-${basename(path, '.json')}`, () => {
299
+ const previous = readStoreMark(project);
300
+ const before: StoreCountSnapshot = {
301
+ lexicalRows: previous?.lexicalMax ?? observation.lexicalRows,
302
+ vectorRows: previous?.vectorMax ?? observation.vectorRows,
303
+ lexicalSource: previous?.lexicalSource === 'jsonl' || previous?.lexicalSource === 'sqlite'
304
+ ? previous.lexicalSource
305
+ : observation.lexicalSource,
306
+ };
307
+ const after: StoreCountSnapshot = {
308
+ lexicalRows: observation.lexicalRows,
309
+ vectorRows: observation.vectorRows,
310
+ lexicalSource: observation.lexicalSource,
311
+ };
312
+ const resetAt: StoreResetReceipt = {
313
+ at: observation.observedAt,
314
+ command: 'dz store-guard --reset',
315
+ decision: 'manual operator decision',
316
+ before,
317
+ after,
318
+ reason: `dz store-guard --reset: manual operator decision; before lexical=${before.lexicalRows}, vector=${before.vectorRows}; after lexical=${after.lexicalRows}, vector=${after.vectorRows}`,
319
+ };
320
+ return persistStoreMark(path, {
321
+ project,
322
+ lexicalSource: observation.lexicalSource,
323
+ lexicalMax: observation.lexicalRows,
324
+ vectorMax: observation.vectorRows,
325
+ lexicalLast: observation.lexicalRows,
326
+ vectorLast: observation.vectorRows,
327
+ resetAt,
328
+ updatedAt: observation.observedAt,
329
+ version: STORE_GUARD_VERSION,
330
+ });
331
+ });
332
+ }
333
+
334
+ function collapsedTier(name: 'lexical' | 'vector', rows: number, maximum: number, last: number): string | undefined {
335
+ if (maximum >= STORE_COLLAPSE_MIN_ROWS && rows < maximum * (1 - STORE_COLLAPSE_MAX_FRACTION)) {
336
+ return `${name} rows ${rows} are more than ${STORE_COLLAPSE_MAX_FRACTION * 100}% below maximum ${maximum}`;
337
+ }
338
+ if (last - rows > STORE_COLLAPSE_LAST_ROWS) {
339
+ return `${name} rows ${rows} are more than ${STORE_COLLAPSE_LAST_ROWS} below previous ${last}`;
340
+ }
341
+ return undefined;
342
+ }
343
+
344
+ /** Pure classification of current row counts against the external mark. */
345
+ export function checkStoreHealth(input: StoreHealthInput): StoreHealth {
346
+ const { projectRoot, lexicalRows, lexicalSource, vectorRows, mark } = input;
347
+ const unreadable = [
348
+ ...(lexicalRows === 'unreadable' ? ['lexical'] : []),
349
+ ...(vectorRows === 'unreadable' ? ['vector'] : []),
350
+ ];
351
+ if (unreadable.length > 0) {
352
+ return {
353
+ verdict: 'unreadable',
354
+ reason: `${unreadable.join(' and ')} store${unreadable.length > 1 ? 's are' : ' is'} unreadable`,
355
+ };
356
+ }
357
+ if (mark === undefined) return { verdict: 'no-mark', reason: 'no store mark exists for this project' };
358
+
359
+ const lexicalCount = lexicalRows as number;
360
+ const vectorCount = vectorRows as number;
361
+ const sourceChanged = mark.lexicalSource !== lexicalSource;
362
+
363
+ const cold: string[] = [];
364
+ if (mark.lexicalMax > 0 && lexicalCount === 0) cold.push(`lexical had ${mark.lexicalMax}`);
365
+ if (mark.vectorMax > 0 && vectorCount === 0) cold.push(`vector had ${mark.vectorMax}`);
366
+ if (cold.length > 0) {
367
+ return {
368
+ verdict: 'cold-start-over-existing',
369
+ reason: `${resolve(projectRoot)} is empty over an existing mark (${cold.join(', ')})`,
370
+ };
371
+ }
372
+
373
+ const repeatedLowSourceChange = sourceChanged && mark.sourceChangedAt !== undefined
374
+ && lexicalCount < mark.lexicalMax
375
+ ? `lexical source changed again after ${mark.sourceChangedAt} with rows ${lexicalCount} below maximum ${mark.lexicalMax}`
376
+ : undefined;
377
+ const collapse = repeatedLowSourceChange
378
+ ?? (sourceChanged ? undefined : collapsedTier('lexical', lexicalCount, mark.lexicalMax, mark.lexicalLast))
379
+ ?? collapsedTier('vector', vectorCount, mark.vectorMax, mark.vectorLast);
380
+ if (collapse !== undefined) return { verdict: 'collapsed', reason: collapse };
381
+ if (sourceChanged) {
382
+ return {
383
+ verdict: 'source-changed',
384
+ reason: `lexical source changed from ${mark.lexicalSource} to ${lexicalSource}; one writer may record the migration without lowering maximum ${mark.lexicalMax}, then use dz store-guard --reset if the new baseline is intentional`,
385
+ };
386
+ }
387
+ return { verdict: 'ok', reason: 'row counts are within the recorded high-water bounds' };
388
+ }