hafez 1.3.0 → 2.0.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 (75) hide show
  1. package/README.md +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. package/skill/SKILL.md +0 -196
@@ -9,4 +9,17 @@ export interface OkfExportReport {
9
9
  }>;
10
10
  unresolvedLinks: number;
11
11
  }
12
+ /** Frontmatter of an exported OKF document. Not vault frontmatter: `name` maps
13
+ * to `title`, `type` is normalized per kind, `timestamp` (and `description`,
14
+ * when the source has none) is derived, `resource` survives only on entities,
15
+ * and the remaining keys are carried through verbatim. */
16
+ export interface OkfFrontmatter {
17
+ type: string;
18
+ title: string;
19
+ description?: string;
20
+ resource?: string;
21
+ tags?: string[];
22
+ timestamp?: string;
23
+ [key: string]: unknown;
24
+ }
12
25
  export declare function exportOkf(vaultPath: string, outDir: string): OkfExportReport;
@@ -8,21 +8,48 @@
8
8
  import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
9
9
  import { join, resolve, sep, basename } from 'path';
10
10
  import { parseFilePath, serializeFile, scanVaultDir, kindDir } from './vault.js';
11
- import { deriveSummary, WIKI_LINK_RE } from './document.js';
11
+ import { DEFAULT_KNOWLEDGE_SUBTYPE } from './contracts.js';
12
+ import { deriveSummary, documentFor, WIKI_LINK_RE } from './document.js';
12
13
  const SOURCE_KINDS = ['entity', 'knowledge', 'session'];
13
14
  const KIND_DIRS = SOURCE_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
15
+ const ENTITY_DOC = documentFor({ kind: 'entity' });
14
16
  // The shared wiki-link syntax (document.ts) — group 1 slug, group 2 display.
15
17
  const OKF_LINK_RE = new RegExp(WIKI_LINK_RE.source, 'g');
18
+ /** `index.md` is reserved in every bundle directory for the generated index, so
19
+ * a vault doc slugged `index` cannot keep its name — before this it was silently
20
+ * overwritten by the index that ran after it. Deterministic rule: rename to
21
+ * `index-doc`, then `index-doc-2`, `index-doc-3`, … until the name is free among
22
+ * that directory's real slugs (a vault may legitimately hold both `index` and
23
+ * `index-doc`). Same vault contents always produce the same bundle names, and
24
+ * every link/index reference is written against the renamed file. */
25
+ const RESERVED_INDEX = 'index';
26
+ function assignBundleNames(docs) {
27
+ const slugsByDir = new Map();
28
+ for (const doc of docs) {
29
+ const slugs = slugsByDir.get(doc.dir) ?? new Set();
30
+ slugs.add(doc.slug);
31
+ slugsByDir.set(doc.dir, slugs);
32
+ }
33
+ for (const doc of docs) {
34
+ if (doc.slug !== RESERVED_INDEX)
35
+ continue;
36
+ const taken = slugsByDir.get(doc.dir);
37
+ let candidate = `${RESERVED_INDEX}-doc`;
38
+ for (let n = 2; taken.has(candidate); n++)
39
+ candidate = `${RESERVED_INDEX}-doc-${n}`;
40
+ doc.file = candidate;
41
+ }
42
+ }
16
43
  function mapFrontmatter(doc) {
17
44
  const { fm, kind, body } = doc;
18
- const mapped = {};
45
+ let type;
19
46
  if (kind === 'entity')
20
- mapped.type = fm.type ?? 'entity';
47
+ type = fm.type ?? 'entity';
21
48
  else if (kind === 'knowledge')
22
- mapped.type = fm.subtype ? `knowledge/${fm.subtype}` : 'knowledge';
49
+ type = `knowledge/${fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}`;
23
50
  else
24
- mapped.type = 'session';
25
- mapped.title = fm.name;
51
+ type = 'session';
52
+ const mapped = { type, title: fm.name };
26
53
  const description = deriveSummary(kind, fm, body);
27
54
  if (description)
28
55
  mapped.description = description;
@@ -95,28 +122,28 @@ export function exportOkf(vaultPath, outDir) {
95
122
  process.stderr.write(`Warning: skipping ${relFile}: missing name\n`);
96
123
  continue;
97
124
  }
98
- docs.push({ slug, dir, kind, fm, body });
125
+ docs.push({ slug, dir, kind, fm, body, file: slug });
99
126
  }
