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
@@ -7,49 +7,69 @@ 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';
11
10
  import { resemblances } from '../near-match.js';
12
- import { actionAge, agedActions, staleHandles, stalenessDaysOf } from '../stale.js';
11
+ import { actionAge, staleHandles, stalenessDaysOf } from '../stale.js';
13
12
  import { slugify } from '../vault.js';
14
- import { ITEMS, footerOp } from '../surfaces/items.js';
13
+ import { GRAPH_LAYERS } from '../contracts.js';
14
+ import { ITEMS } from '../surfaces/items.js';
15
+ import { footerFor, pointersFor, readJsonOf } from '../read-json.js';
15
16
  import { placeIn } from '../surfaces/surfaces.js';
16
- import { formatEntityHeader, formatKnowledgeHeader, formatQueryTable, formatKnowledgeTable, formatSearchResults, formatValidation, formatStats, formatChangelog } from './format.js';
17
+ import { formatEntityHeader, formatKnowledgeHeader, formatSessionHeader, formatQueryTable, formatKnowledgeTable, formatSessionTable, formatSearchResults, formatValidation, formatHealth, formatStats, formatChangelog } from './format.js';
17
18
  function jsonOut(data) {
18
19
  return JSON.stringify(data, null, 2);
19
20
  }
20
- const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full]';
21
- const READ_FLAGS = { depth: 'string' };
21
+ const READ_USAGE = 'Usage: hafez read <slug> [--depth frontmatter|summary|full] [--archive]';
22
+ const READ_FLAGS = { depth: 'string', archive: 'boolean' };
22
23
  export async function cmdRead(os, args, opts = {}) {
23
24
  const { flags, positionals } = parseArgs(args, READ_FLAGS, READ_USAGE);
24
25
  const slug = positionals[0];
25
26
  if (!slug)
26
27
  throw new UsageError(READ_USAGE);
27
28
  const depth = (str(flags.depth) ?? 'summary');
28
- const result = await os.read(slug, depth).catch(err => {
29
- if (!(err instanceof HafezError) || err.code !== 'NOT_FOUND')
30
- throw err;
31
- const closest = err.details ?? [];
32
- throw opts.json
33
- ? new RenderedError('NOT_FOUND', jsonOut({ slug, closest }), 'stdout')
34
- : new RenderedError('NOT_FOUND', ITEMS.slugMissRecovery.render({ slug, closest }), 'stderr');
35
- });
36
- const footer = await footerFor(os, slug, result, depth);
37
- if (opts.json) {
38
- const writes = footerOp(footer);
39
- // A frontmatter-depth read carries no body, so it has no actions to list.
40
- if (result.kind !== 'entity' || depth === 'frontmatter')
41
- return jsonOut({ ...result, writes });
42
- return jsonOut({ ...result, actions: agedActions(result.body, result.frontmatter), writes });
29
+ const result = await os.read(slug, depth).catch(err => { throw readMiss(err, slug, opts); });
30
+ if (flags.archive) {
31
+ // The archive is an entity's: reached from it on purpose (#34).
32
+ if (result.kind !== 'entity')
33
+ throw new UsageError(`--archive reads an entity's archived log entries; '${slug}' is a ${result.kind === 'session' ? 'session note' : 'knowledge note'}\n${READ_USAGE}`);
34
+ const archive = await os.archive(slug);
35
+ if (opts.json)
36
+ return jsonOut({ slug, archive });
37
+ return archive ?? `no archive for ${slug}`;
43
38
  }
39
+ if (opts.json)
40
+ return jsonOut(await readJsonOf(os, slug, depth, result));
41
+ // A session note is read, never written: no Footer (#35).
42
+ if (result.kind === 'session')
43
+ return formatSessionHeader(result.frontmatter, result.body);
44
+ // The Footer and the pointers are the same at every depth: they read the
45
+ // whole file, not the body this depth shows.
46
+ const whole = depth === 'full' ? result : await os.read(slug, 'full');
47
+ const footer = footerFor(slug, whole);
48
+ const pointers = whole.kind === 'entity' ? await pointersFor(os, slug, whole.body) : null;
44
49
  const fm = result.frontmatter;
45
50
  const days = stalenessDaysOf(fm);
46
51
  const staleMark = (added) => ITEMS.staleMarker.render(actionAge(added, days));
47
52
  const text = result.kind === 'entity'
48
- ? formatEntityHeader(fm, ITEMS.handles.render(result.body, staleMark), await parentLine(os, fm.parent))
53
+ ? formatEntityHeader(fm, ITEMS.handles.render(result.body, staleMark), await parentLine(os, fm.parent), [
54
+ ITEMS.childrenBlock.render({ slug, children: pointers.children }),
55
+ ...pointers.workstreams.map(w => ITEMS.workstreamLine.render(w)),
56
+ ITEMS.sessionsLine.render({ slug, ...pointers.sessions }),
57
+ ITEMS.archivePointer.render({ slug, entries: pointers.archive_entries }),
58
+ ].filter(Boolean))
49
59
  : formatKnowledgeHeader(fm, result.body);
50
60
  // Trailing newlines only: the Footer brings its own blank line.
51
61
  return `${text.replace(/\n+$/, '')}\n${ITEMS.writeLine.render(footer)}`;
52
62
  }
63
+ /** A `read` miss as the CLI renders it: the closest slugs on stderr (`--json`:
64
+ * `{slug, closest}` on stdout), exit 1. Any other error passes through. */
65
+ function readMiss(err, slug, opts) {
66
+ if (!(err instanceof HafezError) || err.code !== 'NOT_FOUND')
67
+ return err;
68
+ const closest = err.details ?? [];
69
+ return opts.json
70
+ ? new RenderedError('NOT_FOUND', jsonOut({ slug, closest }), 'stdout')
71
+ : new RenderedError('NOT_FOUND', ITEMS.slugMissRecovery.render({ slug, closest }), 'stderr');
72
+ }
53
73
  /** The `parent:` line's value: the slug with the parent's description. The
54
74
  * one thing `read` takes from another file; a parent that cannot be read
55
75
  * (a broken slug) is named by its slug alone. */
@@ -73,25 +93,16 @@ async function staleAfterWrite(os, slug) {
73
93
  return [];
74
94
  }
75
95
  }
