hafez 2.1.0 → 2.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.
Files changed (75) hide show
  1. package/README.md +72 -5
  2. package/dist/apply.d.ts +14 -1
  3. package/dist/apply.js +94 -32
  4. package/dist/batch-ops.d.ts +31 -27
  5. package/dist/batch-ops.js +83 -16
  6. package/dist/cli/block-hook.d.ts +6 -0
  7. package/dist/cli/block-hook.js +27 -12
  8. package/dist/cli/commands.d.ts +3 -1
  9. package/dist/cli/commands.js +183 -73
  10. package/dist/cli/format.d.ts +11 -3
  11. package/dist/cli/format.js +61 -4
  12. package/dist/cli/index.js +40 -19
  13. package/dist/cli/install.d.ts +9 -0
  14. package/dist/cli/install.js +36 -15
  15. package/dist/cli/onboard.js +30 -9
  16. package/dist/cli/upgrade.js +5 -4
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.js +9 -0
  19. package/dist/dates.d.ts +7 -0
  20. package/dist/dates.js +21 -1
  21. package/dist/db.d.ts +50 -10
  22. package/dist/db.js +251 -65
  23. package/dist/digest.js +14 -7
  24. package/dist/document.d.ts +2 -0
  25. package/dist/document.js +2 -2
  26. package/dist/git.d.ts +13 -2
  27. package/dist/git.js +121 -14
  28. package/dist/graph/activity.d.ts +5 -0
  29. package/dist/graph/activity.js +70 -0
  30. package/dist/graph/client.js +326 -0
  31. package/dist/graph/copy-fonts.d.ts +1 -0
  32. package/dist/graph/copy-fonts.js +13 -0
  33. package/dist/graph/fields.d.ts +8 -0
  34. package/dist/graph/fields.js +12 -0
  35. package/dist/graph/fonts/geist-OFL.txt +93 -0
  36. package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
  37. package/dist/graph/fonts/geist-mono.woff2 +0 -0
  38. package/dist/graph/fonts/geist.woff2 +0 -0
  39. package/dist/graph/fonts/outfit-OFL.txt +93 -0
  40. package/dist/graph/fonts/outfit.woff2 +0 -0
  41. package/dist/graph/fonts.d.ts +14 -0
  42. package/dist/graph/fonts.js +17 -0
  43. package/dist/graph/live.d.ts +16 -0
  44. package/dist/graph/live.js +99 -0
  45. package/dist/graph/open.d.ts +1 -0
  46. package/dist/graph/open.js +20 -0
  47. package/dist/graph/server.d.ts +17 -0
  48. package/dist/graph/server.js +189 -0
  49. package/dist/health.d.ts +6 -0
  50. package/dist/health.js +310 -0
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +66 -18
  53. package/dist/internal.d.ts +21 -0
  54. package/dist/internal.js +9 -0
  55. package/dist/knowledge-index.d.ts +1 -1
  56. package/dist/read-json.d.ts +26 -0
  57. package/dist/read-json.js +53 -0
  58. package/dist/schema.d.ts +2 -0
  59. package/dist/schema.js +11 -0
  60. package/dist/stale.d.ts +2 -2
  61. package/dist/stale.js +4 -9
  62. package/dist/surfaces/items.d.ts +100 -1
  63. package/dist/surfaces/items.js +148 -10
  64. package/dist/surfaces/surfaces.d.ts +8 -5
  65. package/dist/surfaces/surfaces.js +34 -17
  66. package/dist/templates.d.ts +2 -0
  67. package/dist/templates.js +6 -1
  68. package/dist/types.d.ts +239 -25
  69. package/dist/validate.js +44 -56
  70. package/dist/vault.d.ts +29 -9
  71. package/dist/vault.js +39 -8
  72. package/dist/workstream.d.ts +17 -0
  73. package/dist/workstream.js +78 -0
  74. package/package.json +10 -4
  75. package/skill/hafez/SKILL.md +16 -7
