hafez 1.1.0 → 1.3.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.
@@ -5,12 +5,13 @@
5
5
  // session and then produce valid batch JSON on first try.
6
6
  //
7
7
  // Hand-written sections: Preamble + Hard rules (prose, free-form).
8
- // Generated sections (wrapped in <!-- SECTION:name --> markers so the drift
9
- // test can parse them): op-catalog, critical-enums, batch-examples, digest.
8
+ // Generated sections (wrapped in <!-- SECTION:name --> markers so tests can
9
+ // parse them): op-catalog, critical-enums, batch-examples, digest.
10
10
  //
11
- // If the generated sections drift from the Zod schema, the drift test fails
12
- // loudly. Do NOT enumerate enum values inside the preamble or hard rules —
13
- // those come from schema-introspect.ts.
11
+ // Generated sections derive from the Op Spec table (batch-ops.ts via
12
+ // schema-introspect.ts), so they cannot drift from the Zod schemas. Do NOT
13
+ // enumerate enum values inside the preamble or hard rules — those come from
14
+ // schema-introspect.ts.
14
15
  import { listOps, getEnumValues, getOpExample, getOpSchema } from './schema-introspect.js';
15
16
  const PREAMBLE = `# Hafez — Agent Prompt Gate
16
17
 
@@ -58,7 +59,17 @@ listed here, it does not exist — do not invent fields.
58
59
  - When in doubt, run \`hafez schema <op>\` before writing a batch. Cheap,
59
60
  deterministic, no side effects.
60
61
  - Use \`hafez batch --dry-run\` to validate a payload without touching the
61
- vault. Same errors, zero filesystem mutation.
62
+ vault. Same validation as a real apply, zero filesystem mutation.
63
+
64
+ ## Slug contract (create-then-link in one batch)
65
+
66
+ Slugs are deterministic: slug = slugify(name) — lowercase, non-alphanumeric
67
+ runs collapse to single \`-\`, edges trimmed (\`"Auth Refactor!"\` →
68
+ \`auth-refactor\`). A batch executes sequentially, so later ops may reference
69
+ the slugs of earlier creates in the same batch (\`create\` then \`link\`/\`update\`
70
+ works in one payload). Collisions hard-error — a wrong slug prediction can
71
+ never silently hit an existing document. To verify predictions first:
72
+ \`hafez batch --dry-run\` reports the derived slug for every create op.
62
73
  `;