76
- /** The Footer for a file `read` returned at `depth`. The Footer is the same
77
- * at every depth, so its Handle comes from the whole file, not from the body
78
- * this depth shows. */
79
- async function footerFor(os, slug, result, depth) {
80
- return { slug, agent: detectAgent(), ...(result.kind === 'entity'
81
- ? {
82
- target: { kind: 'entity', type: result.frontmatter.type },
83
- handle: getActionItems(depth === 'full' ? result.body : (await os.read(slug, 'full')).body).find(a => a.handle)?.handle,
84
- }
85
- : { target: { kind: 'knowledge', subtype: result.frontmatter.subtype } }) };
86
- }
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]
96
+ 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
97
 
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.`;
98
+ --filter active|paused|done matches entity status; knowledge and session
99
+ notes carry no status, so under --kind all they are always included
100
+ alongside the matching entities. --filter stale|capture are entity-only
101
+ views and return no knowledge. --filter/--type/--parent are rejected with
102
+ --kind knowledge, and --confidence/--subtype are rejected with --kind
103
+ entity, rather than being silently dropped. --kind session lists session
104
+ notes newest first and takes the time window, sort and paging flags only.
105
+ --related-to takes --kind to narrow to one kind and no other query flag.`;
95
106
  const QUERY_FLAGS = {
96
107
  kind: 'string', filter: 'string', type: 'string', parent: 'string', 'related-to': 'string',
97
108
  domain: 'string', confidence: 'string', subtype: 'string', tag: 'string',
@@ -107,8 +118,9 @@ export const QUERY_FLAG_NAMES = Object.keys(QUERY_FLAGS);
107
118
  export const QUERY_FLAG_SUPPORT = {
108
119
  entity: ['kind', 'filter', 'type', 'parent', 'domain', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
109
120
  knowledge: ['kind', 'domain', 'confidence', 'subtype', 'tag', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
121
+ session: ['kind', 'since', 'before', 'created-since', 'created-before', 'sort-by', 'sort-order', 'limit', 'offset'],
110
122
  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'],
123
+ 'related-to': ['related-to', 'kind'],
112
124
  };
113
125
  const FLAG_SCOPE_REASON = {
114
126
  filter: 'it selects entity status (or the entity-only stale/capture views); knowledge notes carry no status',
@@ -117,16 +129,17 @@ const FLAG_SCOPE_REASON = {
117
129
  confidence: 'knowledge notes only',
118
130
  subtype: 'knowledge notes only',
119
131
  };
120
- const QUERY_KINDS = ['entity', 'knowledge', 'all'];
132
+ const QUERY_KINDS = ['entity', 'knowledge', 'session', 'all'];
121
133
  function rejectUnsupportedFlags(branch, flags) {
122
134
  const supported = new Set(QUERY_FLAG_SUPPORT[branch]);
123
135
  for (const [name, value] of Object.entries(flags)) {
124
136
  if (value === undefined || supported.has(name))
125
137
  continue;
126
138
  const where = branch === 'related-to'
127
- ? 'with --related-to (it returns every entity and knowledge note linked to the slug)'
139
+ ? 'with --related-to (it returns every entity, knowledge note and session note linked to the slug)'
128
140
  : `with --kind ${branch}`;
129
- const reason = FLAG_SCOPE_REASON[name] ? ` — ${FLAG_SCOPE_REASON[name]}` : '';
141
+ const scope = branch === 'session' && (name === 'domain' || name === 'tag') ? 'a session note carries none' : FLAG_SCOPE_REASON[name];
142
+ const reason = scope ? ` — ${scope}` : '';
130
143
  throw new UsageError(`--${name} is not supported ${where}${reason}\n${QUERY_USAGE}`);
131
144
  }
132
145
  }
@@ -142,9 +155,10 @@ function intFlag(raw, name) {
142
155
  export async function cmdQuery(os, args, opts = {}) {
143
156
  const { flags } = parseArgs(args, QUERY_FLAGS, QUERY_USAGE);
144
157
  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}`);
158
+ const kindFlag = str(flags.kind);
159
+ const branch = relatedTo !== undefined ? 'related-to' : (kindFlag ?? 'entity');
160
+ if (kindFlag !== undefined && !QUERY_KINDS.includes(kindFlag)) {
161
+ throw new UsageError(`--kind must be one of ${QUERY_KINDS.join(', ')} (got "${kindFlag}")\n${QUERY_USAGE}`);
148
162
  }
149
163
  rejectUnsupportedFlags(branch, flags);
150
164
  const filter = (str(flags.filter) ?? 'active');
@@ -165,20 +179,16 @@ export async function cmdQuery(os, args, opts = {}) {
165
179
  const offset = intFlag(str(flags.offset), 'offset');
166
180
  const sortOpts = { sort_by: sortBy, sort_order: sortOrder, limit, offset };
167
181
  const timeOpts = { since, before, createdSince, createdBefore };
168
- // --related-to: always returns both entities and knowledge
182
+ // --related-to: every kind linked to the slug, the sessions that touched
183
+ // an entity included; --kind narrows to one (the entity header's
184
+ // `--kind session --related-to` line)
169
185
  if (relatedTo) {
170
- const result = await os.related_to(relatedTo);
186
+ const linked = await os.related_to(relatedTo);
187
+ const items = kindFlag && kindFlag !== 'all' ? linked.items.filter(i => i.kind === kindFlag) : linked.items;
188
+ const result = { items, total: items.length };
171
189
  if (opts.json)
172
190
  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.';
191
+ return formatUnifiedSections(result.items, result.total);
182
192
  }
183
193
  const queryOpts = { filter, type, parent, domain, ...timeOpts, tags, ...sortOpts };
184
194
  // --kind knowledge: query knowledge only. The time window applies here too —
@@ -190,22 +200,19 @@ export async function cmdQuery(os, args, opts = {}) {
190
200
  return jsonOut(result);
191
201
  return formatKnowledgeTable(result.items, result.total);
192
202
  }
193
- // --kind all: query both entities and knowledge via unified query
203
+ // --kind session: the session notes alone, newest first
204
+ if (branch === 'session') {
205
+ const result = await os.query_sessions({ ...timeOpts, ...sortOpts });
206
+ if (opts.json)
207
+ return jsonOut(result);
208
+ return formatSessionTable(result.items, result.total);
209
+ }
210
+ // --kind all: every kind via the unified query, sessions included
194
211
  if (branch === 'all') {
195
212
  const result = await os.queryUnified({ ...queryOpts, kind: 'all', confidence, subtype });
196
213
  if (opts.json)
197
214
  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.';
215
+ return formatUnifiedSections(result.items, result.total);
209
216
  }
210
217
  // --kind entity (default): query entities
211
218
  const result = await os.query(queryOpts);
@@ -213,14 +220,38 @@ export async function cmdQuery(os, args, opts = {}) {
213
220
  return jsonOut(result);
214
221
  return formatQueryTable(result.items, result.total);
215
222
  }
216
- const SEARCH_USAGE = 'Usage: hafez search <terms> [--kind entity|knowledge|all]';
223
+ /** One section per kind present, in the order entities, knowledge,
224
+ * sessions; a page line when the page is short of the total. */
225
+ function formatUnifiedSections(items, total) {
226
+ const entities = items.filter((i) => i.kind === 'entity');
227
+ const knowledge = items.filter((i) => i.kind === 'knowledge');
228
+ const sessions = items.filter((i) => i.kind === 'session');
229
+ const sections = [];
230
+ if (entities.length > 0)
231
+ sections.push('## Entities\n\n' + formatQueryTable(entities));
232
+ if (knowledge.length > 0)
233
+ sections.push('## Knowledge\n\n' + formatKnowledgeTable(knowledge));
234
+ if (sessions.length > 0)
235
+ sections.push('## Sessions\n\n' + formatSessionTable(sessions));
236
+ if (items.length < total)
237
+ sections.push(`\nShowing ${items.length} of ${total}`);
238
+ return sections.length > 0 ? sections.join('\n\n') : 'No results.';
239
+ }
240
+ const SEARCH_USAGE = `Usage: hafez search <terms> [--kind entity|knowledge|session|all]
241
+
242
+ Without --kind, entities and knowledge notes are searched. --kind all adds
243
+ the session notes; --kind session searches them alone.`;
217
244
  const SEARCH_FLAGS = { kind: 'string' };
245
+ const SEARCH_KINDS = ['entity', 'knowledge', 'session', 'all'];
218
246
  export async function cmdSearch(os, args, opts = {}) {
219
247
  const { flags, positionals } = parseArgs(args, SEARCH_FLAGS, SEARCH_USAGE);
220
248
  const terms = positionals.join(' ');
221
249
  if (!terms)
222
250
  throw new UsageError(SEARCH_USAGE);
223
- const kind = (str(flags.kind) ?? 'all');
251
+ const kind = str(flags.kind);
252
+ if (kind !== undefined && !SEARCH_KINDS.includes(kind)) {
253
+ throw new UsageError(`--kind must be one of ${SEARCH_KINDS.join(', ')} (got "${kind}")\n${SEARCH_USAGE}`);
254
+ }
224
255
  const results = await os.search(terms, kind);
225
256
  if (opts.json)
226
257
  return jsonOut(results);
@@ -462,7 +493,14 @@ async function updateFailure(os, slug, err, opts) {
462
493
  return err;
463
494
  let retry;
464
495
  try {
465
- retry = { footer: await footerFor(os, slug, await os.read(slug, 'full'), 'full') };
496
+ const file = await os.read(slug, 'full');
497
+ // The Op Spec resolves no session note, so the update missed; the slug
498
+ // names one all the same. Say what it is instead of offering a Footer
499
+ // it will never have.
500
+ if (file.kind === 'session') {
501
+ 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');
502
+ }
503
+ retry = { footer: footerFor(slug, file) };
466
504
  }
467
505
  catch (readErr) {
468
506
  if (!(readErr instanceof HafezError) || readErr.code !== 'NOT_FOUND')
@@ -542,6 +580,78 @@ export async function cmdValidate(os, args, opts = {}) {
542
580
  return jsonOut(report);
543
581
  return formatValidation(report);
544
582
  }
583
+ export async function cmdHealth(os, args, opts = {}) {
584
+ helpIfAsked(args, 'Usage: hafez health (what placement would change; applies nothing)');
585
+ const report = await os.health();
586
+ if (opts.json)
587
+ return jsonOut(report);
588
+ return formatHealth(report);
589
+ }
590
+ const GRAPH_USAGE = "Usage: hafez graph [slug] [--port N] [--no-open] (serve the vault's graph on 127.0.0.1 and open it in the browser; a slug opens it focused on that note)\n hafez graph --json [--layers tag,domain,session] (the vault's structure as {nodes, edges})";
591
+ const GRAPH_FLAGS = { layers: 'string', port: 'string', 'no-open': 'boolean' };
592
+ /** The local-graph depth `hafez graph <slug>` opens at (#256). */
593
+ const GRAPH_FOCUS_DEPTH = 2;
594
+ export async function cmdGraph(os, args, opts = {}) {
595
+ const { flags, positionals } = parseArgs(args, GRAPH_FLAGS, GRAPH_USAGE);
596
+ if (positionals.length > 1)
597
+ throw new UsageError(GRAPH_USAGE);
598
+ const slug = positionals[0];
599
+ if (!opts.json) {
600
+ if (flags.layers !== undefined)
601
+ throw new UsageError('--layers requires --json', GRAPH_USAGE);
602
+ // A miss fails as `read` does, before any server starts.
603
+ if (slug !== undefined)
604
+ await os.read(slug, 'frontmatter').catch(err => { throw readMiss(err, slug, opts); });
605
+ return serveGraph(opts.vaultPath, flags, slug);
606
+ }
607
+ if (slug !== undefined)
608
+ throw new UsageError('a slug opens the page focused on it; --json prints the whole Graph', GRAPH_USAGE);
609
+ if (flags.port !== undefined || flags['no-open'])
610
+ throw new UsageError('--port and --no-open serve the page; --json prints the Graph and exits', GRAPH_USAGE);
611
+ const layers = splitList(flags.layers);
612
+ const unknown = layers?.find(l => !GRAPH_LAYERS.includes(l));
613
+ if (unknown !== undefined)
614
+ throw new UsageError(`Unknown layer: ${unknown}. Valid layers: ${GRAPH_LAYERS.join(', ')}`, GRAPH_USAGE);
615
+ return jsonOut(await os.graph(layers ? { layers: layers } : undefined));
616
+ }
617
+ const STOP_SIGNALS = ['SIGINT', 'SIGTERM'];
618
+ /** `hafez graph` without --json: serve the page until Ctrl-C or SIGTERM, then close the
619
+ * server and the index. The server gets its own instance whose reads trust the
620
+ * index: it syncs the index when its poll sees a commit (#271). */
621
+ async function serveGraph(vaultPath, flags, focus) {
622
+ const portFlag = str(flags.port);
623
+ if (portFlag !== undefined && !(/^\d+$/.test(portFlag) && Number(portFlag) <= 65535)) {
624
+ throw new UsageError(`--port takes a port number (0-65535), got "${portFlag}"`, GRAPH_USAGE);
625
+ }
626
+ const port = portFlag === undefined ? undefined : Number(portFlag);
627
+ const { startGraphServer, DEFAULT_PORT } = await import('../graph/server.js');
628
+ const { createInternalHafez } = await import('../internal.js');
629
+ const os = createInternalHafez({ vaultPath, git: { push: false }, indexSync: 'manual' });
630
+ const server = await startGraphServer({ os, port }).catch(err => {
631
+ os.close();
632
+ if (err.code !== 'EADDRINUSE' || port === undefined)
633
+ throw err;
634
+ throw new UsageError(`port ${port} is in use: pick another with --port, or leave it off for the first free port from ${DEFAULT_PORT}`);
635
+ });
636
+ // The page reads its view from the URL: a focus opens local-graph mode around it.
637
+ const url = focus === undefined ? server.url : `${server.url}/?focus=${encodeURIComponent(focus)}&depth=${GRAPH_FOCUS_DEPTH}`;
638
+ process.stdout.write(`Serving the graph at ${url} (Ctrl-C to stop)\n`);
639
+ if (!flags['no-open'])
640
+ (await import('../graph/open.js')).openBrowser(url);
641
+ // SIGTERM (a supervisor's kill) stops it as Ctrl-C does, so the index is
642
+ // closed either way (#280). A second signal during the close is not caught.
643
+ await new Promise(resolve => {
644
+ const stop = () => {
645
+ for (const signal of STOP_SIGNALS)
646
+ process.off(signal, stop);
647
+ resolve();
648
+ };
649
+ for (const signal of STOP_SIGNALS)
650
+ process.on(signal, stop);
651
+ });
652
+ await server.close();
653
+ return 'Graph server stopped.';
654
+ }
545
655
  export async function cmdIndexRebuild(os) {
546
656
  await os.rebuildIndex();
547
657
  return 'Index rebuilt.';
@@ -1,13 +1,21 @@
1
- import type { QueryResult, KnowledgeQueryResult, ValidationReport, SearchResult, VaultStats, ChangelogEntry } from '../types.js';
1
+ import type { QueryResult, KnowledgeQueryResult, SessionQueryResult, ValidationReport, HealthReport, 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;
18
+ /** The Health pass as markdown: one section per check, every section
19
+ * printed, so an empty report still shows what was checked. */
20
+ export declare function formatHealth(report: HealthReport): string;
13
21
  export declare function formatValidation(report: ValidationReport): string;
@@ -1,6 +1,8 @@
1
+ import { ITEMS } from '../surfaces/items.js';
1
2
  /** `parent` is the rendered value of the `parent:` line (the slug with the
2
- * parent's description); absent, the slug alone. */
3
- export function formatEntityHeader(fm, body, parent = fm.parent) {
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 function formatEntityHeader(fm, body, parent = fm.parent, pointers = []) {
4
6
  const lines = [`# ${fm.name}`];
5
7
  if (fm.description)
6
8
  lines.push(`> ${fm.description}`);
@@ -29,6 +31,23 @@ export function formatEntityHeader(fm, body, parent = fm.parent) {
29
31
  lines.push(`tags: ${fm.tags.join(', ')}`);
30
32
  if (fm.resource)
31
33
  lines.push(`resource: ${fm.resource}`);
34
+ lines.push(...pointers);
35
+ lines.push('');
36
+ lines.push(body);
37
+ return lines.join('\n');
38
+ }
39
+ /** A session note: its day, then the entities it touched. */
40
+ export function formatSessionHeader(fm, body) {
41
+ const lines = [`# ${fm.name}`];
42
+ const meta = [];
43
+ if (fm['session-date'])
44
+ meta.push(`session-date: ${fm['session-date']}`);
45
+ if (fm.created)
46
+ meta.push(`created: ${fm.created}`);
47
+ if (meta.length)
48
+ lines.push(meta.join(' | '));
49
+ if (fm.related?.length)
50
+ lines.push(`related: ${fm.related.join(', ')}`);
32
51
  lines.push('');
33
52
  lines.push(body);
34
53
  return lines.join('\n');
@@ -100,11 +119,24 @@ export function formatKnowledgeTable(results, total) {
100
119
  }
101
120
  return lines.join('\n');
102
121
  }
122
+ export function formatSessionTable(results, total) {
123
+ if (results.length === 0)
124
+ return 'No results.';
125
+ const lines = ['| slug | name | session-date | related |', '|------|------|--------------|---------|'];
126
+ for (const r of results) {
127
+ lines.push(`| ${r.slug} | ${r.name} | ${r.session_date} | ${r.related.join(', ')} |`);
128
+ }
129
+ if (total != null && results.length < total) {
130
+ lines.push(`\nShowing ${results.length} of ${total}`);
131
+ }
132
+ return lines.join('\n');
133
+ }
103
134
  export function formatSearchResults(results) {
104
135
  if (results.length === 0)
105
136
  return 'No results.';
106
137
  const entities = results.filter(r => r.kind === 'entity');
107
138
  const knowledge = results.filter(r => r.kind === 'knowledge');
139
+ const sessions = results.filter(r => r.kind === 'session');
108
140
  const sections = [];
109
141
  if (entities.length > 0) {
110
142
  const lines = ['## Entities', '', '| slug | name | type | match |', '|------|------|------|-------|'];
@@ -118,6 +150,12 @@ export function formatSearchResults(results) {
118
150
  lines.push(`| ${r.slug} | ${r.name} | ${r.confidence ?? ''} | ${r.snippet} |`);
119
151
  sections.push(lines.join('\n'));
120
152
  }
153
+ if (sessions.length > 0) {
154
+ const lines = ['## Sessions', '', '| slug | name | match |', '|------|------|-------|'];
155
+ for (const r of sessions)
156
+ lines.push(`| ${r.slug} | ${r.name} | ${r.snippet} |`);
157
+ sections.push(lines.join('\n'));
158
+ }
121
159
  return sections.join('\n\n');
122
160
  }
123
161
  export function formatStats(stats) {
@@ -159,13 +197,32 @@ export function formatChangelog(entries) {
159
197
  const rows = entries.map(e => `| ${e.slug} | ${e.kind} | ${e.operation} | ${e.timestamp.slice(0, 10)} | ${e.commit_message} |`);
160
198
  return [header, sep, ...rows].join('\n');
161
199
  }
200
+ /** The Health pass as markdown: one section per check, every section
201
+ * printed, so an empty report still shows what was checked. */
202
+ export function formatHealth(report) {
203
+ const out = ['## Health'];
204
+ const section = (title, items) => {
205
+ out.push('', `### ${title} (${items.length})`);
206
+ for (const item of items)
207
+ out.push(`- ${item}`);
208
+ };
209
+ // The workstream Item's own text (ADR 0001), prefixed with the entity.
210
+ section('Workstreams', report.workstreams.map(w => `${w.entity} ${ITEMS.workstreamLine.render(w)}`));
211
+ section('Recurring names', report.recurring_names.map(r => `${r.entity}: \`${r.term}\` in ${r.units} units across ${r.days.length} days (${r.days[0]} .. ${r.days[r.days.length - 1]})`));
212
+ section('Leakage', report.leakage.map(l => `${l.parent} → ${l.child} (${l.relation}): ${l.hits} of ${l.entries} entries (${Math.round(100 * l.hits / l.entries)}%)`));
213
+ section('One-way edges', report.one_way_edges.map(e => `${e.from} → ${e.to}: \`hafez link ${e.to} ${e.from} --relation related\``));
214
+ section('Unlinked mentions', report.unlinked_mentions.map(e => `${e.from} mentions [[${e.to}]]: \`hafez link ${e.from} ${e.to} --relation related\``));
215
+ section('Restated decisions', report.restated_decisions.map(r => `${r.day} ${r.entity} → ${r.note} (${Math.round(100 * r.share)}%): ${r.summary}`));
216
+ return out.join('\n');
217
+ }
162
218
  export function formatValidation(report) {
163
219
  const issues = report.broken_slugs.length + report.orphaned_knowledge.length +
164
220
  report.missing_fields.length;
221
+ const counts = `${report.total_entities} entities, ${report.total_knowledge} knowledge, ${report.total_sessions} sessions`;
165
222
  if (issues === 0) {
166
- return `Vault OK — ${report.total_entities} entities, ${report.total_knowledge} knowledge, 0 issues`;
223
+ return `Vault OK — ${counts}, 0 issues`;
167
224
  }
168
- const sections = [`## Vault Validation — ${report.total_entities} entities, ${report.total_knowledge} knowledge`];
225
+ const sections = [`## Vault Validation — ${counts}`];
169
226
  const addSection = (title, items) => {
170
227
  if (items.length === 0)
171
228
  return;
package/dist/cli/index.js CHANGED
@@ -41,6 +41,8 @@ export const COMMANDS = {
41
41
  delete: { phase: 'vault', mutating: true, run: lazy(m => m.cmdDelete) },
42
42
  batch: { phase: 'vault', mutating: true, run: lazy(m => m.cmdBatch) },
43
43
  validate: { phase: 'vault', run: lazy(m => m.cmdValidate) },
44
+ health: { phase: 'vault', run: lazy(m => m.cmdHealth) },
45
+ graph: { phase: 'vault', run: lazy(m => m.cmdGraph) },
44
46
  index: {
45
47
  phase: 'vault',
46
48
  run: async (os, args) => {
@@ -68,6 +70,7 @@ export const EXIT_TABLE = [
68
70
  { exit: 5, meaning: 'slug already exists', errorCodes: ['SLUG_EXISTS'] },
69
71
  { exit: 6, meaning: 'git commit failed — nothing was written to history', errorCodes: ['GIT_COMMIT_FAILED'] },
70
72
  { exit: 7, meaning: 'vault locked by another hafez process — retry when it finishes', errorCodes: ['VAULT_LOCKED'] },
73
+ { exit: 8, meaning: "git read failed (changelog, stats) — the vault's git is unusable", errorCodes: ['GIT_READ_FAILED'] },
71
74
  ];
72
75
  export const EXIT_CODES = Object.fromEntries(EXIT_TABLE.flatMap(row => (row.errorCodes ?? []).map(code => [code, row.exit])));
73
76
  /** Exported so a guard test can assert every registered command is documented
@@ -75,11 +78,14 @@ export const EXIT_CODES = Object.fromEntries(EXIT_TABLE.flatMap(row => (row.erro
75
78
  export const USAGE = `Usage: hafez <command> [args]
76
79
 
77
80
  Commands:
78
- read <slug> Read entity or knowledge note
81
+ read <slug> Read an entity, knowledge note or session note
82
+ --archive An entity's archived log entries
79
83
  query [--filter ...] ... Filter entities/knowledge
80
- --kind all Both kinds; --filter matches entity status only,
81
- so knowledge notes (which have none) always show
82
- search <terms> Full-text search
84
+ --kind all Every kind, sessions included; --filter matches
85
+ entity status only, so the rest always show
86
+ --kind session Session notes alone, newest first
87
+ search <terms> Full-text search of entities and knowledge
88
+ --kind session|all Session notes alone, or every kind
83
89
  create <kind> <name> Create entity or knowledge note
84
90
  capture <name> [--notes] Quick inbox capture
85
91
  promote <slug> <target> Promote capture→entity/project/knowledge, entity→project
@@ -105,15 +111,24 @@ Commands:
105
111
  changelog --since <when> Git-derived change history (e.g. --since 7.days.ago)
106
112
  schema [op] [--examples] Machine-readable JSON schema for batch operations
107
113
  validate Check vault integrity
114
+ health The Health pass: what placement would change (applies nothing)
115
+ graph --json The vault's structure: entities and knowledge, parent/related/wiki-link edges
116
+ --layers tag,domain,session Add tag and domain nodes, and sessions
117
+ graph [slug] [--port N] [--no-open]
118
+ Serve the graph on 127.0.0.1 (first free port from 4747) and open the browser;
119
+ a slug opens it focused on that note, local graph at depth 2
108
120
  index rebuild Rebuild the SQLite index
109
121
  init --register <path> Register an existing vault
110
122
  init Show current vault resolution
111
123
  onboard Setup status, then the script your agent follows to set Hafez up
112
- --install --mode <always-on|invoke-only> [--harness <h>] [--confirm]
124
+ --install --mode <always-on|invoke-only> [--harness <h>] [--vault-repo <o/r>] [--confirm]
113
125
  Write both skills and, always-on, the Block
126
+ (--vault-repo: the cloud Block, which attaches that repo)
114
127
  --remove [--confirm] Remove the skills and the Block region
115
128
  --block [--harness <h>] Print the Block and the entities matching this folder
116
129
  (the SessionStart hook; prints nothing on any failure)
130
+ --cloud --vault-repo <o/r>
131
+ Print the setup script for Claude Code cloud sessions
117
132
  upgrade Check whether hafez is outdated and print the update steps
118
133
  help --agent The hafez skill body: the reference when the skill is not installed
119
134
 
@@ -278,22 +293,28 @@ export async function main(argv) {
278
293
  // First touch of the core — and so of the native SQLite binding. A
279
294
  // failure to load it lands in the catch below and is recognised by
280
295
  // isBindingsFailure, exactly as it was when this import was static.
281
- const { createHafez } = await import('../index.js');
282
- const os = createHafez({ vaultPath, git: { push: false } });
283
- const result = await spec.run(os, args, { json, vaultPath });
284
- // Auto-sync after mutating commands
285
- if (spec.mutating) {
286
- try {
287
- await os.sync();
288
- }
289
- catch (syncErr) {
290
- // Operation succeeded but sync failed — emit result with warning, exit 4
291
- process.stdout.write(result + '\n');
292
- process.stderr.write(`Warning: ${syncErr.message ?? syncErr.message}\n`);
293
- process.exit(4);
296
+ const { createInternalHafez } = await import('../internal.js');
297
+ // One index scan per process (#269): the first read syncs, later reads trust it.
298
+ const os = createInternalHafez({ vaultPath, git: { push: false }, indexSync: 'once' });
299
+ try {
300
+ const result = await spec.run(os, args, { json, vaultPath });
301
+ // Auto-sync after mutating commands
302
+ if (spec.mutating) {
303
+ try {
304
+ await os.sync();
305
+ }
306
+ catch (syncErr) {
307
+ // Operation succeeded but sync failed — emit result with warning, exit 4
308
+ process.stdout.write(result + '\n');
309
+ process.stderr.write(`Warning: ${syncErr.message ?? syncErr.message}\n`);
310
+ process.exit(4);
311
+ }
294
312
  }
313
+ process.stdout.write(result + '\n');
314
+ }
315
+ finally {
316
+ os.close();
295
317
  }
296
- process.stdout.write(result + '\n');
297
318
  }
298
319
  catch (err) {
299
320
  if (hook)
@@ -6,6 +6,8 @@ export declare const SKILL_DIRS: string[];
6
6
  export type HashState = 'absent' | 'current' | 'stale' | 'hand-edited';
7
7
  /** What to do about broken markers: a hand edit, then `rerun`. */
8
8
  export declare const fixBrokenMarkers: (path: string, rerun: string) => string;
9
+ /** The flag that keeps an install a cloud one, for every printed install line. */
10
+ export declare const vaultRepoFlag: (vaultRepo: string | undefined) => string;
9
11
  /** What to do about a 1.x section: Hafez cannot tell where it ends. */
10
12
  export declare const removeLegacy: (path: string, line: number) => string;
11
13
  /** The status line for a 1.x section. */
@@ -24,13 +26,18 @@ export interface InstallState {
24
26
  onboarded: boolean;
25
27
  /** The line of a 1.x section in the instructions file, else null. */
26
28
  legacy: number | null;
29
+ /** The Block is a cloud Block, for this vault repo. */
30
+ vaultRepo?: string;
27
31
  }
28
32
  export declare function installState(home: string, harness: HarnessId): InstallState;
33
+ /** `vaultRepo`: the install is for a cloud container, whose Block carries
34
+ * the rule that attaches that repo as the vault (#160). */
29
35
  export declare function install(opts: {
30
36
  home: string;
31
37
  harness: HarnessId;
32
38
  mode: Mode;
33
39
  confirm: boolean;
40
+ vaultRepo?: string;
34
41
  }): string;
35
42
  export interface SkillCheck {
36
43
  path: string;
@@ -43,6 +50,8 @@ export interface BlockCheck {
43
50
  state: Exclude<HashState, 'absent'> | 'broken';
44
51
  /** The Block Hafez writes has changed since this one was written. */
45
52
  renderChanged: boolean;
53
+ /** A cloud Block: the vault repo it was written for. */
54
+ vaultRepo?: string;
46
55
  }
47
56
  export interface LegacyCheck {
48
57
  harness: HarnessId;