package/dist/git.js CHANGED
@@ -6,6 +6,49 @@ import { mergeVaultContent } from './merge.js';
6
6
  import { VAULT_FILE_RE, INDEXED_DIR_PATHSPECS, classifyIndexedRelPath, vaultKindFromRelPath } from './vault.js';
7
7
  import { normalizeSinceDate } from './dates.js';
8
8
  import { HafezError } from './types.js';
9
+ // Hafez runs git in the user's own shell, and git inherited that environment before simple-git
10
+ // guarded it. simple-git 4 guards it two ways, and both are opened in full: `allowEnvironment`
11
+ // names every key the process carries (the guard otherwise strips ambient GIT_* / EDITOR / PAGER
12
+ // keys and rejects supplied ones — a user's GIT_SSH_COMMAND or GIT_AUTHOR_* would vanish), and
13
+ // `unsafe` opens every category — `Required`, so a category the library adds fails the build
14
+ // instead of rejecting some user's shell at runtime (4.0 moved GIT_EXEC_PATH to allowUnsafeExec).
15
+ const OPEN_UNSAFE = {
16
+ allowUnsafeAlias: true,
17
+ allowUnsafeAskPass: true,
18
+ allowUnsafeCommandBinaries: true,
19
+ allowUnsafeConfigEnvCount: true,
20
+ allowUnsafeConfigPaths: true,
21
+ allowUnsafeCredentialHelper: true,
22
+ allowUnsafeDiffExternal: true,
23
+ allowUnsafeDiffTextConv: true,
24
+ allowUnsafeEditor: true,
25
+ allowUnsafeExec: true,
26
+ allowUnsafeFilter: true,
27
+ allowUnsafeFsMonitor: true,
28
+ allowUnsafeGitProxy: true,
29
+ allowUnsafeGpgProgram: true,
30
+ allowUnsafeHooksPath: true,
31
+ allowUnsafeInclude: true,
32
+ allowUnsafeMergeDriver: true,
33
+ allowUnsafePack: true,
34
+ allowUnsafePager: true,
35
+ allowUnsafeProtocolOverride: true,
36
+ allowUnsafeSshCommand: true,
37
+ allowUnsafeSubmodule: true,
38
+ allowUnsafeTemplateDir: true,
39
+ allowUnsafeUrlRewrite: true,
40
+ allowUnsafeCustomBinary: true,
41
+ // Otherwise the plugin exports GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS to the child and the
42
+ // vault's own hooks start failing on abbreviated long options only when hafez commits.
43
+ allowAbbreviatedOptions: true
44
+ };
45
+ // Hafez's own git calls are never interactive: GIT_EDITOR outranks core.editor, -c, VISUAL and
46
+ // EDITOR. `.env()` replaces the child env, hence the spread.
47
+ const vaultGit = (vaultPath) => simpleGit({
48
+ baseDir: vaultPath,
49
+ allowEnvironment: [...Object.keys(process.env), 'GIT_EDITOR'],
50
+ unsafe: OPEN_UNSAFE
51
+ }).env({ ...process.env, GIT_EDITOR: 'true' });
9
52
  const logMerge = (msg) => process.stderr.write(`[hafez-merge] ${msg}\n`);
10
53
  const MAX_PUSH_RETRIES = 3;
11
54
  const RETRY_BASE_MS = 200;
@@ -279,7 +322,7 @@ async function pushResolving(git, vaultPath, conflictMessage, exhaustedMessage)
279
322
  * All work must be committed before calling — no file save/restore needed.
280
323
  */
