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.
- package/README.md +72 -5
- package/dist/apply.d.ts +14 -1
- package/dist/apply.js +94 -32
- package/dist/batch-ops.d.ts +31 -27
- package/dist/batch-ops.js +83 -16
- package/dist/cli/block-hook.d.ts +6 -0
- package/dist/cli/block-hook.js +27 -12
- package/dist/cli/commands.d.ts +3 -1
- package/dist/cli/commands.js +183 -73
- package/dist/cli/format.d.ts +11 -3
- package/dist/cli/format.js +61 -4
- package/dist/cli/index.js +40 -19
- package/dist/cli/install.d.ts +9 -0
- package/dist/cli/install.js +36 -15
- package/dist/cli/onboard.js +30 -9
- package/dist/cli/upgrade.js +5 -4
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.js +9 -0
- package/dist/dates.d.ts +7 -0
- package/dist/dates.js +21 -1
- package/dist/db.d.ts +50 -10
- package/dist/db.js +251 -65
- package/dist/digest.js +14 -7
- package/dist/document.d.ts +2 -0
- package/dist/document.js +2 -2
- package/dist/git.d.ts +13 -2
- package/dist/git.js +121 -14
- package/dist/graph/activity.d.ts +5 -0
- package/dist/graph/activity.js +70 -0
- package/dist/graph/client.js +326 -0
- package/dist/graph/copy-fonts.d.ts +1 -0
- package/dist/graph/copy-fonts.js +13 -0
- package/dist/graph/fields.d.ts +8 -0
- package/dist/graph/fields.js +12 -0
- package/dist/graph/fonts/geist-OFL.txt +93 -0
- package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
- package/dist/graph/fonts/geist-mono.woff2 +0 -0
- package/dist/graph/fonts/geist.woff2 +0 -0
- package/dist/graph/fonts/outfit-OFL.txt +93 -0
- package/dist/graph/fonts/outfit.woff2 +0 -0
- package/dist/graph/fonts.d.ts +14 -0
- package/dist/graph/fonts.js +17 -0
- package/dist/graph/live.d.ts +16 -0
- package/dist/graph/live.js +99 -0
- package/dist/graph/open.d.ts +1 -0
- package/dist/graph/open.js +20 -0
- package/dist/graph/server.d.ts +17 -0
- package/dist/graph/server.js +189 -0
- package/dist/health.d.ts +6 -0
- package/dist/health.js +310 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +66 -18
- package/dist/internal.d.ts +21 -0
- package/dist/internal.js +9 -0
- package/dist/knowledge-index.d.ts +1 -1
- package/dist/read-json.d.ts +26 -0
- package/dist/read-json.js +53 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +11 -0
- package/dist/stale.d.ts +2 -2
- package/dist/stale.js +4 -9
- package/dist/surfaces/items.d.ts +100 -1
- package/dist/surfaces/items.js +148 -10
- package/dist/surfaces/surfaces.d.ts +8 -5
- package/dist/surfaces/surfaces.js +34 -17
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +6 -1
- package/dist/types.d.ts +239 -25
- package/dist/validate.js +44 -56
- package/dist/vault.d.ts +29 -9
- package/dist/vault.js +39 -8
- package/dist/workstream.d.ts +17 -0
- package/dist/workstream.js +78 -0
- package/package.json +10 -4
- 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 =
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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}
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|