63
74
  function renderOpCatalog() {
64
75
  const lines = ['<!-- SECTION:op-catalog -->'];
@@ -93,9 +104,10 @@ function renderCriticalEnums() {
93
104
  }
94
105
  function renderBatchExamples() {
95
106
  const lines = ['<!-- SECTION:batch-examples -->'];
96
- lines.push('Batch JSON shape — wrap one or more ops in an array and pipe to stdin:');
107
+ lines.push('Batch JSON shape — wrap one or more ops in an array:');
97
108
  lines.push('');
98
- lines.push(' echo \'[...]\' | hafez batch');
109
+ lines.push(' echo \'[...]\' | hafez batch # POSIX shells');
110
+ lines.push(' hafez batch --file payload.json # any platform (PowerShell 5.1 cannot pipe stdin)');
99
111
  lines.push('');
100
112
  lines.push('Minimal example per op (canonical field names; aliased keys stripped):');
101
113
  lines.push('');
@@ -113,9 +125,11 @@ function renderBatchExamples() {
113
125
  function renderDigestSection() {
114
126
  const lines = ['<!-- SECTION:digest -->'];
115
127
  lines.push('Session digest — turn end-of-session context into a batch payload.');
116
- lines.push('NOT a batch op: it reads its own JSON from stdin and prints batch JSON.');
128
+ lines.push('NOT a batch op: it reads its own JSON and prints batch JSON.');
117
129
  lines.push('');
118
130
  lines.push(" echo '<digest-json>' | hafez digest | hafez batch");
131
+ lines.push(' # No stdin pipes (PowerShell 5.1): hafez digest --file summary.json > ops.json,');
132
+ lines.push(' # then hafez batch --file ops.json');
119
133
  lines.push('');
120
134
  lines.push('Input fields (`hafez schema digest` for the full schema):');
121
135
  const schema = getOpSchema('digest');
@@ -142,6 +156,18 @@ const FOOTER = `## Lookup pointers
142
156
  * measured as char/3.5. The drift test in tests/cli-help-agent.test.ts
143
157
  * enforces the ceiling.
144
158
  */
159
+ const EXIT_CODES_SECTION = `## Exit codes
160
+
161
+ | code | meaning |
162
+ |------|---------|
163
+ | 0 | success |
164
+ | 1 | not found / unknown error |
165
+ | 2 | validation or usage error (bad flags, bad payload, core rejected the write) |
166
+ | 3 | git push failed — the write IS committed locally |
167
+ | 4 | write succeeded but the auto-sync after it failed |
168
+ | 5 | slug already exists |
169
+ | 6 | git commit failed — nothing was written to history |
170
+ | 7 | vault locked by another hafez process — retry when it finishes |`;
145
171
  export function renderAgentHelp() {
146
172
  return [
147
173
  PREAMBLE,
@@ -153,6 +179,8 @@ export function renderAgentHelp() {
153
179
  '',
154
180
  renderDigestSection(),
155
181
  '',
182
+ EXIT_CODES_SECTION,
183
+ '',
156
184
  FOOTER,
157
185
  ].join('\n');
158
186
  }
package/dist/cli/index.js CHANGED
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createHafez } from '../index.js';
6
6
  import { HafezError } from '../types.js';
7
+ import { parseArgs, UsageError } from './args.js';
7
8
  import { cmdRead, cmdQuery, cmdSearch, cmdCreate, cmdUpdate, cmdLink, cmdUnlink, cmdPromote, cmdValidate, cmdIndexRebuild, cmdBatch, cmdSync, cmdStats, cmdChangelog, cmdDigest, cmdCapture } from './commands.js';
8
9
  import { cmdSchema } from './commands-schema.js';
9
10
  import { renderAgentHelp } from './help-agent.js';
@@ -11,6 +12,7 @@ import { renderOnboard } from './onboard.js';
11
12
  import { resolveVaultPath, noVaultMessage, cmdInit } from './resolve-vault.js';
12
13
  const COMMANDS = {
13
14
  read: cmdRead,
15
+ sync: cmdSync,
14
16
  query: cmdQuery,
15
17
  search: cmdSearch,
16
18
  create: cmdCreate,
@@ -25,7 +27,7 @@ const COMMANDS = {
25
27
  index: (os, args) => {
26
28
  if (args[0] === 'rebuild')
27
29
  return cmdIndexRebuild(os);
28
- throw new Error('Usage: hafez index rebuild');
30
+ throw new UsageError('Usage: hafez index rebuild');
29
31
  },
30
32
  stats: cmdStats,
31
33
  changelog: cmdChangelog,
@@ -76,6 +78,49 @@ Global flags:
76
78
 
77
79
  Vault discovery: --vault flag > ~/.config/hafez/vault
78
80
  `;
81
+ /** Exit codes, documented in `hafez help --agent`. 0 = success; 4 is the
82
+ * mutate-succeeded-sync-failed warning path; anything unmapped = 1. */
83
+ const EXIT_CODES = {
84
+ NOT_FOUND: 1,
85
+ VALIDATION_FAILED: 2,
86
+ GIT_PUSH_FAILED: 3,
87
+ SLUG_EXISTS: 5,
88
+ GIT_COMMIT_FAILED: 6,
89
+ VAULT_LOCKED: 7,
90
+ };
91
+ /** The one error → exit adapter. Usage errors (bad flags/args) are distinct
92
+ * from vault errors and from unknown crashes. */
93
+ function report(err) {
94
+ if (err instanceof UsageError) {
95
+ process.stderr.write(`Error: ${err.message}\n`);
96
+ process.exit(2);
97
+ }
98
+ if (err instanceof HafezError) {
99
+ process.stderr.write(`Error: ${err.message}\n`);
100
+ if (err.details?.length) {
101
+ for (const detail of err.details)
102
+ process.stderr.write(` - ${detail}\n`);
103
+ }
104
+ process.exit(EXIT_CODES[err.code] ?? 1);
105
+ }
106
+ failUnknown(err);
107
+ }
108
+ // Terminal handler for non-Hafez errors. Special-cases unbuilt better-sqlite3
109
+ // bindings: npm 11+ blocks install scripts by default, so `npm install -g
110
+ // hafez` reports success with the native module unbuilt and the first indexed
111
+ // command dies here — guide instead of stack-tracing.
112
+ function failUnknown(err) {
113
+ const e = err;
114
+ if (/better[-_]sqlite3|Could not locate the bindings file/i.test(`${e.message}\n${e.stack ?? ''}`)) {
115
+ process.stderr.write('Error: the SQLite bindings (better-sqlite3) are not built.\n' +
116
+ 'npm 11+ blocks install scripts by default, so the install reports success\n' +
117
+ 'while skipping the native build. Fix with:\n\n' +
118
+ ' npm install -g hafez --allow-scripts=better-sqlite3\n');
119
+ process.exit(1);
120
+ }
121
+ process.stderr.write(`Error: ${e.message}\n`);
122
+ process.exit(1);
123
+ }
79
124
  export async function main(argv) {
80
125
  const args = argv.slice(2);
81
126
  // Extract global flags
@@ -121,16 +166,7 @@ export async function main(argv) {
121
166
  process.stdout.write(result + '\n');
122
167
  }
123
168
  catch (err) {
124
- if (err instanceof HafezError) {
125
- process.stderr.write(`Error: ${err.message}\n`);
126
- if (err.details?.length) {
127
- for (const detail of err.details)
128
- process.stderr.write(` - ${detail}\n`);
129
- }
130
- process.exit(2);
131
- }
132
- process.stderr.write(`Error: ${err.message}\n`);
133
- process.exit(1);
169
+ report(err);
134
170
  }
135
171
  return;
136
172
  }
@@ -152,61 +188,28 @@ export async function main(argv) {
152
188
  const vaultPath = resolution;
153
189
  // export runs before Hafez instance creation — read-only, no auto-sync
154
190
  if (command === 'export') {
155
- if (!args.includes('--okf')) {
156
- process.stderr.write('Usage: hafez export --okf [--out <dir>]\n');
157
- process.exit(1);
158
- }
159
- const outIdx = args.indexOf('--out');
160
- let outDir = resolve(process.cwd(), 'okf-export');
161
- if (outIdx !== -1) {
162
- const outValue = args[outIdx + 1];
163
- if (!outValue) {
164
- process.stderr.write('Usage: hafez export --okf [--out <dir>]\n');
165
- process.exit(1);
166
- }
167
- outDir = resolve(outValue);
168
- }
169
- const { exportOkf } = await import('../export-okf.js');
170
191
  try {
171
- const report = exportOkf(vaultPath, outDir);
192
+ const EXPORT_USAGE = 'Usage: hafez export --okf [--out <dir>]';
193
+ const { flags } = parseArgs(args, { okf: 'boolean', out: 'string' }, EXPORT_USAGE);
194
+ if (flags.okf !== true)
195
+ throw new UsageError(EXPORT_USAGE);
196
+ const outDir = typeof flags.out === 'string' ? resolve(flags.out) : resolve(process.cwd(), 'okf-export');
197
+ const { exportOkf } = await import('../export-okf.js');
198
+ const exportReport = exportOkf(vaultPath, outDir);
172
199
  if (json) {
173
- process.stdout.write(JSON.stringify(report, null, 2) + '\n');
200
+ process.stdout.write(JSON.stringify(exportReport, null, 2) + '\n');
174
201
  }
175
202
  else {
176
- const lines = [`Exported ${report.entities} entities, ${report.knowledge} knowledge, ${report.sessions} sessions to ${report.outDir}`];
177
- for (const s of report.skipped)
203
+ const lines = [`Exported ${exportReport.entities} entities, ${exportReport.knowledge} knowledge, ${exportReport.sessions} sessions to ${exportReport.outDir}`];
204
+ for (const s of exportReport.skipped)
178
205
  lines.push(` skipped ${s.file}: ${s.reason}`);
179
- if (report.unresolvedLinks > 0)
180
- lines.push(` unresolved links: ${report.unresolvedLinks}`);
206
+ if (exportReport.unresolvedLinks > 0)
207
+ lines.push(` unresolved links: ${exportReport.unresolvedLinks}`);
181
208
  process.stdout.write(lines.join('\n') + '\n');
182
209
  }
183
210
  }
184
211
  catch (err) {
185
- process.stderr.write(`Error: ${err.message}\n`);
186
- process.exit(1);
187
- }
188
- return;
189
- }
190
- // sync is handled specially — always runs, not gated by MUTATING_COMMANDS
191
- if (command === 'sync') {
192
- const os = createHafez({ vaultPath, git: { push: false } });
193
- try {
194
- const result = await cmdSync(os, args);
195
- process.stdout.write(result + '\n');
196
- }
197
- catch (err) {
198
- if (err instanceof HafezError) {
199
- process.stderr.write(`Error: ${err.message}\n`);
200
- if (err.details?.length) {
201
- for (const detail of err.details) {
202
- process.stderr.write(` - ${detail}\n`);
203
- }
204
- }
205
- const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
206
- process.exit(exitCodes[err.code] ?? 1);
207
- }
208
- process.stderr.write(`Error: ${err.message}\n`);
209
- process.exit(1);
212
+ report(err);
210
213
  }
211
214
  return;
212
215
  }
@@ -233,17 +236,6 @@ export async function main(argv) {
233
236
  process.stdout.write(result + '\n');
234
237
  }
235
238
  catch (err) {
236
- if (err instanceof HafezError) {
237
- process.stderr.write(`Error: ${err.message}\n`);
238
- if (err.details?.length) {
239
- for (const detail of err.details) {
240
- process.stderr.write(` - ${detail}\n`);
241
- }
242
- }
243
- const exitCodes = { NOT_FOUND: 1, VALIDATION_FAILED: 2, GIT_PUSH_FAILED: 3 };
244
- process.exit(exitCodes[err.code] ?? 1);
245
- }
246
- process.stderr.write(`Error: ${err.message}\n`);
247
- process.exit(1);
239
+ report(err);
248
240
  }
249
241
  }
@@ -1,2 +1,2 @@
1
- export declare const INTEGRATION_SNIPPET = "## Hafez \u2014 Session Memory\n\n- Session start: if the first message names a project or topic, load context\n with `hafez read <slug>` (or `hafez search \"<topic>\"`). Don't load\n speculatively.\n- Mid-session: new workstream -> create an entity. Durable insight -> create\n or update a knowledge note (search first). User mentions a project by\n name -> check if it's a Hafez entity. Use `hafez batch` for 2+ mutations.\n- Session end: record a digest (`hafez digest | hafez batch`) with the\n entities touched, decisions made, and a short narrative.\n- Knowledge boundary: useful beyond this repo/session -> vault knowledge\n note. Specific to one repo's code -> that repo's own docs, not the vault.";
1
+ export declare const INTEGRATION_SNIPPET = "## Hafez \u2014 Session Memory\n\n- Session start: if the first message names a project or topic, load context\n with `hafez read <slug>` (or `hafez search \"<topic>\"`). Don't load\n speculatively.\n- Mid-session: new workstream -> create an entity. Durable insight -> create\n or update a knowledge note (search first). User mentions a project by\n name -> check if it's a Hafez entity. Use `hafez batch` for 2+ mutations.\n- Session end: record a digest (`hafez digest | hafez batch`; on shells\n that can't pipe, e.g. PowerShell 5.1: `--file` on both) with the\n entities touched, decisions made, and a short narrative.\n- Knowledge boundary: useful beyond this repo/session -> vault knowledge\n note. Specific to one repo's code -> that repo's own docs, not the vault.";
2
2
  export declare function renderOnboard(): string;
@@ -16,7 +16,8 @@ export const INTEGRATION_SNIPPET = `## Hafez — Session Memory
16
16
  - Mid-session: new workstream -> create an entity. Durable insight -> create
17
17
  or update a knowledge note (search first). User mentions a project by
18
18
  name -> check if it's a Hafez entity. Use \`hafez batch\` for 2+ mutations.
19
- - Session end: record a digest (\`hafez digest | hafez batch\`) with the
19
+ - Session end: record a digest (\`hafez digest | hafez batch\`; on shells
20
+ that can't pipe, e.g. PowerShell 5.1: \`--file\` on both) with the
20
21
  entities touched, decisions made, and a short narrative.
21
22
  - Knowledge boundary: useful beyond this repo/session -> vault knowledge
22
23
  note. Specific to one repo's code -> that repo's own docs, not the vault.`;
@@ -31,6 +32,11 @@ Prerequisites: \`hafez init\` resolves a vault, and you have read
31
32
  no other pieces are required. If your harness is Claude Code, also install
32
33
  the bundled skill — see the README Setup (github.com/shrvn-r/hafez).
33
34
 
35
+ Re-running onboard is normal and intended: on a vault that is already set
36
+ up and integrated, skip Step 2 and use Step 1 as a seeding interview for
37
+ whatever is new since last time. Seed current, active work — seeding never
38
+ means documenting the past; using the vault as you go stays primary.
39
+
34
40
  ## Step 1 — Seed the vault
35
41
 
36
42
  An empty vault gives the agent nothing to trigger on, so the memory loop
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs
3
3
  import { join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { execSync } from 'child_process';
6
+ import { kindDir } from '../vault.js';
6
7
  export function getConfigDir() {
7
8
  const xdg = process.env.XDG_CONFIG_HOME;
8
9
  return join(xdg || join(homedir(), '.config'), 'hafez');
@@ -28,7 +29,7 @@ export function saveVaultConfig(vaultPath) {
28
29
  writeFileSync(join(dir, 'vault'), resolve(vaultPath) + '\n');
29
30
  }
30
31
  function isVault(dirPath) {
31
- return existsSync(dirPath) && statSync(dirPath).isDirectory() && existsSync(join(dirPath, 'entities'));
32
+ return existsSync(dirPath) && statSync(dirPath).isDirectory() && existsSync(join(dirPath, kindDir('entity')));
32
33
  }
33
34
  /**
34
35
  * Resolve vault path. Returns string or null.
@@ -1,4 +1,4 @@
1
- import { BatchOperationSchema, SessionLogSchema, UpdateFieldsSchema } from './commands.js';
1
+ import { BatchOperationSchema, SessionLogSchema, UpdateFieldsSchema } from '../batch-ops.js';
2
2
  export interface FieldSchema {
3
3
  type: string;
4
4
  optional?: boolean;
@@ -10,7 +10,7 @@
10
10
  // types actually used in BatchOperationSchema plus a few forward-looking ones
11
11
  // (`default`, `effects`, `union`) so the walker does not explode when the
12
12
  // schema is extended.
13
- import { BatchOperationSchema, SessionLogSchema, UpdateFieldsSchema, } from './commands.js';
13
+ import { BatchOperationSchema, SessionLogSchema, UpdateFieldsSchema, OP_SPECS, } from '../batch-ops.js';
14
14
  import { DigestInputSchema } from '../digest.js';
15
15
  import { ALIAS_KEYS } from '../contracts.js';
16
16
  // --- Walker ---
@@ -155,16 +155,8 @@ export const DIGEST_EXAMPLE = {
155
155
  session_date: '2026-01-15',
156
156
  agent: 'claude',
157
157
  };
158
- const OP_DESCRIPTIONS = {
159
- 'update': 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …).',
160
- 'create-entity': 'Create a new entity (capture / entity / project).',
161
- 'create-knowledge': 'Create a new knowledge note (insight / plan subtype).',
162
- 'create-session': 'Create a new session note linked to an entity.',
163
- 'capture': 'Quick capture — a raw inbox item with no structure required.',
164
- 'link': 'Add a parent or related link between two entities.',
165
- 'unlink': 'Remove a parent or related link between two entities.',
166
- 'promote': 'Promote a capture to entity/project/knowledge, or an entity to project.',
167
- };
158
+ // Descriptions live in the Op Spec table (src/batch-ops.ts) — one row per op.
159
+ const OP_DESCRIPTIONS = Object.fromEntries(OP_SPECS.map(s => [s.name, s.description]));
168
160
  export function listOps() {
169
161
  return enumerateBranches().map(({ op, kind }) => {
170
162
  const name = kind ? `${op}-${kind}` : op;
@@ -293,17 +285,9 @@ function shouldStrip(path, _key) {
293
285
  return Object.prototype.hasOwnProperty.call(ALIAS_KEYS, path);
294
286
  }
295
287
  function isDefaultIncluded(op, kind, fieldKey) {
296
- if (op === 'create' && kind === 'entity' && fieldKey === 'type')
297
- return true;
298
- if (op === 'create' && kind === 'knowledge' && (fieldKey === 'subtype' || fieldKey === 'synthesis'))
299
- return true;
300
- if (op === 'create' && kind === 'session' && fieldKey === 'synthesis')
301
- return true;
302
- if (op === 'update' && fieldKey === 'status')
303
- return true;
304
- if (op === 'capture' && fieldKey === 'notes')
305
- return true;
306
- return false;
288
+ // Example-inclusion hints live in the Op Spec table, one row per op.
289
+ const spec = OP_SPECS.find(s => s.op === op && s.kind === kind);
290
+ return spec ? spec.exampleInclude.includes(fieldKey) : false;
307
291
  }
308
292
  function sampleValue(field) {
309
293
  switch (field.type) {
package/dist/cli.js CHANGED
@@ -1,3 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { main } from './cli/index.js';
3
- main(process.argv);
2
+ // Dynamic import so a failed native-module load can be caught: npm 11+
3
+ // blocks install scripts by default, so `npm install -g hafez` "succeeds"
4
+ // with the better-sqlite3 bindings unbuilt and every command dies at import
5
+ // time. Catch that one failure and print the remedy instead of a stack trace.
6
+ try {
7
+ const { main } = await import('./cli/index.js');
8
+ await main(process.argv);
9
+ }
10
+ catch (err) {
11
+ const msg = err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err);
12
+ if (/better[-_]sqlite3|Could not locate the bindings file|\bbindings\b/i.test(msg)) {
13
+ process.stderr.write('hafez: the SQLite bindings (better-sqlite3) are not built.\n' +
14
+ 'npm 11+ blocks install scripts by default, so the install reports success\n' +
15
+ 'while skipping the native build. Fix with:\n\n' +
16
+ ' npm install -g hafez --allow-scripts=better-sqlite3\n');
17
+ process.exit(1);
18
+ }
19
+ throw err;
20
+ }
21
+ export {};
@@ -24,3 +24,9 @@ export declare const ALIAS_KEYS: {
24
24
  readonly 'create.session.fields.insight': "synthesis";
25
25
  };
26
26
  export declare const SUBTYPE_SECTIONS: Record<KnowledgeSubtype, string[]>;
27
+ /** Sections whose content only ever grows — sync merges union their lines
28
+ * instead of letting the newer side replace the older (see merge.ts). */
29
+ export declare const APPEND_ONLY_SECTIONS: ReadonlySet<string>;
30
+ /** Session-file sections. Sessions have no TypeContract (they are not typed
31
+ * entities), but their headings are still structural for Document splitting. */
32
+ export declare const SESSION_SECTIONS: readonly ["Summary", "Entities Touched", "Decisions"];
package/dist/contracts.js CHANGED
@@ -63,8 +63,8 @@ export function hasCapability(type, section) {
63
63
  return contract.defaultSections.includes(section) || contract.optionalSections.includes(section);
64
64
  }
65
65
  // --- Canonical enum tuples (single source of truth) ---
66
- // Every consumer (file validators, CLI input validator, help renderer) imports these.
67
- // The cli-schema-parity test enforces that no other file declares local copies.
66
+ // Every consumer (file validators, the Op Spec table, help renderer) imports
67
+ // these — no other file may declare local copies.
68
68
  export const ENTITY_TYPES = ['capture', 'entity', 'project'];
69
69
  export const ENTITY_STATUSES = ['active', 'paused', 'done'];
70
70
  export const SESSION_LOG_TYPES = ['progress', 'decision', 'blocker', 'research'];
@@ -85,3 +85,9 @@ export const SUBTYPE_SECTIONS = {
85
85
  insight: ['Synthesis', 'Evidence', 'Sources'],
86
86
  plan: ['Goal', 'Steps', 'Dependencies'],
87
87
  };
88
+ /** Sections whose content only ever grows — sync merges union their lines
89
+ * instead of letting the newer side replace the older (see merge.ts). */
90
+ export const APPEND_ONLY_SECTIONS = new Set(['Evidence', 'Sources']);
91
+ /** Session-file sections. Sessions have no TypeContract (they are not typed
92
+ * entities), but their headings are still structural for Document splitting. */
93
+ export const SESSION_SECTIONS = ['Summary', 'Entities Touched', 'Decisions'];
package/dist/db.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { createHash } from 'crypto';
3
- import { readdirSync, readFileSync, statSync, existsSync, unlinkSync } from 'fs';
3
+ import { readFileSync, statSync, existsSync, unlinkSync } from 'fs';
4
4
  import { join, basename } from 'path';
5
- import { parseFilePath } from './vault.js';
5
+ import { parseFilePath, scanVaultDir, kindDir, INDEXED_KINDS } from './vault.js';
6
6
  import { ensureLocalExclude } from './knowledge-index.js';
7
- import { getNextActions, getBrief, WIKI_LINK_RE, findSection } from './sections.js';
8
- import { parseSessionLog } from './parser.js';
7
+ import { getNextActions, getBrief, WIKI_LINK_RE, findSection } from './document.js';
8
+ import { parseSessionLog } from './document.js';
9
9
  // Bump this when indexing logic changes to force a full rebuild.
10
10
  // v5: wiki link harvesting (mention relations), parent link indexing, relation filter on queries.
11
11
  // v6: description column on items.
@@ -86,16 +86,6 @@ const STOPWORDS = new Set([
86
86
  function hashContent(content) {
87
87
  return createHash('sha256').update(content).digest('hex');
88
88
  }
89
- function scanDir(dirPath) {
90
- try {
91
- // Skip dotfiles: their slugs are rejected by resolveFilePath, so indexing
92
- // them would list entities that read/update can never reach.
93
- return readdirSync(dirPath).filter(f => f.endsWith('.md') && !f.startsWith('.')).map(f => join(dirPath, f));
94
- }
95
- catch {
96
- return [];
97
- }
98
- }
99
89
  // --- Shared query functions (used by both write and readonly paths) ---
100
90
  function sanitizeFtsQuery(raw) {
101
91
  const cleaned = raw.replace(/[^\w\s]/g, ' ');
@@ -322,15 +312,19 @@ function getStatsFn(db) {
322
312
  WHERE kind = 'entity' AND status = 'active' AND next_action_count = 0
323
313
  ORDER BY last_touched DESC
324
314
  `).all();
315
+ // Full lists, not top-5: day-granularity timestamps tie massively (a
316
+ // new vault ties on everything), so the caller resolves ties (git commit
317
+ // time, then name) and caps. Name tiebreak here keeps the base order
318
+ // deterministic instead of leaking SQLite row order.
325
319
  const recently_touched = db.prepare(`
326
320
  SELECT slug, name, last_touched FROM items
327
321
  WHERE kind = 'entity'
328
- ORDER BY last_touched DESC LIMIT 5
322
+ ORDER BY last_touched DESC, name COLLATE NOCASE ASC
329
323
  `).all();
330
324
  const recently_created = db.prepare(`
331
325
  SELECT slug, name, created FROM items
332
326
  WHERE kind = 'entity'
333
- ORDER BY created DESC LIMIT 5
327
+ ORDER BY created DESC, name COLLATE NOCASE ASC
334
328
  `).all();
335
329
  const knowledgeCount = db.prepare("SELECT COUNT(*) as cnt FROM items WHERE kind = 'knowledge'").get().cnt;
336
330
  return {
@@ -579,8 +573,8 @@ export function createIndex(vaultPath, opts) {
579
573
  while ((wikiMatch = linkRe.exec(authoredBody)) !== null) {
580
574
  const targetSlug = wikiMatch[1];
581
575
  // Check if target file exists on disk (not in index — target may not be indexed yet)
582
- const targetEntityPath = join(vaultPath, 'entities', `${targetSlug}.md`);
583
- const targetKnowledgePath = join(vaultPath, 'knowledge', `${targetSlug}.md`);
576
+ const targetEntityPath = join(vaultPath, kindDir('entity'), `${targetSlug}.md`);
577
+ const targetKnowledgePath = join(vaultPath, kindDir('knowledge'), `${targetSlug}.md`);
584
578
  const targetExists = knownSlugs
585
579
  ? knownSlugs.has(targetSlug)
586
580
  : existsSync(targetEntityPath) || existsSync(targetKnowledgePath);
@@ -594,16 +588,16 @@ export function createIndex(vaultPath, opts) {
594
588
  db.exec('DELETE FROM links');
595
589
  // Collect all vault slugs for wiki link target validation
596
590
  const allVaultSlugs = new Set();
597
- for (const file of scanDir(join(vaultPath, 'entities'))) {
591
+ for (const file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
598
592
  allVaultSlugs.add(basename(file, '.md'));
599
593
  }
600
- for (const file of scanDir(join(vaultPath, 'knowledge'))) {
594
+ for (const file of scanVaultDir(join(vaultPath, kindDir('knowledge')))) {
601
595
  allVaultSlugs.add(basename(file, '.md'));
602
596
  }
603
- for (const file of scanDir(join(vaultPath, 'entities'))) {
597
+ for (const file of scanVaultDir(join(vaultPath, kindDir('entity')))) {
604
598
  indexFile(file, 'entity', allVaultSlugs);
605
599
  }
606
- for (const file of scanDir(join(vaultPath, 'knowledge'))) {
600
+ for (const file of scanVaultDir(join(vaultPath, kindDir('knowledge')))) {
607
601
  indexFile(file, 'knowledge', allVaultSlugs);
608
602
  }
609
603
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_sync', ?)")
@@ -620,12 +614,9 @@ export function createIndex(vaultPath, opts) {
620
614
  }
621
615
  const lastSyncTime = parseInt(lastSync.value, 10);
622
616
  let changed = false;
623
- const dirs = [
624
- { dir: 'entities', kind: 'entity' },
625
- { dir: 'knowledge', kind: 'knowledge' },
626
- ];
617
+ const dirs = INDEXED_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
627
618
  for (const { dir, kind } of dirs) {
628
- for (const file of scanDir(join(vaultPath, dir))) {
619
+ for (const file of scanVaultDir(join(vaultPath, dir))) {
629
620
  if (statSync(file).mtimeMs > lastSyncTime) {
630
621
  indexFile(file, kind);
631
622
  changed = true;
@@ -636,7 +627,7 @@ export function createIndex(vaultPath, opts) {
636
627
  const dbSlugs = db.prepare('SELECT slug FROM items').all().map(r => r.slug);
637
628
  const fileSlugs = new Set();
638
629
  for (const { dir } of dirs) {
639
- for (const file of scanDir(join(vaultPath, dir))) {
630
+ for (const file of scanVaultDir(join(vaultPath, dir))) {
640
631
  fileSlugs.add(basename(file, '.md'));
641
632
  }
642
633
  }
@@ -652,8 +643,7 @@ export function createIndex(vaultPath, opts) {
652
643
  }
653
644
  }
654
645
  function upsertFromFile(slug, kind) {
655
- const dir = kind === 'entity' ? 'entities' : 'knowledge';
656
- const filePath = join(vaultPath, dir, `${slug}.md`);
646
+ const filePath = join(vaultPath, kindDir(kind), `${slug}.md`);
657
647
  if (existsSync(filePath)) {
658
648
  // Force re-index by clearing cached hash
659
649
  deleteItemStmt.run(slug);
package/dist/digest.js CHANGED
@@ -60,6 +60,8 @@ export function digest(input, existingSlugs, now = new Date()) {
60
60
  // leak into the note body (the schema promises they are omitted).
61
61
  const noteBody = buildSessionNoteBody(input, knownTouched);
62
62
  const related = knownTouched;
63
+ // Typechecked against the Op Spec's session-create schema so digest output
64
+ // can never silently diverge from what batch validation accepts.
63
65
  const noteFields = {
64
66
  synthesis: noteBody,
65
67
  'session-date': input.session_date,