281
324
  export async function gitSync(vaultPath) {
282
- const git = simpleGit(vaultPath);
325
+ const git = vaultGit(vaultPath);
283
326
  if (!(await hasOriginRemote(git))) {
284
327
  return { pulled: false, pushed: false, remote: false };
285
328
  }
@@ -330,7 +373,7 @@ export async function gitSync(vaultPath) {
330
373
  */
331
374
  async function gitUnstage(vaultPath, files) {
332
375
  try {
333
- await simpleGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
376
+ await vaultGit(vaultPath).raw(['reset', '-q', 'HEAD', '--', ...files]);
334
377
  }
335
378
  catch { /* best effort — rollback must not mask the original error */ }
336
379
  }
@@ -390,20 +433,40 @@ function parseNameStatusLine(line) {
390
433
  const operation = FILE_STATUS_MAP[match[1]];
391
434
  return operation ? [{ path: match[2], operation }] : [];
392
435
  }
393
- export async function gitChangelog(vaultPath, since) {
394
- const git = simpleGit(vaultPath);
436
+ /**
437
+ * Read-side "no history": the one state where `git log` has nothing to say
438
+ * and empty is the true answer — a repo with no commits yet. Everything else
439
+ * (a directory that is not a repository, an env-guard or unsafe-category
440
+ * rejection, a missing git binary, a corrupt repo) is a failure the write
441
+ * side already reports loudly, so the read side must not hide it. Matched on
442
+ * git's stderr text, which simple-git carries in `GitError.message`; both
443
+ * callers pass a `--` pathspec and no revision, and an unborn HEAD then says
444
+ * exactly this.
445
+ */
446
+ const NO_HISTORY_RE = /does not have any commits yet/i;
447
+ function isNoHistory(err) {
448
+ return NO_HISTORY_RE.test(err instanceof Error ? err.message : String(err));
449
+ }
450
+ function gitReadFailed(err) {
451
+ const lines = (err instanceof Error ? err.message : String(err)).split('\n').map(l => l.trim()).filter(Boolean);
452
+ return new HafezError('GIT_READ_FAILED', `git read failed: ${lines[0] ?? 'unknown error'}`, lines);
453
+ }
454
+ export async function gitChangelog(vaultPath, since, limit) {
455
+ const git = vaultGit(vaultPath);
395
456
  // Bare dates become local midnight — the same calendar-day boundary the
396
457
  // vault stamps and query comparisons use (see dates.ts).
397
458
  const normalizedSince = normalizeSinceDate(since);
398
459
  let output;
399
460
  try {
400
461
  output = await git.raw([
401
- 'log', `--since=${normalizedSince}`, '--name-status',
462
+ 'log', ...(since ? [`--since=${normalizedSince}`] : []), ...(limit === undefined ? [] : [`--max-count=${limit}`]), '--name-status',
402
463
  '--pretty=format:%H|%aI|%s', '--', ...INDEXED_DIR_PATHSPECS,
403
464
  ]);
404
465
  }
405
- catch {
406
- return []; // No git history or invalid since — return empty
466
+ catch (err) {
467
+ if (isNoHistory(err))
468
+ return [];
469
+ throw gitReadFailed(err);
407
470
  }
408
471
  if (!output.trim())
409
472
  return [];
@@ -416,7 +479,7 @@ export async function gitChangelog(vaultPath, since) {
416
479
  const headerMatch = lines[0].match(/^([0-9a-f]+)\|(.+?)\|(.*)$/);
417
480
  if (!headerMatch)
418
481
  continue;
419
- const [, , timestamp, commit_message] = headerMatch;
482
+ const [, commit, timestamp, commit_message] = headerMatch;
420
483
  for (let i = 1; i < lines.length; i++) {
421
484
  for (const { path: filePath, operation } of parseNameStatusLine(lines[i])) {
422
485
  // Only vault documents — scaffold files (.gitkeep) live in the same
@@ -426,7 +489,7 @@ export async function gitChangelog(vaultPath, since) {
426
489
  const classified = classifyIndexedRelPath(filePath);
427
490
  if (!classified)
428
491
  continue;
429
- entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message });
492
+ entries.push({ slug: classified.slug, kind: classified.kind, operation, timestamp, commit_message, commit });
430
493
  }
431
494
  }
432
495
  }
@@ -439,7 +502,7 @@ export async function gitChangelog(vaultPath, since) {
439
502
  * Files with no history (not yet committed) are absent from the map.
440
503
  */
441
504
  export async function gitFileTimes(vaultPath, mode) {
442
- const git = simpleGit(vaultPath);
505
+ const git = vaultGit(vaultPath);
443
506
  let output;
444
507
  try {
445
508
  const args = ['log', '--pretty=format:%x00%aI', '--name-only', '--', ...INDEXED_DIR_PATHSPECS];
@@ -447,8 +510,10 @@ export async function gitFileTimes(vaultPath, mode) {
447
510
  args.splice(1, 0, '--diff-filter=A');
448
511
  output = await git.raw(args);
449
512
  }
450
- catch {
451
- return new Map(); // no git history
513
+ catch (err) {
514
+ if (isNoHistory(err))
515
+ return new Map();
516
+ throw gitReadFailed(err);
452
517
  }
453
518
  const times = new Map();
454
519
  let current = '';
@@ -463,8 +528,49 @@ export async function gitFileTimes(vaultPath, mode) {
463
528
  }
464
529
  return times;
465
530
  }
531
+ const CHANGE_OPS = { created: 'create', updated: 'update', deleted: 'delete' };
532
+ /**
533
+ * The Journal's changesSince: HEAD as the cursor, and the indexed-dir files
534
+ * whose content differs between `from` and HEAD. A diff between two points,
535
+ * not a walk of the commits in between, so the status is the net one: a file
536
+ * made and removed inside the span is not there. core.quotePath off keeps
537
+ * non-ASCII paths (legitimate slugs) unquoted.
538
+ */
539
+ export async function gitChangesSince(vaultPath, from) {
540
+ const git = vaultGit(vaultPath);
541
+ let cursor;
542
+ try {
543
+ cursor = (await git.raw(['log', '-1', '--format=%H'])).trim();
544
+ }
545
+ catch (err) {
546
+ if (isNoHistory(err))
547
+ return { cursor: '', files: [] };
548
+ throw gitReadFailed(err);
549
+ }
550
+ if (from === undefined || from === cursor)
551
+ return { cursor, files: [] };
552
+ const files = [];
553
+ try {
554
+ if (from === '') {
555
+ const listed = await git.raw(['-c', 'core.quotePath=false', 'ls-tree', '-r', '--name-only', cursor, '--', ...INDEXED_DIR_PATHSPECS]);
556
+ for (const path of listed.split('\n').filter(Boolean))
557
+ files.push({ path, op: 'create' });
558
+ }
559
+ else {
560
+ const diff = await git.raw(['-c', 'core.quotePath=false', 'diff', '--name-status', from, cursor, '--', ...INDEXED_DIR_PATHSPECS]);
561
+ for (const line of diff.split('\n')) {
562
+ for (const { path, operation } of parseNameStatusLine(line))
563
+ files.push({ path, op: CHANGE_OPS[operation] });
564
+ }
565
+ }
566
+ }
567
+ catch (err) {
568
+ throw gitReadFailed(err);
569
+ }
570
+ return { cursor, files };
571
+ }
466
572
  export async function gitCommitAndPush(vaultPath, files, message, config = {}) {
467
- const git = simpleGit(vaultPath);
573
+ const git = vaultGit(vaultPath);
468
574
  // If a previous run crashed mid-rebase/merge, the repo is stuck.
469
575
  // Aborting restores tracked files to their pre-rebase state, which may
470
576
  // overwrite files that index.ts just wrote. Save and restore them.
@@ -536,7 +642,8 @@ export function createGitJournal(vaultPath, config = {}) {
536
642
  }
537
643
  },
538
644
  sync: () => gitSync(vaultPath),
539
- changelog: (since) => gitChangelog(vaultPath, since),
645
+ changelog: (since, limit) => gitChangelog(vaultPath, since, limit),
540
646
  fileTimes: (mode) => gitFileTimes(vaultPath, mode),
647
+ changesSince: (from) => gitChangesSince(vaultPath, from),
541
648
  };
542
649
  }
@@ -0,0 +1,5 @@
1
+ import type { ActivityRow, Hafez } from '../types.js';
2
+ /** How many rows `/activity` lists. */
3
+ export declare const ACTIVITY_ROWS = 20;
4
+ /** `names` maps a slug to the name its node carries now. */
5
+ export declare function activity(os: Hafez, names: Map<string, string>): Promise<ActivityRow[]>;
@@ -0,0 +1,70 @@
1
+ import { parseSessionLog } from '../document.js';
2
+ import { localDay } from '../dates.js';
3
+ import { kindDir, noteAtRelPath } from '../vault.js';
4
+ /** How many rows `/activity` lists. */
5
+ export const ACTIVITY_ROWS = 20;
6
+ /** How many of the newest commits are read first: a history read costs
7
+ * ~25 ms a commit on drvfs, so the whole history is never read at once.
8
+ * While those collapse into fewer rows, twice as many are read, until the
9
+ * rows are full or a read covers fewer commits than it asked for (the
10
+ * history's end). */
11
+ const COMMITS_READ = 50;
12
+ const OP = { created: 'create', updated: 'update', deleted: 'delete' };
13
+ /** `names` maps a slug to the name its node carries now. */
14
+ export async function activity(os, names) {
15
+ for (let commits = COMMITS_READ;; commits *= 2) {
16
+ const entries = await os.changelog('', commits);
17
+ const { rows, full } = collapse(entries, names);
18
+ // The commits the read covered, counted by hash: every note commit lists
19
+ // at least one entry (a commit touching no note under a note dir, the
20
+ // scaffold's .gitkeep, is the history's first).
21
+ if (full || new Set(entries.map(e => e.commit)).size < commits)
22
+ return withAgents(os, rows);
23
+ }
24
+ }
25
+ /** The changelog's entries as rows, newest first; `full` once a row past the
26
+ * last has begun, so the last row's run of writes is known to be whole. */
27
+ function collapse(entries, names) {
28
+ const rows = [];
29
+ for (const e of entries) {
30
+ // The changelog lists every file under a kind dir, an archive log
31
+ // (`entities/archive/<slug>-log.md`) too; the Layout says which are notes.
32
+ if (!noteAtRelPath(`${kindDir(e.kind)}/${e.slug}.md`))
33
+ continue;
34
+ const op = OP[e.operation];
35
+ const last = rows[rows.length - 1];
36
+ if (last?.slug === e.slug) {
37
+ // A promote deletes and creates the note in one commit: one write, and
38
+ // the create is what happened to it.
39
+ if (last.commit === e.commit && (last.op === 'delete') !== (op === 'delete')) {
40
+ if (op !== 'delete' && last.count === 1)
41
+ Object.assign(last, { op, kind: e.kind });
42
+ }
43
+ else {
44
+ last.count++;
45
+ last.commit = e.commit;
46
+ }
47
+ continue;
48
+ }
49
+ if (rows.length === ACTIVITY_ROWS)
50
+ return { rows, full: true };
51
+ rows.push({ slug: e.slug, kind: e.kind, op, timestamp: e.timestamp, name: names.get(e.slug) ?? e.slug, count: 1, commit: e.commit });
52
+ }
53
+ return { rows, full: false };
54
+ }
55
+ /** Each Entity row with the agent its Session Log records for the write's day. */
56
+ function withAgents(os, rows) {
57
+ return Promise.all(rows.map(async ({ commit: _, ...row }) => {
58
+ const agent = row.kind === 'entity' && row.op !== 'delete' ? await agentOf(os, row.slug, localDay(new Date(row.timestamp))) : undefined;
59
+ return agent ? { ...row, agent } : row;
60
+ }));
61
+ }
62
+ /** The agent of the Entity's newest Session Log entry dated `day`. */
63
+ async function agentOf(os, slug, day) {
64
+ try {
65
+ return parseSessionLog((await os.read(slug, 'full')).body).find(entry => entry.date === day)?.agent;
66
+ }
67
+ catch {
68
+ return undefined; // gone since: nothing to read the agent from
69
+ }
70
+ }