100
127
  }
101
- // Slug→dir map: insertion order entities/knowledge/sessions, first write wins,
102
- // giving entity precedence on cross-kind slug collisions.
103
- const slugDirs = new Map();
128
+ assignBundleNames(docs);
129
+ // Slug→bundle path map: insertion order entities/knowledge/sessions, first write
130
+ // wins, giving entity precedence on cross-kind slug collisions. Paths are
131
+ // bundle-root-absolute with hardcoded '/' separators.
132
+ const slugPaths = new Map();
104
133
  for (const doc of docs) {
105
- if (!slugDirs.has(doc.slug))
106
- slugDirs.set(doc.slug, doc.dir);
134
+ if (!slugPaths.has(doc.slug))
135
+ slugPaths.set(doc.slug, `/${doc.dir}/${doc.file}.md`);
107
136
  }
108
137
  function transformBody(body) {
109
138
  return body.replace(OKF_LINK_RE, (_m, rawSlug, rawDisplay) => {
110
- // Trim: Obsidian-idiomatic [[ foo ]] / [[foo | Label]] carry whitespace in the captures
111
139
  const slug = rawSlug.trim();
112
140
  const label = rawDisplay?.trim() || slug;
113
- const dir = slugDirs.get(slug);
114
- if (!dir) {
141
+ const path = slugPaths.get(slug);
142
+ if (!path) {
115
143
  report.unresolvedLinks++;
116
144
  return label;
117
145
  }
118
- // Bundle-root-absolute path, hardcoded '/' separators
119
- return `[${label}](/${dir}/${slug}.md)`;
146
+ return `[${label}](${path})`;
120
147
  });
121
148
  }
122
149
  mkdirSync(resolvedOut, { recursive: true });
@@ -125,7 +152,10 @@ export function exportOkf(vaultPath, outDir) {
125
152
  const mapped = mapFrontmatter(doc);
126
153
  const outSubdir = join(resolvedOut, doc.dir);
127
154
  mkdirSync(outSubdir, { recursive: true });
128
- writeFileSync(join(outSubdir, `${doc.slug}.md`), serializeFile(mapped, transformBody(doc.body)));
155
+ // The added-date stamp is vault file format, not action text: exported
156
+ // Next Actions carry the unstamped text.
157
+ const body = doc.kind === 'entity' ? ENTITY_DOC.stripNextActionStamps(doc.body) : doc.body;
158
+ writeFileSync(join(outSubdir, `${doc.file}.md`), serializeFile(mapped, transformBody(body)));
129
159
  if (doc.kind === 'entity')
130
160
  report.entities++;
131
161
  else if (doc.kind === 'knowledge')
@@ -133,7 +163,7 @@ export function exportOkf(vaultPath, outDir) {
133
163
  else
134
164
  report.sessions++;
135
165
  const list = byDir.get(doc.dir) ?? [];
136
- list.push({ slug: doc.slug, title: mapped.title, description: mapped.description ?? '' });
166
+ list.push({ file: doc.file, title: mapped.title, description: mapped.description ?? '' });
137
167
  byDir.set(doc.dir, list);
138
168
  }
139
169
  // Per-dir index.md — reserved file: NO frontmatter, sorted bullets
@@ -142,7 +172,7 @@ export function exportOkf(vaultPath, outDir) {
142
172
  const lines = [`# ${heading}`, ''];
143
173
  for (const item of items.sort((a, b) => a.title.localeCompare(b.title))) {
144
174
  const desc = item.description ? ` — ${item.description}` : '';
145
- lines.push(`- [${item.title}](/${dir}/${item.slug}.md)${desc}`);
175
+ lines.push(`- [${item.title}](/${dir}/${item.file}.md)${desc}`);
146
176
  }
147
177
  writeFileSync(join(resolvedOut, dir, 'index.md'), lines.join('\n') + '\n');
148
178
  }
package/dist/git.js CHANGED
@@ -3,7 +3,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { simpleGit } from 'simple-git';
5
5
  import { mergeVaultContent } from './merge.js';
6
- import { VAULT_FILE_RE, INDEXED_DIR_PATHSPECS, classifyIndexedRelPath } from './vault.js';
6
+ import { VAULT_FILE_RE, INDEXED_DIR_PATHSPECS, classifyIndexedRelPath, vaultKindFromRelPath } from './vault.js';
7
+ import { normalizeSinceDate } from './dates.js';
7
8
  import { HafezError } from './types.js';
8
9
  const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
9
10
  const MAX_PUSH_RETRIES = 3;
@@ -50,6 +51,61 @@ async function hasOriginRemote(git) {
50
51
  const remotes = await git.getRemotes();
51
52
  return remotes.some(r => r.name === 'origin');
52
53
  }
54
+ /** The branch this vault is on (symbolic HEAD). */
55
+ async function currentBranch(git) {
56
+ return (await git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
57
+ }
58
+ /** The branch's configured upstream (`origin/main`), or null when it has none. */
59
+ async function upstreamRef(git) {
60
+ try {
61
+ const ref = (await git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim();
62
+ return ref || null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Give the current branch an upstream when git does not know about one yet.
70
+ *
71
+ * `git remote add origin …` on a vault that has only ever been local leaves
72
+ * the branch both unpublished and untracked, and in that state `git pull`,
73
+ * `git push` and `@{u}` are all fatal — so every mutation after the remote
74
+ * was added died at the sync/push stage. Returns false when origin does not
75
+ * carry the branch at all: there is nothing to pull and nothing to track
76
+ * until the first push creates it (see pushCurrent).
77
+ *
78
+ * A remote we cannot reach also reports false — the caller's push, and its
79
+ * retry loop, is where a network failure belongs.
80
+ */
81
+ async function ensureUpstream(git) {
82
+ if (await upstreamRef(git))
83
+ return true;
84
+ try {
85
+ const branch = await currentBranch(git);
86
+ const heads = await git.raw(['ls-remote', '--heads', 'origin', branch]);
87
+ if (!heads.trim())
88
+ return false;
89
+ await git.fetch('origin', branch);
90
+ await git.raw(['branch', `--set-upstream-to=origin/${branch}`, branch]);
91
+ return true;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ /**
98
+ * Push the current branch, publishing it when it has no upstream yet. That
99
+ * first `push --set-upstream` is what turns a freshly-added origin into a
100
+ * normal tracking setup; every push after it is the plain push.
101
+ */
102
+ async function pushCurrent(git) {
103
+ if (await upstreamRef(git)) {
104
+ await git.push();
105
+ return;
106
+ }
107
+ await git.push(['--set-upstream', 'origin', await currentBranch(git)]);
108
+ }
53
109
  /** Read one side of a conflicted file from the index; null if that side deleted it. */
54
110
  async function showStage(git, stage, file) {
55
111
  try {
@@ -104,9 +160,12 @@ async function resolveRebaseConflicts(git, vaultPath) {
104
160
  await git.raw(['rm', '--force', '--ignore-unmatch', '--', file]);
105
161
  continue;
106
162
  }
163
+ // Classify from the repo-relative path: every file here passed
164
+ // VAULT_FILE_RE, so the Layout classifier is total over it — no
165
+ // fallback kind to drift when a vault dir is added.
107
166
  const merged = localContent === null ? remoteContent
108
167
  : remoteContent === null ? localContent
109
- : mergeVaultContent(remoteContent, localContent);
168
+ : mergeVaultContent(remoteContent, localContent, vaultKindFromRelPath(file));
110
169
  const fullPath = join(vaultPath, file);
111
170
  mkdirSync(dirname(fullPath), { recursive: true });
112
171
  writeFileSync(fullPath, merged);
@@ -188,10 +247,13 @@ async function pullResolving(git, vaultPath, conflictMessage) {
188
247
  * local data survives a conflict.
189
248
  */
190
249
  async function pushResolving(git, vaultPath, conflictMessage, exhaustedMessage) {
250
+ // Bootstrap a freshly-added origin before the retry loop: without an
251
+ // upstream the rejection handling below (pull --rebase) is itself fatal.
252
+ await ensureUpstream(git);
191
253
  let lastDetail = '';
192
254
  for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
193
255
  try {
194
- await git.push();
256
+ await pushCurrent(git);
195
257
  return;
196
258
  }
197
259
  catch (pushErr) {
@@ -226,29 +288,36 @@ export async function gitSync(vaultPath) {
226
288
  await ensureCleanState(git, vaultPath);
227
289
  const syncConflictMessage = (files) => `Git conflict during sync (files: ${files.join(', ')}). Changes saved locally.`;
228
290
  let pulled = false;
229
- // Always pull first (bidirectional sync)
230
- const headBefore = (await git.revparse(['HEAD'])).trim();
231
- for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
232
- try {
233
- await pullResolving(git, vaultPath, syncConflictMessage);
234
- const headAfter = (await git.revparse(['HEAD'])).trim();
235
- pulled = headAfter !== headBefore;
236
- break;
237
- }
238
- catch (err) {
239
- if (err instanceof HafezError)
240
- throw err;
241
- if (attempt === MAX_PUSH_RETRIES) {
242
- const detail = err instanceof Error ? err.message : String(err);
243
- throw new HafezError('GIT_PUSH_FAILED', `Git pull failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`, [detail]);
291
+ // A remote that does not carry this branch yet (freshly added to a vault
292
+ // that has only ever been local) has nothing to pull and no `@{u}` to count
293
+ // against — both steps would be fatal. Skip straight to the push, which
294
+ // publishes the branch and sets the upstream.
295
+ const tracking = await ensureUpstream(git);
296
+ if (tracking) {
297
+ // Always pull first (bidirectional sync)
298
+ const headBefore = (await git.revparse(['HEAD'])).trim();
299
+ for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) {
300
+ try {
301
+ await pullResolving(git, vaultPath, syncConflictMessage);
302
+ const headAfter = (await git.revparse(['HEAD'])).trim();
303
+ pulled = headAfter !== headBefore;
304
+ break;
305
+ }
306
+ catch (err) {
307
+ if (err instanceof HafezError)
308
+ throw err;
309
+ if (attempt === MAX_PUSH_RETRIES) {
310
+ const detail = err instanceof Error ? err.message : String(err);
311
+ throw new HafezError('GIT_PUSH_FAILED', `Git pull failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`, [detail]);
312
+ }
313
+ await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
244
314
  }
245
- await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
246
315
  }
316
+ // Check if there are local commits to push
317
+ const ahead = await git.raw(['rev-list', '--count', '@{u}..HEAD']).then(s => parseInt(s.trim(), 10));
318
+ if (ahead === 0)
319
+ return { pulled, pushed: false, remote: true };
247
320
  }
248
- // Check if there are local commits to push
249
- const ahead = await git.raw(['rev-list', '--count', '@{u}..HEAD']).then(s => parseInt(s.trim(), 10));
250
- if (ahead === 0)
251
- return { pulled, pushed: false, remote: true };
252
321
  await pushResolving(git, vaultPath, syncConflictMessage, `Git push failed after ${MAX_PUSH_RETRIES} attempts during sync. Changes saved locally.`);
253
322
  return { pulled, pushed: true, remote: true };
254
323
  }
@@ -265,6 +334,23 @@ async function gitUnstage(vaultPath, files) {
265
334
  }
266
335
  catch { /* best effort — rollback must not mask the original error */ }
267
336
  }
337
+ /**
338
+ * The subset of `files` that `git add` can actually stage. `git add` is fatal
339
+ * on a pathspec matching nothing, and a batch that creates then deletes the
340
+ * same slug (capture then cross-kind promote) hands the Journal a deleted path
341
+ * that is absent from the worktree AND was never tracked — unstageable by
342
+ * construction, and it needs no staging: git never knew about it. A deleted
343
+ * path git DOES track still goes through `add`, which stages its removal.
344
+ */
345
+ async function stageablePaths(git, vaultPath, files) {
346
+ const absent = files.filter(f => !existsSync(join(vaultPath, f)));
347
+ if (absent.length === 0)
348
+ return files;
349
+ // -z: NUL-separated, so git never quotes/escapes the paths it echoes back.
350
+ const tracked = new Set((await git.raw(['ls-files', '-z', '--', ...absent])).split('\0').filter(Boolean));
351
+ const absentSet = new Set(absent);
352
+ return files.filter(f => !absentSet.has(f) || tracked.has(f));
353
+ }
268
354
  /** Get list of files with unresolved conflicts */
269
355
  async function getConflictingFiles(git) {
270
356
  try {
@@ -280,13 +366,35 @@ const FILE_STATUS_MAP = {
280
366
  M: 'updated',
281
367
  D: 'deleted',
282
368
  };
369
+ /**
370
+ * One `--name-status` line → the (path, operation) pairs it stands for.
371
+ *
372
+ * Rename and copy lines carry a similarity score and TWO paths
373
+ * (`R100\told\tnew`), so they match neither the single-path shape nor a
374
+ * bare status letter. A promote moves entities/<slug>.md to
375
+ * knowledge/<slug>.md, which git reports exactly that way — dropping the
376
+ * line made every promote invisible in the changelog. A rename reads as the
377
+ * old path disappearing and the new one appearing; a copy leaves the source
378
+ * in place, so only the destination is news.
379
+ */
380
+ function parseNameStatusLine(line) {
381
+ const moveMatch = line.match(/^([RC])\d*\t([^\t]+)\t(.+)$/);
382
+ if (moveMatch) {
383
+ const [, statusChar, from, to] = moveMatch;
384
+ const dest = [{ path: to, operation: 'created' }];
385
+ return statusChar === 'C' ? dest : [{ path: from, operation: 'deleted' }, ...dest];
386
+ }
387
+ const match = line.match(/^([A-Z])\t(.+)$/);
388
+ if (!match)
389
+ return [];
390
+ const operation = FILE_STATUS_MAP[match[1]];
391
+ return operation ? [{ path: match[2], operation }] : [];
392
+ }
283
393
  export async function gitChangelog(vaultPath, since) {
284
394
  const git = simpleGit(vaultPath);
285
- // git approxidate fills a date-only --since with the *current* time of
286
- // day, so `--since <today>` means "since right now" and silently returns
287
- // nothing — the obvious "what happened today" query was the one that
288
- // failed. Normalise bare dates to local midnight.
289
- const normalizedSince = /^\d{4}-\d{2}-\d{2}$/.test(since) ? `${since}T00:00:00` : since;
395
+ // Bare dates become local midnight — the same calendar-day boundary the
396
+ // vault stamps and query comparisons use (see dates.ts).
397
+ const normalizedSince = normalizeSinceDate(since);
290
398
  let output;
291
399
  try {
292
400
  output = await git.raw([
@@ -310,21 +418,16 @@ export async function gitChangelog(vaultPath, since) {
310
418
  continue;
311
419
  const [, , timestamp, commit_message] = headerMatch;
312
420
  for (let i = 1; i < lines.length; i++) {
313
- const fileMatch = lines[i].match(/^([AMDRC])\t(.+)$/);
314
- if (!fileMatch)
315
- continue;
316
- const [, statusChar, filePath] = fileMatch;
317
- const operation = FILE_STATUS_MAP[statusChar];
318
- if (!operation)
319
- continue;
320
- // Only vault documents — scaffold files (.gitkeep) live in the same
321
- // dirs and would otherwise surface as changelog rows.
322
- if (!filePath.endsWith('.md'))
323
- continue;
324
- const classified = classifyIndexedRelPath(filePath);
325
- if (!classified)
326
- continue;
327
- entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message });
421
+ for (const { path: filePath, operation } of parseNameStatusLine(lines[i])) {
422
+ // Only vault documents — scaffold files (.gitkeep) live in the same
423
+ // dirs and would otherwise surface as changelog rows.
424
+ if (!filePath.endsWith('.md'))
425
+ continue;
426
+ const classified = classifyIndexedRelPath(filePath);
427
+ if (!classified)
428
+ continue;
429
+ entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message });
430
+ }
328
431
  }
329
432
  }
330
433
  return entries;
@@ -377,10 +480,19 @@ export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
377
480
  writeFileSync(fullPath, content);
378
481
  }
379
482
  }
380
- await git.add(files);
381
- // Handle "nothing to commit" (e.g., idempotent update wrote identical content)
382
- const status = await git.status();
383
- if (status.staged.length === 0)
483
+ const stageable = await stageablePaths(git, vaultPath, files);
484
+ if (stageable.length === 0)
485
+ return;
486
+ await git.add(stageable);
487
+ // Handle "nothing to commit" (e.g., idempotent update wrote identical
488
+ // content). Ask the index directly rather than via simple-git's status:
489
+ // its `staged` list omits renamed paths (they go to `renamed` only), so a
490
+ // promote git detected as a rename read as "nothing to commit" and was
491
+ // silently dropped — left staged, uncommitted, absent from the changelog.
492
+ // Same output-based protocol the rebase loop uses, and it is well-defined
493
+ // against an unborn HEAD (diffs the empty tree).
494
+ const stagedPaths = (await git.raw(['diff', '--cached', '--name-only'])).trim();
495
+ if (stagedPaths.length === 0)
384
496
  return;
385
497
  const committed = await git.commit(message);
386
498
  // simple-git swallows some commit failures (e.g. a rejected pre-commit
@@ -415,7 +527,12 @@ export function createGitJournal(vaultPath, config = {}) {
415
527
  if (!(err instanceof HafezError && err.code === 'GIT_PUSH_FAILED')) {
416
528
  await gitUnstage(vaultPath, files);
417
529
  }
418
- throw err;
530
+ // The Journal is the seam: git's own error types must not escape it,
531
+ // or callers (and the CLI exit-code table) see an unclassified failure.
532
+ if (err instanceof HafezError)
533
+ throw err;
534
+ const detail = err instanceof Error ? err.message : String(err);
535
+ throw new HafezError('GIT_COMMIT_FAILED', `git commit failed for: ${files.join(', ')}`, [detail]);
419
536
  }
420
537
  },
421
538
  sync: () => gitSync(vaultPath),
@@ -0,0 +1,58 @@
1
+ /** Recorded when no Harness is detectable. */
2
+ export declare const NEUTRAL_AGENT = "agent";
3
+ export declare function detectAgent(env?: Record<string, string | undefined>): string;
4
+ /** Every Harness `onboard` can configure, and where its always-loaded
5
+ * user-level instructions file lives, relative to the home directory (harness
6
+ * research, docs/research/2026-09-29-harness-instruction-surfaces.md): the
7
+ * first candidate that exists, else the last. Cursor keeps its User Rules in a
8
+ * Settings UI, not a file: `null`, and the Block is printed for manual paste.
9
+ * `dir` is the config directory whose presence says the Harness is installed. */
10
+ export declare const HARNESSES: {
11
+ readonly 'claude-code': {
12
+ readonly label: "Claude Code";
13
+ readonly dir: ".claude";
14
+ readonly instructions: readonly [".claude/CLAUDE.md"];
15
+ };
16
+ readonly codex: {
17
+ readonly label: "Codex";
18
+ readonly dir: ".codex";
19
+ readonly instructions: readonly [".codex/AGENTS.override.md", ".codex/AGENTS.md"];
20
+ };
21
+ readonly cursor: {
22
+ readonly label: "Cursor";
23
+ readonly dir: ".cursor";
24
+ readonly instructions: null;
25
+ };
26
+ readonly gemini: {
27
+ readonly label: "Gemini CLI";
28
+ readonly dir: ".gemini";
29
+ readonly instructions: readonly [".gemini/GEMINI.md"];
30
+ };
31
+ readonly copilot: {
32
+ readonly label: "Copilot CLI";
33
+ readonly dir: ".copilot";
34
+ readonly instructions: readonly [".copilot/copilot-instructions.md"];
35
+ };
36
+ };
37
+ export type HarnessId = keyof typeof HARNESSES;
38
+ /** The Harnesses bare `onboard` sets up from inside — the ones detected from
39
+ * the environment — and, relative to the home directory, where each keeps
40
+ * what the agent applies: the SessionStart hook and the permission entries.
41
+ * `keystroke` invokes the `hafez-save` skill there. */
42
+ export declare const ONBOARD_HARNESSES: {
43
+ readonly 'claude-code': {
44
+ readonly keystroke: "/hafez-save";
45
+ readonly hooks: ".claude/settings.json";
46
+ readonly allowEntry: "Bash(hafez:*)";
47
+ };
48
+ readonly codex: {
49
+ readonly keystroke: "$hafez-save";
50
+ readonly hooks: ".codex/hooks.json";
51
+ readonly rules: ".codex/rules/default.rules";
52
+ readonly rule: "prefix_rule(pattern = [\"hafez\"], decision = \"allow\")";
53
+ readonly config: ".codex/config.toml";
54
+ };
55
+ };
56
+ export type OnboardHarness = keyof typeof ONBOARD_HARNESSES;
57
+ export declare const isOnboardHarness: (value: string) => value is OnboardHarness;
58
+ export declare const isHarness: (value: string) => value is HarnessId;
@@ -0,0 +1,50 @@
1
+ // src/harness.ts
2
+ // Which Harness is running this command, read from the environment. The one
3
+ // helper the Apply update arm, the CLI's --log and `digest` call to name the
4
+ // agent in a session log, and `onboard` calls to pick the Harness it sets up.
5
+ // Also where each Harness keeps its instructions file.
6
+ /** Recorded when no Harness is detectable. */
7
+ export const NEUTRAL_AGENT = 'agent';
8
+ /** Variables Codex sets on the commands it runs. CODEX_HOME and the Claude
9
+ * Code plugin's CODEX_COMPANION_* are deliberately absent: both can be set in
10
+ * a shell Codex is not running. */
11
+ const CODEX_VARS = ['CODEX_THREAD_ID', 'CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED'];
12
+ export function detectAgent(env = process.env) {
13
+ const set = (key) => Boolean(env[key]);
14
+ // Codex first: Codex started from inside Claude Code inherits CLAUDECODE,
15
+ // and the innermost Harness is the one running the command.
16
+ if (CODEX_VARS.some(set))
17
+ return 'codex';
18
+ if (set('CLAUDECODE'))
19
+ return 'claude-code';
20
+ return NEUTRAL_AGENT;
21
+ }
22
+ /** Every Harness `onboard` can configure, and where its always-loaded
23
+ * user-level instructions file lives, relative to the home directory (harness
24
+ * research, docs/research/2026-09-29-harness-instruction-surfaces.md): the
25
+ * first candidate that exists, else the last. Cursor keeps its User Rules in a
26
+ * Settings UI, not a file: `null`, and the Block is printed for manual paste.
27
+ * `dir` is the config directory whose presence says the Harness is installed. */
28
+ export const HARNESSES = {
29
+ 'claude-code': { label: 'Claude Code', dir: '.claude', instructions: ['.claude/CLAUDE.md'] },
30
+ codex: { label: 'Codex', dir: '.codex', instructions: ['.codex/AGENTS.override.md', '.codex/AGENTS.md'] },
31
+ cursor: { label: 'Cursor', dir: '.cursor', instructions: null },
32
+ gemini: { label: 'Gemini CLI', dir: '.gemini', instructions: ['.gemini/GEMINI.md'] },
33
+ copilot: { label: 'Copilot CLI', dir: '.copilot', instructions: ['.copilot/copilot-instructions.md'] },
34
+ };
35
+ /** The Harnesses bare `onboard` sets up from inside — the ones detected from
36
+ * the environment — and, relative to the home directory, where each keeps
37
+ * what the agent applies: the SessionStart hook and the permission entries.
38
+ * `keystroke` invokes the `hafez-save` skill there. */
39
+ export const ONBOARD_HARNESSES = {
40
+ 'claude-code': { keystroke: '/hafez-save', hooks: '.claude/settings.json', allowEntry: 'Bash(hafez:*)' },
41
+ codex: {
42
+ keystroke: '$hafez-save',
43
+ hooks: '.codex/hooks.json',
44
+ rules: '.codex/rules/default.rules',
45
+ rule: 'prefix_rule(pattern = ["hafez"], decision = "allow")',
46
+ config: '.codex/config.toml',
47
+ },
48
+ };
49
+ export const isOnboardHarness = (value) => Object.hasOwn(ONBOARD_HARNESSES, value);
50
+ export const isHarness = (value) => Object.hasOwn(HARNESSES, value);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Hafez, HafezConfig } from './types.js';
2
2
  export declare function createHafez(config: HafezConfig): Hafez;
3
3
  export { HafezError } from './types.js';
4
- export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
5
- export { getBrief, getNextActions, findSection } from './document.js';
4
+ export type { Hafez, HafezConfig, HafezErrorCode, EntityType, EntityStatus, EntityQueryOpts, KnowledgeQueryOpts, UnifiedQueryOpts, AllQueryOpts, SortField, EntitySortField, EntityFrontmatter, KnowledgeFrontmatter, ParsedFile, SessionLogEntry, UpdateFields, UpdateResult, CreateEntityFields, CreateKnowledgeFields, QueryFilter, QueryResult, KnowledgeQueryResult, UnifiedResult, ValidationReport, BatchOperation, BatchResult, ReadDepth, ConfidenceLevel, LinkRelation, GitConfig, Journal, SearchResult, VaultStats, ChangelogEntry, KnowledgeSubtype } from './types.js';
5
+ export { getBrief, getNextActions } from './document.js';
6
6
  export { parseSessionLogHeading, parseSessionLog } from './document.js';