hafez 2.1.0 → 2.2.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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # Hafez
2
2
 
3
+ [![CI](https://github.com/shrvn-r/hafez/actions/workflows/public.yml/badge.svg?branch=main)](https://github.com/shrvn-r/hafez/actions/workflows/public.yml)
4
+
3
5
  **A knowledge and project vault shared between you and your agents.** *Hafez* (حافظ) is Persian for "the keeper — the one who memorizes," after the poet. Plain markdown files you own, backed by SQLite full-text search and git.
4
6
 
5
7
  ## Why
@@ -77,6 +79,23 @@ plus `--allow-remote=root`, which npm 12+ requires for a remote URL:
77
79
  npm install -g https://github.com/shrvn-r/hafez/releases/latest/download/hafez.tgz --allow-scripts=better-sqlite3 --allow-remote=root
78
80
  ```
79
81
 
82
+ ### Cloud sessions
83
+
84
+ A Claude Code cloud session (claude.ai/code, the mobile app) starts in a fresh
85
+ container: no `hafez`, no vault, no Block. Keep the vault in a private GitHub
86
+ repo and print the environment's setup script for it:
87
+
88
+ ```bash
89
+ hafez onboard --cloud --vault-repo <owner>/<vault-repo>
90
+ ```
91
+
92
+ Paste the script into the cloud environment's Setup script; the Trusted network
93
+ policy is enough. It installs Hafez, registers the vault when it is attached to
94
+ the session, and writes a Block with one rule more: a session started without
95
+ the vault attached attaches it, clones it and registers it, with one approval
96
+ from you. The script never clones the vault itself, because it runs without
97
+ GitHub credentials. Writes push to the vault's `main` like anywhere else.
98
+
80
99
  ## Updating
81
100
 
82
101
  Tell your agent: **update hafez**. Or paste: "Run `hafez upgrade` and follow
@@ -161,7 +180,7 @@ Three kinds of documents, all markdown + YAML frontmatter:
161
180
 
162
181
  - **Entities** (`entities/*.md`) — the things you're working on. Types: `capture` (inbox item), `entity` (anything worth tracking), `project` (has outcomes). Statuses: `active | paused | done`. Bodies start from a per-type template (capture: `## Notes`; entity: `## Context`; project: `## Purpose` + `## Goals`) plus a `## Session Log` of dated agent entries; `## Brief` (freeform handoff context) and `## Next Actions` (checkbox list) appear when first written to.
163
182
  - **Knowledge** (`knowledge/*.md`) — durable insights that outlive any one project. Subtypes `insight | plan`; confidence levels `observation → pattern → principle` (promoted manually). Bodies hold `## Synthesis`, `## Evidence`, and `## Sources` — evidence accumulates over time via `--add-evidence`.
164
- - **Sessions** (`sessions/*.md`) — session summaries written via `batch`, kept as history, not indexed.
183
+ - **Sessions** (`sessions/*.md`) — session summaries written at save time, never updated. Readable (`hafez read <slug>`), listed newest first (`hafez query --kind session`), and the look back from an entity (`hafez query --related-to <slug>`); search and query reach them only under `--kind session` or `--kind all`.
165
184
 
166
185
  Frontmatter at a glance:
167
186
 
@@ -181,9 +200,9 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
181
200
 
182
201
  | Command | What it does |
183
202
  |---|---|
184
- | `hafez read <slug> [--depth frontmatter\|summary\|full]` | Read an entity or knowledge note |
185
- | `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
186
- | `hafez search <terms> [--kind ...]` | FTS5 full-text search |
203
+ | `hafez read <slug> [--depth frontmatter\|summary\|full] [--archive]` | Read an entity, knowledge or session note; `--archive` an entity's archived log entries |
204
+ | `hafez query [--filter active\|paused\|done\|stale\|capture\|all] [--type] [--kind entity\|knowledge\|session\|all] [--domain] [--tag] [--parent] [--related-to] [--limit]` | Filter and list |
205
+ | `hafez search <terms> [--kind entity\|knowledge\|session\|all]` | FTS5 full-text search (default: entities and knowledge) |
187
206
  | `hafez create <entity\|knowledge> <name> [flags]` | Create a document |
188
207
  | `hafez capture <name> [--notes]` | Quick inbox capture |
189
208
  | `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
@@ -197,6 +216,7 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
197
216
  | `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
198
217
  | `hafez onboard` | Setup status, then the script your agent follows: Mode, install, permissions, seeding |
199
218
  | `hafez onboard --install --mode <always-on\|invoke-only>` / `--remove` | Write (or remove) the two skills and, always-on, the marked Block in the harness's instructions file |
219
+ | `hafez onboard --cloud --vault-repo <owner>/<repo>` | Print the setup script for Claude Code cloud sessions |
200
220
  | `hafez schema [op] [--examples]` | Machine-readable JSON schema for every operation |
201
221
  | `hafez export --okf [--out <dir>]` | Read-only OKF v0.1 bundle export |
202
222
 
package/dist/apply.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type VaultKind } from './vault.js';
1
2
  import type { BatchOperation, BatchResult, CreateEntityFields, DeletableKind, CreateKnowledgeFields, KnowledgeSubtype, UpdateFields, UpdateResult } from './types.js';
2
3
  export interface AppliedOp {
3
4
  filesWritten: string[];
@@ -7,7 +8,7 @@ export interface AppliedOp {
7
8
  indexRemovals: string[];
8
9
  indexUpserts: Array<{
9
10
  slug: string;
10
- kind: 'entity' | 'knowledge';
11
+ kind: VaultKind;
11
12
  }>;
12
13
  /** Set by the update arm: the line each Next Actions query matched. */
13
14
  updateResult?: UpdateResult;
package/dist/apply.js CHANGED
@@ -18,8 +18,8 @@
18
18
  // pure (no filesystem access); the frontmatter builders live here and are
19
19
  // exported so the batch validate phase can run them against a simulated vault.
20
20
  import { writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from 'fs';
21
- import { join, relative, dirname } from 'path';
22
- import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
21
+ import { join, dirname } from 'path';
22
+ import { parseFilePath, serializeFile, slugify, resolveFilePath, vaultRel, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
23
23
  import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
24
24
  import { bodyTemplate, knowledgeBodyTemplate, sessionBodyTemplate } from './templates.js';
25
25
  import { formatSessionLogEntry, stampAction, documentFor, documentForFile } from './document.js';
@@ -403,9 +403,9 @@ function applyUpdate(op, ctx) {
403
403
  // Only the files THIS update wrote. Keying off the archive file merely
404
404
  // existing swept any external hand-edit sitting in entities/archive/
405
405
  // into an unrelated commit.
406
- const files = [relative(vaultPath, filePath)];
406
+ const files = [vaultRel(vaultPath, filePath)];
407
407
  if (wroteArchive)
408
- files.push(relative(vaultPath, archivePath));
408
+ files.push(vaultRel(vaultPath, archivePath));
409
409
  return {
410
410
  filesWritten: files,
411
411
  filesDeleted: [],
@@ -433,7 +433,7 @@ function applyCreateEntity(op, ctx) {
433
433
  body = documentFor({ kind: 'entity' }).syncRelatedSection(body, fm);
434
434
  writeFileSync(filePath, serializeFile(fm, body));
435
435
  return {
436
- filesWritten: [relative(vaultPath, filePath)],
436
+ filesWritten: [vaultRel(vaultPath, filePath)],
437
437
  filesDeleted: [],
438
438
  result: { op: 'create', slug, status: 'ok', created: true },
439
439
  commitMessage: `create: ${op.name}`,
@@ -455,7 +455,7 @@ function applyCreateKnowledge(op, ctx) {
455
455
  body = documentFor({ kind: 'knowledge' }).syncRelatedSection(body, fm);
456
456
  writeFileSync(filePath, serializeFile(fm, body));
457
457
  return {
458
- filesWritten: [relative(vaultPath, filePath)],
458
+ filesWritten: [vaultRel(vaultPath, filePath)],
459
459
  filesDeleted: [],
460
460
  result: { op: 'create', slug, status: 'ok', created: true },
461
461
  commitMessage: `create: ${op.name}`,
@@ -490,15 +490,13 @@ function applyCreateSession(op, ctx) {
490
490
  let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis });
491
491
  sessionBody = documentFor({ kind: 'session' }).syncRelatedSection(sessionBody, fm);
492
492
  writeFileSync(filePath, serializeFile(fm, sessionBody));
493
- // Sessions are not indexed (db.ts only scans entities/ and knowledge/).
494
- // They ARE included in git semantic merge (git.ts VAULT_FILE_RE).
495
493
  return {
496
- filesWritten: [relative(vaultPath, filePath)],
494
+ filesWritten: [vaultRel(vaultPath, filePath)],
497
495
  filesDeleted: [],
498
496
  result: { op: 'create', slug, status: 'ok', created: true },
499
497
  commitMessage: `create: ${op.name}`,
500
498
  indexRemovals: [],
501
- indexUpserts: [],
499
+ indexUpserts: [{ slug, kind: 'session' }],
502
500
  };
503
501
  }
504
502
  function applyCapture(op, ctx) {
@@ -518,7 +516,7 @@ function applyCapture(op, ctx) {
518
516
  mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
519
517
  writeFileSync(filePath, serializeFile(fm, bodyTemplate('capture', { notes: op.notes })));
520
518
  return {
521
- filesWritten: [relative(vaultPath, filePath)],
519
+ filesWritten: [vaultRel(vaultPath, filePath)],
522
520
  filesDeleted: [],
523
521
  result: { op: 'capture', slug, status: 'ok', created: true },
524
522
  commitMessage: `capture: ${op.name}`,
@@ -545,7 +543,7 @@ function applyLink(op, ctx) {
545
543
  body = documentForFile(parsedFile).syncRelatedSection(body, fm);
546
544
  writeFileSync(filePath, serializeFile(fm, body));
547
545
  return {
548
- filesWritten: [relative(vaultPath, filePath)],
546
+ filesWritten: [vaultRel(vaultPath, filePath)],
549
547
  filesDeleted: [],
550
548
  result: { op: 'link', slug: op.slug, status: 'ok' },
551
549
  commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
@@ -571,7 +569,7 @@ function applyUnlink(op, ctx) {
571
569
  body = documentForFile(parsedFile).syncRelatedSection(body, fm);
572
570
  writeFileSync(filePath, serializeFile(fm, body));
573
571
  return {
574
- filesWritten: [relative(vaultPath, filePath)],
572
+ filesWritten: [vaultRel(vaultPath, filePath)],
575
573
  filesDeleted: [],
576
574
  result: { op: 'unlink', slug: op.slug, status: 'ok' },
577
575
  commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
@@ -631,10 +629,10 @@ function applyPromote(op, ctx) {
631
629
  catch (err) {
632
630
  throw new HafezError('VALIDATION_FAILED', `Failed to promote '${op.slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
633
631
  }
634
- modifiedFiles.push(relative(vaultPath, newPath));
632
+ modifiedFiles.push(vaultRel(vaultPath, newPath));
635
633
  // The deletion must be staged too — `git add` of a removed path stages
636
634
  // the removal; omitting it leaves the vault permanently dirty.
637
- deletedFiles.push(relative(vaultPath, filePath));
635
+ deletedFiles.push(vaultRel(vaultPath, filePath));
638
636
  }
639
637
  else {
640
638
  // Same-kind: capture→entity, capture→project, entity→project
@@ -661,7 +659,7 @@ function applyPromote(op, ctx) {
661
659
  }));
662
660
  newBody = doc.syncRelatedSection(newBody, fm);
663
661
  writeFileSync(filePath, serializeFile(fm, newBody));
664
- modifiedFiles.push(relative(vaultPath, filePath));
662
+ modifiedFiles.push(vaultRel(vaultPath, filePath));
665
663
  }
666
664
  return {
667
665
  filesWritten: modifiedFiles,
@@ -686,11 +684,10 @@ function applyDelete(op, ctx) {
686
684
  unlinkSync(filePath);
687
685
  return {
688
686
  filesWritten: [],
689
- filesDeleted: [relative(vaultPath, filePath)],
687
+ filesDeleted: [vaultRel(vaultPath, filePath)],
690
688
  result: { op: 'delete', slug: op.slug, status: 'ok' },
691
689
  commitMessage: `delete: ${op.slug} (${kind})`,
692
- // Sessions are not indexed; a knowledge row goes with its file.
693
- indexRemovals: kind === 'knowledge' ? [op.slug] : [],
690
+ indexRemovals: [op.slug],
694
691
  indexUpserts: [],
695
692
  deletedKind: kind,
696
693
  };
package/dist/batch-ops.js CHANGED
@@ -264,7 +264,9 @@ export function duplicateSessionWarning(slug) {
264
264
  * batch). The builder runs before the claim — it must see the pre-claim
265
265
  * world so a self-referencing parent still fails. */
266
266
  function claimNew(ctx, errors, slug, info, build) {
267
- const taken = ctx.existsAnywhere(slug);
267
+ // A session note holds its slug too: every kind shares one index row per
268
+ // slug, so a create must not take one a session already has.
269
+ const taken = ctx.existsAnywhere(slug) || ctx.sessionExists(slug);
268
270
  if (taken)
269
271
  errors.push(`Slug '${slug}' already exists`);
270
272
  if (build) {
@@ -26,11 +26,16 @@ export async function blockHook(ctx) {
26
26
  // leave an index file in it.
27
27
  if (typeof vaultPath !== 'string' || !isVault(vaultPath))
28
28
  return null;
29
- const cwd = realpathSync(ctx.cwd ?? process.cwd());
29
+ const dir = ctx.cwd ?? process.cwd();
30
+ // A deleted working directory: no folder to match.
31
+ if (!existsSync(dir))
32
+ return null;
33
+ const cwd = canonical(dir);
30
34
  const folders = foldersOf(cwd);
31
35
  const remotes = remoteUrls(cwd);
32
36
  const { createHafez } = await import('../index.js');
33
- const { items } = await createHafez({ vaultPath, git: { push: false } }).query({ filter: 'all' });
37
+ const os = createHafez({ vaultPath, git: { push: false } });
38
+ const { items } = await os.query({ filter: 'all' }).finally(() => os.close());
34
39
  const matches = items
35
40
  .filter(e => folders.some(f => basename(f) === e.slug) || (e.resource !== null && resourceMatches(e.resource, folders, remotes)))
36
41
  // Active first: the status tuple is ordered active, paused, done.
@@ -59,13 +64,25 @@ function foldersOf(cwd) {
59
64
  const [top, common] = out?.split('\n') ?? [];
60
65
  // Before git 2.31 `--path-format` is echoed back, not obeyed: no git root.
61
66
  if (top && common && isAbsolute(top) && isAbsolute(common)) {
62
- folders.push(top);
67
+ const root = canonical(top);
68
+ folders.push(root);
63
69
  // A linked worktree shares the main checkout's `.git` as its common dir.
64
- if (basename(common) === '.git' && dirname(common) !== top)
65
- folders.push(dirname(common));
70
+ // The shape is read off git's own path: a symlinked `.git` resolves to a
71
+ // directory with some other name.
72
+ const main = canonical(dirname(common));
73
+ if (basename(common) === '.git' && main !== root)
74
+ folders.push(main);
66
75
  }
67
76
  return [...new Set(folders)];
68
77
  }
78
+ /** One spelling per folder, so paths from git, the working directory and a
79
+ * `resource` compare equal: git prints `C:/…` on Windows, realpath `C:\…`.
80
+ * The native realpath, since only it returns the filesystem's own letter case
81
+ * (`c:\users\…` is `C:\Users\…`) and long names for 8.3 ones. */
82
+ function canonical(path) {
83
+ const abs = resolve(path);
84
+ return existsSync(abs) ? realpathSync.native(abs) : abs;
85
+ }
69
86
  /** Every remote's URL, normalised. */
70
87
  function remoteUrls(cwd) {
71
88
  const out = git(cwd, 'config', '--get-regexp', '^remote\\..*\\.url$');
@@ -82,9 +99,7 @@ function normaliseUrl(url) {
82
99
  .replace(/\.git$/, '');
83
100
  }
84
101
  function resourceMatches(resource, folders, remotes) {
85
- if (isAbsolute(resource)) {
86
- const path = resolve(resource);
87
- return folders.includes(existsSync(path) ? realpathSync(path) : path);
88
- }
102
+ if (isAbsolute(resource))
103
+ return folders.includes(canonical(resource));
89
104
  return remotes.includes(normaliseUrl(resource));
90
105
  }
@@ -9,7 +9,7 @@ export interface CommandOpts {
9
9
  export declare function cmdRead(os: Hafez, args: string[], opts?: CommandOpts): Promise<string>;
10
10
  /** Every flag the query parser accepts, for the branch-support matrix. */
11
11
  export declare const QUERY_FLAG_NAMES: string[];
12
- export type QueryBranch = 'entity' | 'knowledge' | 'all' | 'related-to';
12
+ export type QueryBranch = 'entity' | 'knowledge' | 'session' | 'all' | 'related-to';
13
13
  /** Which flags each kind branch actually applies. A branch that cannot honour
14
14
  * a flag rejects it (below) instead of dropping it: `--kind knowledge --filter
15
15
  * stale` used to return every knowledge note with exit 0, and `--confidence`
@@ -7,18 +7,18 @@ import { BatchOperationSchema, normalizeAliases, opErrorLine, batchInvalidHeader
7
7
  import { formatBatchError } from './batch-errors.js';
8
8
  import { parseDigestInput, digest } from '../digest.js';
9
9
  import { detectAgent } from '../harness.js';
10
- import { getActionItems } from '../document.js';
10
+ import { getActionItems, parseSessionLogHeading } from '../document.js';
11
11
  import { resemblances } from '../near-match.js';
12
12
  import { actionAge, agedActions, staleHandles, stalenessDaysOf } from '../stale.js';
13
13
  import { slugify } from '../vault.js';
14
14
  import { ITEMS, footerOp } from '../surfaces/items.js';
15
15
  import { placeIn } from '../surfaces/surfaces.js';
16
- import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
16
+ import { formatEntityHeader, formatKnowledgeHeader, formatSessionHeader, formatQueryTable, formatKnowledgeTable, formatSessionTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
17
17
  function jsonOut(data) {
18
18
  return JSON.stringify(data, null, 2);
19
19
  }
20
- const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full]';
21
- const READ_FLAGS = { depth: 'string' };
20
+ const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full] [--archive]';
21
+ const READ_FLAGS = { depth: 'string', archive: 'boolean' };
22
22
  export async function cmdRead(os, args, opts = {}) {
23
23
  const { flags, positionals } = parseArgs(args, READ_FLAGS, READ_USAGE);
24
24
  const slug = positionals[0];
@@ -33,23 +33,52 @@ export async function cmdRead(os, args, opts = {}) {
33
33
  ? new RenderedError('NOT_FOUND', jsonOut({ slug, closest }), 'stdout')
34
34
  : new RenderedError('NOT_FOUND', ITEMS.slugMissRecovery.render({ slug, closest }), 'stderr');
35
35
  });
36
+ if (flags.archive) {
37
+ // The archive is an entity's: reached from it on purpose (#34).
38
+ if (result.kind !== 'entity')
39
+ throw new UsageError(`--archive reads an entity's archived log entries; '${slug}' is a ${result.kind === 'session' ? 'session note' : 'knowledge note'}\n${READ_USAGE}`);
40
+ const archive = await os.archive(slug);
41
+ if (opts.json)
42
+ return jsonOut({ slug, archive });
43
+ return archive ?? `no archive for ${slug}`;
44
+ }
45
+ // A session note is read, never written: no Footer (#35).
46
+ if (result.kind === 'session') {
47
+ if (opts.json)
48
+ return jsonOut(result);
49
+ return formatSessionHeader(result.frontmatter, result.body);
50
+ }
36
51
  const footer = await footerFor(os, slug, result, depth);
52
+ const pointers = result.kind === 'entity' ? await pointersFor(os, slug) : null;
37
53
  if (opts.json) {
38
54
  const writes = footerOp(footer);
39
55
  // A frontmatter-depth read carries no body, so it has no actions to list.
40
56
  if (result.kind !== 'entity' || depth === 'frontmatter')
41
- return jsonOut({ ...result, writes });
42
- return jsonOut({ ...result, actions: agedActions(result.body, result.frontmatter), writes });
57
+ return jsonOut({ ...result, ...pointers, writes });
58
+ return jsonOut({ ...result, actions: agedActions(result.body, result.frontmatter), ...pointers, writes });
43
59
  }
44
60
  const fm = result.frontmatter;
45
61
  const days = stalenessDaysOf(fm);
46
62
  const staleMark = (added) => ITEMS.staleMarker.render(actionAge(added, days));
47
63
  const text = result.kind === 'entity'
48
- ? formatEntityHeader(fm, ITEMS.handles.render(result.body, staleMark), await parentLine(os, fm.parent))
64
+ ? formatEntityHeader(fm, ITEMS.handles.render(result.body, staleMark), await parentLine(os, fm.parent), [
65
+ ITEMS.sessionsLine.render({ slug, ...pointers.sessions }),
66
+ ITEMS.archivePointer.render({ slug, entries: pointers.archive_entries }),
67
+ ].filter(Boolean))
49
68
  : formatKnowledgeHeader(fm, result.body);
50
69
  // Trailing newlines only: the Footer brings its own blank line.
51
70
  return `${text.replace(/\n+$/, '')}\n${ITEMS.writeLine.render(footer)}`;
52
71
  }
72
+ /** What hangs off an entity elsewhere: the sessions that touched it (the
73
+ * look back, #35) and its archived log entries (#34). Counted at read time,
74
+ * nothing written. */
75
+ async function pointersFor(os, slug) {
76
+ const sessions = (await os.related_to(slug)).items.filter((i) => i.kind === 'session');
77
+ const latest = sessions.reduce((max, s) => (max === null || s.session_date > max ? s.session_date : max), null);
78
+ const archive = await os.archive(slug);
79
+ const entries = archive ? archive.split('\n').filter(line => parseSessionLogHeading(line)).length : 0;
80
+ return { sessions: { count: sessions.length, latest }, archive_entries: entries };
81
+ }
53
82
  /** The `parent:` line's value: the slug with the parent's description. The
54
83
  * one thing `read` takes from another file; a parent that cannot be read
55
84
  * (a broken slug) is named by its slug alone. */
@@ -75,7 +104,7 @@ async function staleAfterWrite(os, slug) {
75
104
  }
76
105
  /** The Footer for a file `read` returned at `depth`. The Footer is the same
77
106
  * at every depth, so its Handle comes from the whole file, not from the body
78
- * this depth shows. */
107
+ * this depth shows. A session note has none: it is never written to. */
79
108
  async function footerFor(os, slug, result, depth) {
80
109
  return { slug, agent: detectAgent(), ...(result.kind === 'entity'
81
110
  ? {
@@ -84,14 +113,16 @@ async function footerFor(os, slug, result, depth) {
84
113
  }
85
114
  : { target: { kind: 'knowledge', subtype: result.frontmatter.subtype } }) };
86
115
  }
87
- const QUERY_USAGE = `Usage: hafez query [--kind entity|knowledge|all] [--filter ...] [--type t] [--parent slug] [--related-to slug] [--domain d] [--confidence c] [--subtype s] [--tag t] [--since d] [--before d] [--created-since d] [--created-before d] [--sort-by f] [--sort-order asc|desc] [--limit n] [--offset n]
116
+ const QUERY_USAGE = `Usage: hafez query [--kind entity|knowledge|session|all] [--filter ...] [--type t] [--parent slug] [--related-to slug] [--domain d] [--confidence c] [--subtype s] [--tag t] [--since d] [--before d] [--created-since d] [--created-before d] [--sort-by f] [--sort-order asc|desc] [--limit n] [--offset n]
88
117
 
89
- --filter active|paused|done matches entity status; knowledge notes carry no
90
- status, so under --kind all they are always included alongside the matching
91
- entities. --filter stale|capture are entity-only views and return no
92
- knowledge. --filter/--type/--parent are rejected with --kind knowledge, and
93
- --confidence/--subtype are rejected with --kind entity, rather than being
94
- silently dropped. --related-to takes no other query flags.`;
118
+ --filter active|paused|done matches entity status; knowledge and session
119
+ notes carry no status, so under --kind all they are always included
120
+ alongside the matching entities. --filter stale|capture are entity-only
121
+ views and return no knowledge. --filter/--type/--parent are rejected with
122
+ --kind knowledge, and --confidence/--subtype are rejected with --kind
123
+ entity, rather than being silently dropped. --kind session lists session
124
+ notes newest first and takes the time window, sort and paging flags only.
125
+ --related-to takes --kind to narrow to one kind and no other query flag.`;
95
126
  const QUERY_FLAGS = {
96
127
  kind: 'string', filter: 'string', type: 'string', parent: 'string', 'related-to': 'string',
97
128
  domain: 'string', confidence: 'string', subtype: 'string', tag: 'string',
@@ -107,8 +138,9 @@ export const QUERY_FLAG_NAMES = Object.keys(QUERY_FLAGS);
107
138
  export const QUERY_FLAG_SUPPORT = {
108
139
  entity: ['kind', 'filter', 'type', 'parent', 'domain', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
109
140
  knowledge: ['kind', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
141
+ session: ['kind', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
110
142
  all: ['kind', 'filter', 'type', 'parent', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
111
- 'related-to': ['related-to'],
143
+ 'related-to': ['related-to', 'kind'],
112
144
  };
113
145
  const FLAG_SCOPE_REASON = {
114
146
  filter: 'it selects entity status (or the entity-only stale/capture views); knowledge notes carry no status',
@@ -117,16 +149,17 @@ const FLAG_SCOPE_REASON = {
117
149
  confidence: 'knowledge notes only',
118
150
  subtype: 'knowledge notes only',
119
151
  };
120
- const QUERY_KINDS = ['entity', 'knowledge', 'all'];
152
+ const QUERY_KINDS = ['entity', 'knowledge', 'session', 'all'];
121
153
  function rejectUnsupportedFlags(branch, flags) {
122
154
  const supported = new Set(QUERY_FLAG_SUPPORT[branch]);
123
155
  for (const [name, value] of Object.entries(flags)) {
124
156
  if (value === undefined || supported.has(name))
125
157
  continue;
126
158
  const where = branch === 'related-to'
127
- ? 'with --related-to (it returns every entity and knowledge note linked to the slug)'
159
+ ? 'with --related-to (it returns every entity, knowledge note and session note linked to the slug)'
128
160
  : `with --kind ${branch}`;
129
- const reason = FLAG_SCOPE_REASON[name] ? ` — ${FLAG_SCOPE_REASON[name]}` : '';
161
+ const scope = branch === 'session' && (name === 'domain' || name === 'tag') ? 'a session note carries none' : FLAG_SCOPE_REASON[name];
162
+ const reason = scope ? ` — ${scope}` : '';
130
163
  throw new UsageError(`--${name} is not supported ${where}${reason}\n${QUERY_USAGE}`);
131
164
  }
132
165
  }
@@ -142,9 +175,10 @@ function intFlag(raw, name) {
142
175
  export async function cmdQuery(os, args, opts = {}) {
143
176
  const { flags } = parseArgs(args, QUERY_FLAGS, QUERY_USAGE);
144
177
  const relatedTo = str(flags['related-to']);
145
- const branch = relatedTo !== undefined ? 'related-to' : (str(flags.kind) ?? 'entity');
146
- if (branch !== 'related-to' && !QUERY_KINDS.includes(branch)) {
147
- throw new UsageError(`--kind must be one of ${QUERY_KINDS.join(', ')} (got "${branch}")\n${QUERY_USAGE}`);
178
+ const kindFlag = str(flags.kind);
179
+ const branch = relatedTo !== undefined ? 'related-to' : (kindFlag ?? 'entity');
180
+ if (kindFlag !== undefined && !QUERY_KINDS.includes(kindFlag)) {
181
+ throw new UsageError(`--kind must be one of ${QUERY_KINDS.join(', ')} (got "${kindFlag}")\n${QUERY_USAGE}`);
148
182
  }
149
183
  rejectUnsupportedFlags(branch, flags);
150
184
  const filter = (str(flags.filter) ?? 'active');
@@ -165,20 +199,16 @@ export async function cmdQuery(os, args, opts = {}) {
165
199
  const offset = intFlag(str(flags.offset), 'offset');
166
200
  const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
167
201
  const timeOpts = { since, before, createdSince, createdBefore };
168
- // --related-to: always returns both entities and knowledge
202
+ // --related-to: every kind linked to the slug, the sessions that touched
203
+ // an entity included; --kind narrows to one (the entity header's
204
+ // `--kind session --related-to` line)
169
205
  if (relatedTo) {
170
- const result = await os.related_to(relatedTo);
206
+ const linked = await os.related_to(relatedTo);
207
+ const items = kindFlag && kindFlag !== 'all' ? linked.items.filter(i => i.kind === kindFlag) : linked.items;
208
+ const result = { items, total: items.length };
171
209
  if (opts.json)
172
210
  return jsonOut(result);
173
- const { items: results, total } = result;
174
- const entities = results.filter(r => r.kind === 'entity');
175
- const knowledge = results.filter(r => r.kind === 'knowledge');
176
- const sections = [];
177
- if (entities.length > 0)
178
- sections.push('## Entities\n\n' + formatQueryTable(entities, total));
179
- if (knowledge.length > 0)
180
- sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge, total));
181
- return sections.length > 0 ? sections.join('\n\n') : 'No results.';
211
+ return formatUnifiedSections(result.items, result.total);
182
212
  }
183
213
  const queryOpts = { filter, type, parent, domain, ...timeOpts, tags, ...sortOpts };
184
214
  // --kind knowledge: query knowledge only. The time window applies here too —
@@ -190,22 +220,19 @@ export async function cmdQuery(os, args, opts = {}) {
190
220
  return jsonOut(result);
191
221
  return formatKnowledgeTable(result.items, result.total);
192
222
  }
193
- // --kind all: query both entities and knowledge via unified query
223
+ // --kind session: the session notes alone, newest first
224
+ if (branch === 'session') {
225
+ const result = await os.query_sessions({ ...timeOpts, ...sortOpts });
226
+ if (opts.json)
227
+ return jsonOut(result);
228
+ return formatSessionTable(result.items, result.total);
229
+ }
230
+ // --kind all: every kind via the unified query, sessions included
194
231
  if (branch === 'all') {
195
232
  const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence, subtype });
196
233
  if (opts.json)
197
234
  return jsonOut(result);
198
- const { items, total } = result;
199
- const entities = items.filter(i => i.kind === 'entity');
200
- const knowledge = items.filter(i => i.kind === 'knowledge');
201
- const sections = [];
202
- if (entities.length > 0)
203
- sections.push('## Entities\n\n' + formatQueryTable(entities));
204
- if (knowledge.length > 0)
205
- sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge));
206
- if (items.length < total)
207
- sections.push(`\nShowing ${items.length} of ${total}`);
208
- return sections.length > 0 ? sections.join('\n\n') : 'No results.';
235
+ return formatUnifiedSections(result.items, result.total);
209
236
  }
210
237
  // --kind entity (default): query entities
211
238
  const result = await os.query(queryOpts);
@@ -213,14 +240,38 @@ export async function cmdQuery(os, args, opts = {}) {
213
240
  return jsonOut(result);
214
241
  return formatQueryTable(result.items, result.total);
215
242
  }
216
- const SEARCH_USAGE = 'Usage: hafez search <terms> [--kind entity|knowledge|all]';
243
+ /** One section per kind present, in the order entities, knowledge,
244
+ * sessions; a page line when the page is short of the total. */
245
+ function formatUnifiedSections(items, total) {
246
+ const entities = items.filter((i) => i.kind === 'entity');
247
+ const knowledge = items.filter((i) => i.kind === 'knowledge');
248
+ const sessions = items.filter((i) => i.kind === 'session');
249
+ const sections = [];
250
+ if (entities.length > 0)
251
+ sections.push('## Entities\n\n' + formatQueryTable(entities));
252
+ if (knowledge.length > 0)
253
+ sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge));
254
+ if (sessions.length > 0)
255
+ sections.push('## Sessions\n\n' + formatSessionTable(sessions));
256
+ if (items.length < total)
257
+ sections.push(`\nShowing ${items.length} of ${total}`);
258
+ return sections.length > 0 ? sections.join('\n\n') : 'No results.';
259
+ }
260
+ const SEARCH_USAGE = `Usage: hafez search <terms> [--kind entity|knowledge|session|all]
261
+
262
+ Without --kind, entities and knowledge notes are searched. --kind all adds
263
+ the session notes; --kind session searches them alone.`;
217
264
  const SEARCH_FLAGS = { kind: 'string' };
265
+ const SEARCH_KINDS = ['entity', 'knowledge', 'session', 'all'];
218
266
  export async function cmdSearch(os, args, opts = {}) {
219
267
  const { flags, positionals } = parseArgs(args, SEARCH_FLAGS, SEARCH_USAGE);
220
268
  const terms = positionals.join(' ');
221
269
  if (!terms)
222
270
  throw new UsageError(SEARCH_USAGE);
223
- const kind = (str(flags.kind) ?? 'all');
271
+ const kind = str(flags.kind);
272
+ if (kind !== undefined && !SEARCH_KINDS.includes(kind)) {
273
+ throw new UsageError(`--kind must be one of ${SEARCH_KINDS.join(', ')} (got "${kind}")\n${SEARCH_USAGE}`);
274
+ }
224
275
  const results = await os.search(terms, kind);
225
276
  if (opts.json)
226
277
  return jsonOut(results);
@@ -462,7 +513,14 @@ async function updateFailure(os, slug, err, opts) {
462
513
  return err;
463
514
  let retry;
464
515
  try {
465
- retry = { footer: await footerFor(os, slug, await os.read(slug, 'full'), 'full') };
516
+ const file = await os.read(slug, 'full');
517
+ // The Op Spec resolves no session note, so the update missed; the slug
518
+ // names one all the same. Say what it is instead of offering a Footer
519
+ // it will never have.
520
+ if (file.kind === 'session') {
521
+ return new RenderedError('VALIDATION_FAILED', `'${slug}' is a session note: written once and read, never updated. What matters from it belongs on the entity it touched.`, 'stderr');
522
+ }
523
+ retry = { footer: await footerFor(os, slug, file, 'full') };
466
524
  }
467
525
  catch (readErr) {
468
526
  if (!(readErr instanceof HafezError) || readErr.code !== 'NOT_FOUND')
@@ -1,12 +1,17 @@
1
- import type { QueryResult, KnowledgeQueryResult, ValidationReport, SearchResult, VaultStats, ChangelogEntry } from '../types.js';
1
+ import type { QueryResult, KnowledgeQueryResult, SessionQueryResult, ValidationReport, SearchResult, VaultStats, ChangelogEntry } from '../types.js';
2
2
  /** `parent` is the rendered value of the `parent:` line (the slug with the
3
- * parent's description); absent, the slug alone. */
4
- export declare function formatEntityHeader(fm: Record<string, any>, body: string, parent?: string | undefined): string;
3
+ * parent's description); absent, the slug alone. `pointers` are the lines
4
+ * naming what hangs off the entity elsewhere (its sessions, its archive). */
5
+ export declare function formatEntityHeader(fm: Record<string, any>, body: string, parent?: string | undefined, pointers?: string[]): string;
6
+ /** A session note: its day, then the entities it touched. */
7
+ export declare function formatSessionHeader(fm: Record<string, any>, body: string): string;
5
8
  export declare function formatKnowledgeHeader(fm: Record<string, any>, body: string): string;
6
9
  export type QueryTableRow = Pick<QueryResult, 'slug' | 'name' | 'type' | 'status' | 'next_action' | 'next_action_count'>;
7
10
  export declare function formatQueryTable(results: QueryTableRow[], total?: number): string;
8
11
  export type KnowledgeTableRow = Pick<KnowledgeQueryResult, 'slug' | 'name' | 'confidence' | 'domain' | 'reinforcement_count'>;
9
12
  export declare function formatKnowledgeTable(results: KnowledgeTableRow[], total?: number): string;
13
+ export type SessionTableRow = Pick<SessionQueryResult, 'slug' | 'name' | 'session_date' | 'related'>;
14
+ export declare function formatSessionTable(results: SessionTableRow[], total?: number): string;
10
15
  export declare function formatSearchResults(results: SearchResult[]): string;
11
16
  export declare function formatStats(stats: VaultStats): string;
12
17
  export declare function formatChangelog(entries: ChangelogEntry[]): string;