hafez 2.0.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.
@@ -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);
@@ -356,11 +407,11 @@ export async function cmdCreate(os, args, opts = {}) {
356
407
  }
357
408
  throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
358
409
  }
359
- const UPDATE_USAGE = 'Usage: hafez update <slug> [--brief text] [--description text] [--resource uri] [--add-action text (repeats)] [--complete-action handle-or-text] [--remove-action handle-or-text] [--clear-actions] [--status <active|paused|done>] [--parent slug] [--log "<progress|decision|blocker|research>: summary" [--body text]]';
410
+ const UPDATE_USAGE = 'Usage: hafez update <slug> [--brief text] [--description text] [--resource uri] [--add-action text (repeats)] [--complete-action handle-or-text (repeats)] [--remove-action handle-or-text (repeats)] [--clear-actions] [--status <active|paused|done>] [--parent slug] [--log "<progress|decision|blocker|research>: summary" [--body text]]';
360
411
  /** Exported so tests can assert the table covers the update schema. */
361
412
  export const UPDATE_FLAGS = {
362
413
  status: 'string', 'current-state': 'string', brief: 'string',
363
- 'add-action': 'repeat', 'complete-action': 'string', 'remove-action': 'string',
414
+ 'add-action': 'repeat', 'complete-action': 'repeat', 'remove-action': 'repeat',
364
415
  'clear-actions': 'boolean', parent: 'string',
365
416
  description: 'string', resource: 'string', domain: 'string', confidence: 'string',
366
417
  tags: 'string', related: 'string', insight: 'string', synthesis: 'string',
@@ -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;
@@ -1,6 +1,7 @@
1
1
  /** `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) {
2
+ * parent's description); absent, the slug alone. `pointers` are the lines
3
+ * naming what hangs off the entity elsewhere (its sessions, its archive). */
4
+ export function formatEntityHeader(fm, body, parent = fm.parent, pointers = []) {
4
5
  const lines = [`# ${fm.name}`];
5
6
  if (fm.description)
6
7
  lines.push(`> ${fm.description}`);
@@ -29,6 +30,23 @@ export function formatEntityHeader(fm, body, parent = fm.parent) {
29
30
  lines.push(`tags: ${fm.tags.join(', ')}`);
30
31
  if (fm.resource)
31
32
  lines.push(`resource: ${fm.resource}`);
33
+ lines.push(...pointers);
34
+ lines.push('');
35
+ lines.push(body);
36
+ return lines.join('\n');
37
+ }
38
+ /** A session note: its day, then the entities it touched. */
39
+ export function formatSessionHeader(fm, body) {
40
+ const lines = [`# ${fm.name}`];
41
+ const meta = [];
42
+ if (fm['session-date'])
43
+ meta.push(`session-date: ${fm['session-date']}`);
44
+ if (fm.created)
45
+ meta.push(`created: ${fm.created}`);
46
+ if (meta.length)
47
+ lines.push(meta.join(' | '));
48
+ if (fm.related?.length)
49
+ lines.push(`related: ${fm.related.join(', ')}`);
32
50
  lines.push('');
33
51
  lines.push(body);
34
52
  return lines.join('\n');
@@ -100,11 +118,24 @@ export function formatKnowledgeTable(results, total) {
100
118
  }
101
119
  return lines.join('\n');
102
120
  }
121
+ export function formatSessionTable(results, total) {
122
+ if (results.length === 0)
123
+ return 'No results.';
124
+ const lines = ['| slug | name | session-date | related |', '|------|------|--------------|---------|'];
125
+ for (const r of results) {
126
+ lines.push(`| ${r.slug} | ${r.name} | ${r.session_date} | ${r.related.join(', ')} |`);
127
+ }
128
+ if (total != null && results.length < total) {
129
+ lines.push(`\nShowing ${results.length} of ${total}`);
130
+ }
131
+ return lines.join('\n');
132
+ }
103
133
  export function formatSearchResults(results) {
104
134
  if (results.length === 0)
105
135
  return 'No results.';
106
136
  const entities = results.filter(r => r.kind === 'entity');
107
137
  const knowledge = results.filter(r => r.kind === 'knowledge');
138
+ const sessions = results.filter(r => r.kind === 'session');
108
139
  const sections = [];
109
140
  if (entities.length > 0) {
110
141
  const lines = ['## Entities', '', '| slug | name | type | match |', '|------|------|------|-------|'];
@@ -118,6 +149,12 @@ export function formatSearchResults(results) {
118
149
  lines.push(`| ${r.slug} | ${r.name} | ${r.confidence ?? ''} | ${r.snippet} |`);
119
150
  sections.push(lines.join('\n'));
120
151
  }
152
+ if (sessions.length > 0) {
153
+ const lines = ['## Sessions', '', '| slug | name | match |', '|------|------|-------|'];
154
+ for (const r of sessions)
155
+ lines.push(`| ${r.slug} | ${r.name} | ${r.snippet} |`);
156
+ sections.push(lines.join('\n'));
157
+ }
121
158
  return sections.join('\n\n');
122
159
  }
123
160
  export function formatStats(stats) {
@@ -162,10 +199,11 @@ export function formatChangelog(entries) {
162
199
  export function formatValidation(report) {
163
200
  const issues = report.broken_slugs.length + report.orphaned_knowledge.length +
164
201
  report.missing_fields.length;
202
+ const counts = `${report.total_entities} entities, ${report.total_knowledge} knowledge, ${report.total_sessions} sessions`;
165
203
  if (issues === 0) {
166
- return `Vault OK — ${report.total_entities} entities, ${report.total_knowledge} knowledge, 0 issues`;
204
+ return `Vault OK — ${counts}, 0 issues`;
167
205
  }
168
- const sections = [`## Vault Validation — ${report.total_entities} entities, ${report.total_knowledge} knowledge`];
206
+ const sections = [`## Vault Validation — ${counts}`];
169
207
  const addSection = (title, items) => {
170
208
  if (items.length === 0)
171
209
  return;
package/dist/cli/index.js CHANGED
@@ -68,6 +68,7 @@ export const EXIT_TABLE = [
68
68
  { exit: 5, meaning: 'slug already exists', errorCodes: ['SLUG_EXISTS'] },
69
69
  { exit: 6, meaning: 'git commit failed — nothing was written to history', errorCodes: ['GIT_COMMIT_FAILED'] },
70
70
  { exit: 7, meaning: 'vault locked by another hafez process — retry when it finishes', errorCodes: ['VAULT_LOCKED'] },
71
+ { exit: 8, meaning: "git read failed (changelog, stats) — the vault's git is unusable", errorCodes: ['GIT_READ_FAILED'] },
71
72
  ];
72
73
  export const EXIT_CODES = Object.fromEntries(EXIT_TABLE.flatMap(row => (row.errorCodes ?? []).map(code => [code, row.exit])));
73
74
  /** Exported so a guard test can assert every registered command is documented
@@ -75,11 +76,14 @@ export const EXIT_CODES = Object.fromEntries(EXIT_TABLE.flatMap(row => (row.erro
75
76
  export const USAGE = `Usage: hafez <command> [args]
76
77
 
77
78
  Commands:
78
- read <slug> Read entity or knowledge note
79
+ read <slug> Read an entity, knowledge note or session note
80
+ --archive An entity's archived log entries
79
81
  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
82
+ --kind all Every kind, sessions included; --filter matches
83
+ entity status only, so the rest always show
84
+ --kind session Session notes alone, newest first
85
+ search <terms> Full-text search of entities and knowledge
86
+ --kind session|all Session notes alone, or every kind
83
87
  create <kind> <name> Create entity or knowledge note
84
88
  capture <name> [--notes] Quick inbox capture
85
89
  promote <slug> <target> Promote capture→entity/project/knowledge, entity→project
@@ -88,9 +92,9 @@ Commands:
88
92
  --brief "text" Set/replace Brief (use --brief "" to clear)
89
93
  --description "text" Set one-line description (use --description "" to clear)
90
94
  --resource "uri" Set canonical resource URI, entities only (use --resource "" to clear)
91
- --add-action "text" Add a next action
92
- --complete-action handle-or-text Complete the action with that Handle (as read shows it), else the matching one
93
- --remove-action handle-or-text Remove the action with that Handle, else the matching one
95
+ --add-action "text" Add a next action (repeats)
96
+ --complete-action handle-or-text Complete the action with that Handle (as read shows it), else the matching one (repeats)
97
+ --remove-action handle-or-text Remove the action with that Handle, else the matching one (repeats)
94
98
  --clear-actions Remove all next actions
95
99
  --synthesis "text" Set/replace Synthesis section (--insight is an alias)
96
100
  --add-evidence "text" Append to Evidence section
@@ -109,11 +113,14 @@ Commands:
109
113
  init --register <path> Register an existing vault
110
114
  init Show current vault resolution
111
115
  onboard Setup status, then the script your agent follows to set Hafez up
112
- --install --mode <always-on|invoke-only> [--harness <h>] [--confirm]
116
+ --install --mode <always-on|invoke-only> [--harness <h>] [--vault-repo <o/r>] [--confirm]
113
117
  Write both skills and, always-on, the Block
118
+ (--vault-repo: the cloud Block, which attaches that repo)
114
119
  --remove [--confirm] Remove the skills and the Block region
115
120
  --block [--harness <h>] Print the Block and the entities matching this folder
116
121
  (the SessionStart hook; prints nothing on any failure)
122
+ --cloud --vault-repo <o/r>
123
+ Print the setup script for Claude Code cloud sessions
117
124
  upgrade Check whether hafez is outdated and print the update steps
118
125
  help --agent The hafez skill body: the reference when the skill is not installed
119
126
 
@@ -280,20 +287,25 @@ export async function main(argv) {
280
287
  // isBindingsFailure, exactly as it was when this import was static.
281
288
  const { createHafez } = await import('../index.js');
282
289
  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);
290
+ try {
291
+ const result = await spec.run(os, args, { json, vaultPath });
292
+ // Auto-sync after mutating commands
293
+ if (spec.mutating) {
294
+ try {
295
+ await os.sync();
296
+ }
297
+ catch (syncErr) {
298
+ // Operation succeeded but sync failed — emit result with warning, exit 4
299
+ process.stdout.write(result + '\n');
300
+ process.stderr.write(`Warning: ${syncErr.message ?? syncErr.message}\n`);
301
+ process.exit(4);
302
+ }
294
303
  }
304
+ process.stdout.write(result + '\n');
305
+ }
306
+ finally {
307
+ os.close();
295
308
  }
296
- process.stdout.write(result + '\n');
297
309
  }
298
310
  catch (err) {
299
311
  if (hook)
@@ -6,6 +6,12 @@ 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;
11
+ /** What to do about a 1.x section: Hafez cannot tell where it ends. */
12
+ export declare const removeLegacy: (path: string, line: number) => string;
13
+ /** The status line for a 1.x section. */
14
+ export declare const legacyLine: (path: string, line: number) => string;
9
15
  /** Codex reads an `AGENTS.override.md` in place of `AGENTS.md` when one
10
16
  * exists: the first candidate present, else the last. */
11
17
  export declare const instructionsFile: (home: string, candidates: readonly string[]) => string;
@@ -18,13 +24,20 @@ export interface InstallState {
18
24
  skills: HashState;
19
25
  /** Some skill file carries a hash: `onboard --install` has run here. */
20
26
  onboarded: boolean;
27
+ /** The line of a 1.x section in the instructions file, else null. */
28
+ legacy: number | null;
29
+ /** The Block is a cloud Block, for this vault repo. */
30
+ vaultRepo?: string;
21
31
  }
22
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). */
23
35
  export declare function install(opts: {
24
36
  home: string;
25
37
  harness: HarnessId;
26
38
  mode: Mode;
27
39
  confirm: boolean;
40
+ vaultRepo?: string;
28
41
  }): string;
29
42
  export interface SkillCheck {
30
43
  path: string;
@@ -37,13 +50,21 @@ export interface BlockCheck {
37
50
  state: Exclude<HashState, 'absent'> | 'broken';
38
51
  /** The Block Hafez writes has changed since this one was written. */
39
52
  renderChanged: boolean;
53
+ /** A cloud Block: the vault repo it was written for. */
54
+ vaultRepo?: string;
55
+ }
56
+ export interface LegacyCheck {
57
+ harness: HarnessId;
58
+ path: string;
59
+ line: number;
40
60
  }
41
61
  /** Both skills in both directories, and the Block in the instructions file
42
62
  * each Harness reads — the one `--install` would write. Only Blocks that are
43
- * there are listed: none means invoke-only. */
63
+ * there are listed: none, and no 1.x section, means invoke-only. */
44
64
  export declare function checkInstall(home: string): {
45
65
  skills: SkillCheck[];
46
66
  blocks: BlockCheck[];
67
+ legacy: LegacyCheck[];
47
68
  };
48
69
  /** Remove everything Hafez wrote: both skills from both directories, and the
49
70
  * Block region from every instructions file a Harness reads. Nothing else. */