hafez 2.2.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 (67) hide show
  1. package/README.md +48 -1
  2. package/dist/apply.d.ts +12 -0
  3. package/dist/apply.js +82 -17
  4. package/dist/batch-ops.d.ts +31 -27
  5. package/dist/batch-ops.js +80 -15
  6. package/dist/cli/block-hook.d.ts +6 -0
  7. package/dist/cli/block-hook.js +3 -3
  8. package/dist/cli/commands.d.ts +2 -0
  9. package/dist/cli/commands.js +98 -46
  10. package/dist/cli/format.d.ts +4 -1
  11. package/dist/cli/format.js +19 -0
  12. package/dist/cli/index.js +11 -2
  13. package/dist/cli/upgrade.js +1 -1
  14. package/dist/contracts.d.ts +9 -0
  15. package/dist/contracts.js +9 -0
  16. package/dist/dates.d.ts +3 -0
  17. package/dist/dates.js +9 -0
  18. package/dist/db.d.ts +34 -4
  19. package/dist/db.js +163 -21
  20. package/dist/digest.js +14 -7
  21. package/dist/document.d.ts +2 -0
  22. package/dist/document.js +2 -2
  23. package/dist/git.d.ts +13 -2
  24. package/dist/git.js +47 -5
  25. package/dist/graph/activity.d.ts +5 -0
  26. package/dist/graph/activity.js +70 -0
  27. package/dist/graph/client.js +326 -0
  28. package/dist/graph/copy-fonts.d.ts +1 -0
  29. package/dist/graph/copy-fonts.js +13 -0
  30. package/dist/graph/fields.d.ts +8 -0
  31. package/dist/graph/fields.js +12 -0
  32. package/dist/graph/fonts/geist-OFL.txt +93 -0
  33. package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
  34. package/dist/graph/fonts/geist-mono.woff2 +0 -0
  35. package/dist/graph/fonts/geist.woff2 +0 -0
  36. package/dist/graph/fonts/outfit-OFL.txt +93 -0
  37. package/dist/graph/fonts/outfit.woff2 +0 -0
  38. package/dist/graph/fonts.d.ts +14 -0
  39. package/dist/graph/fonts.js +17 -0
  40. package/dist/graph/live.d.ts +16 -0
  41. package/dist/graph/live.js +99 -0
  42. package/dist/graph/open.d.ts +1 -0
  43. package/dist/graph/open.js +20 -0
  44. package/dist/graph/server.d.ts +17 -0
  45. package/dist/graph/server.js +189 -0
  46. package/dist/health.d.ts +6 -0
  47. package/dist/health.js +310 -0
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +37 -9
  50. package/dist/internal.d.ts +21 -0
  51. package/dist/internal.js +9 -0
  52. package/dist/read-json.d.ts +26 -0
  53. package/dist/read-json.js +53 -0
  54. package/dist/stale.d.ts +2 -2
  55. package/dist/stale.js +4 -9
  56. package/dist/surfaces/items.d.ts +44 -0
  57. package/dist/surfaces/items.js +68 -9
  58. package/dist/surfaces/surfaces.js +3 -1
  59. package/dist/templates.d.ts +2 -0
  60. package/dist/templates.js +6 -1
  61. package/dist/types.d.ts +178 -8
  62. package/dist/vault.d.ts +9 -2
  63. package/dist/vault.js +10 -0
  64. package/dist/workstream.d.ts +17 -0
  65. package/dist/workstream.js +78 -0
  66. package/package.json +9 -3
  67. package/skill/hafez/SKILL.md +12 -6
@@ -0,0 +1,26 @@
1
+ import type { Hafez, ParsedFile, ReadDepth } from './types.js';
2
+ import { type Workstream } from './workstream.js';
3
+ import { type ChildLine, type FooterInput } from './surfaces/items.js';
4
+ /** `read --json`'s object for a note read at `depth`: what the CLI prints and
5
+ * what the graph server's `/note/:slug` serves. `whole`, its full-depth read
6
+ * when the caller holds one, saves reading it again. */
7
+ export declare function readJsonOf(os: Hafez, slug: string, depth: ReadDepth, result: ParsedFile, whole?: ParsedFile): Promise<object>;
8
+ /** What hangs off an entity elsewhere: the sessions that touched it (the
9
+ * look back, #35), its archived log entries (#34), its children (#230) and
10
+ * the prefix families its own Next Actions carry unsplit against the
11
+ * entities whose `parent` or `related` names it (#230). Counted at read
12
+ * time, nothing written. */
13
+ export declare function pointersFor(os: Hafez, slug: string, body: string): Promise<{
14
+ sessions: {
15
+ count: number;
16
+ latest: string | null;
17
+ };
18
+ archive_entries: number;
19
+ children: ChildLine[];
20
+ workstreams: Workstream[];
21
+ }>;
22
+ /** The Footer for a file read at full depth: its Handle comes from the
23
+ * whole file. A session note has none: it is never written to. */
24
+ export declare function footerFor(slug: string, result: Exclude<ParsedFile, {
25
+ kind: 'session';
26
+ }>): FooterInput;
@@ -0,0 +1,53 @@
1
+ import { detectAgent } from './harness.js';
2
+ import { getActionItems, parseSessionLogHeading } from './document.js';
3
+ import { agedActions } from './stale.js';
4
+ import { workstreams } from './workstream.js';
5
+ import { CHILDREN_SHOWN, footerOp } from './surfaces/items.js';
6
+ /** `read --json`'s object for a note read at `depth`: what the CLI prints and
7
+ * what the graph server's `/note/:slug` serves. `whole`, its full-depth read
8
+ * when the caller holds one, saves reading it again. */
9
+ export async function readJsonOf(os, slug, depth, result, whole) {
10
+ // A session note is read, never written: no Footer (#35).
11
+ if (result.kind === 'session')
12
+ return result;
13
+ // The Footer and the pointers are the same at every depth: they read the
14
+ // whole file, not the body this depth shows.
15
+ const full = (whole ?? (depth === 'full' ? result : await os.read(slug, 'full')));
16
+ const writes = footerOp(footerFor(slug, full));
17
+ const pointers = full.kind === 'entity' ? await pointersFor(os, slug, full.body) : null;
18
+ const json = pointers && { ...pointers, children: pointers.children.slice(0, CHILDREN_SHOWN), children_total: pointers.children.length };
19
+ // A frontmatter-depth read carries no body, so it has no actions to list.
20
+ if (result.kind !== 'entity' || depth === 'frontmatter')
21
+ return { ...result, ...json, writes };
22
+ return { ...result, actions: agedActions(result.body, result.frontmatter), ...json, writes };
23
+ }
24
+ /** What hangs off an entity elsewhere: the sessions that touched it (the
25
+ * look back, #35), its archived log entries (#34), its children (#230) and
26
+ * the prefix families its own Next Actions carry unsplit against the
27
+ * entities whose `parent` or `related` names it (#230). Counted at read
28
+ * time, nothing written. */
29
+ export async function pointersFor(os, slug, body) {
30
+ const relatedTo = (await os.related_to(slug)).items;
31
+ const sessions = relatedTo.filter((i) => i.kind === 'session');
32
+ const latest = sessions.reduce((max, s) => (max === null || s.session_date > max ? s.session_date : max), null);
33
+ const archive = await os.archive(slug);
34
+ const entries = archive ? archive.split('\n').filter(line => parseSessionLogHeading(line)).length : 0;
35
+ // The children block (#230): the index's children query, last-touched
36
+ // descending then slug for a stable order; the renders cap what is shown.
37
+ const childItems = (await os.children(slug)).items;
38
+ const children = [...childItems]
39
+ .sort((a, b) => b.last_touched.localeCompare(a.last_touched) || a.slug.localeCompare(b.slug))
40
+ .map(c => ({ slug: c.slug, description: c.description, open_actions: c.next_action_count, last_touched: c.last_touched }));
41
+ const naming = [...childItems, ...relatedTo.filter(i => i.kind === 'entity')];
42
+ return { sessions: { count: sessions.length, latest }, archive_entries: entries, children, workstreams: workstreams(body, naming) };
43
+ }
44
+ /** The Footer for a file read at full depth: its Handle comes from the
45
+ * whole file. A session note has none: it is never written to. */
46
+ export function footerFor(slug, result) {
47
+ return { slug, agent: detectAgent(), ...(result.kind === 'entity'
48
+ ? {
49
+ target: { kind: 'entity', type: result.frontmatter.type },
50
+ handle: getActionItems(result.body).find(a => a.handle)?.handle,
51
+ }
52
+ : { target: { kind: 'knowledge', subtype: result.frontmatter.subtype } }) };
53
+ }
package/dist/stale.d.ts CHANGED
@@ -9,8 +9,8 @@ export interface ActionAge {
9
9
  }
10
10
  /** The entity side of the rule, as SQL over an index row `i` with `@today`
11
11
  * bound to localDay(): whole days from its last-touched day to today.
12
- * julianday of two bare YYYY-MM-DD days differs by exactly `dayNumber`'s
13
- * difference, so entities and their actions count the same days. */
12
+ * julianday of two bare YYYY-MM-DD days differs by exactly `daysBetween`
13
+ * (src/dates.ts), so entities and their actions count the same days. */
14
14
  export declare const ENTITY_AGE_DAYS_SQL = "CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)";
15
15
  /** An entity row is stale when its age is past its threshold, not equal to
16
16
  * it — `actionAge`'s rule. */
package/dist/stale.js CHANGED
@@ -2,19 +2,14 @@
2
2
  // Stale (see CONTEXT.md): past the entity's `staleness-days` without
3
3
  // movement. An open next action is stale when unticked that long since its
4
4
  // added day; an undated one is stale. Ages count local days (src/dates.ts).
5
- import { localDay } from './dates.js';
5
+ import { daysBetween, localDay } from './dates.js';
6
6
  import { getActionItems } from './document.js';
7
7
  /** The threshold an entity without `staleness-days` gets. */
8
8
  export const DEFAULT_STALENESS_DAYS = 14;
9
- /** A YYYY-MM-DD day as a whole day count, so differences ignore DST. */
10
- const dayNumber = (day) => {
11
- const [y, m, d] = day.split('-').map(Number);
12
- return Date.UTC(y, m - 1, d) / 86_400_000;
13
- };
14
9
  /** The entity side of the rule, as SQL over an index row `i` with `@today`
15
10
  * bound to localDay(): whole days from its last-touched day to today.
16
- * julianday of two bare YYYY-MM-DD days differs by exactly `dayNumber`'s
17
- * difference, so entities and their actions count the same days. */
11
+ * julianday of two bare YYYY-MM-DD days differs by exactly `daysBetween`
12
+ * (src/dates.ts), so entities and their actions count the same days. */
18
13
  export const ENTITY_AGE_DAYS_SQL = 'CAST(julianday(@today) - julianday(i.last_touched) AS INTEGER)';
19
14
  /** An entity row is stale when its age is past its threshold, not equal to
20
15
  * it — `actionAge`'s rule. */
@@ -24,7 +19,7 @@ export const ENTITY_STALE_SQL = `${ENTITY_AGE_DAYS_SQL} > COALESCE(i.staleness_d
24
19
  export function actionAge(added, stalenessDays, today = localDay()) {
25
20
  if (added === null)
26
21
  return { added, age_days: null, stale: true };
27
- const age_days = dayNumber(today) - dayNumber(added);
22
+ const age_days = daysBetween(added, today);
28
23
  return { added, age_days, stale: age_days > stalenessDays };
29
24
  }
30
25
  /** An entity's threshold: its `staleness-days`, else the default. */
@@ -1,6 +1,7 @@
1
1
  import { type DocTarget, type WriteLineField } from '../contracts.js';
2
2
  import { renderActionHandles } from '../document.js';
3
3
  import type { ActionAge } from '../stale.js';
4
+ import type { Workstream } from '../workstream.js';
4
5
  import type { KnowledgeSubtype, UpdateFields, UpdateResult } from '../types.js';
5
6
  import { type OnboardHarness } from '../harness.js';
6
7
  import type { Mode } from '../cli/install.js';
@@ -112,6 +113,28 @@ declare function renderArchivePointer({ slug, entries }: {
112
113
  slug: string;
113
114
  entries: number;
114
115
  }): string;
116
+ /** A child as the `read` header shows it. */
117
+ export interface ChildLine {
118
+ slug: string;
119
+ description: string | null;
120
+ open_actions: number;
121
+ last_touched: string;
122
+ }
123
+ /** How many children the header lists; the rest are one overflow line. */
124
+ export declare const CHILDREN_SHOWN = 5;
125
+ /** The `read` header's children block: the count line (children, open
126
+ * actions across all of them), one `hafez read` line per child (description,
127
+ * open count, last-touched day) for the first `CHILDREN_SHOWN`, and the
128
+ * overflow line naming the query that lists the rest. Children arrive sorted
129
+ * by last-touched descending. No children: no block. */
130
+ declare function renderChildrenBlock({ slug, children }: {
131
+ slug: string;
132
+ children: ChildLine[];
133
+ }): string;
134
+ /** The `read` header's fact line for one unsplit prefix family (#230):
135
+ * its open-line count and the family, and that no child carries it. A
136
+ * fact, never a filled create line or a question. */
137
+ declare function renderWorkstreamLine({ prefix, open_actions }: Workstream): string;
115
138
  /** The line after an update's success while Stale lines remain on the entity. */
116
139
  declare function renderStaleReminder({ handles }: {
117
140
  handles: string[];
@@ -186,6 +209,20 @@ export declare const ITEMS: {
186
209
  description: string | null;
187
210
  }) => string;
188
211
  };
212
+ childrenBlock: {
213
+ label: string;
214
+ owner: "readHeader";
215
+ invokeOnly: string;
216
+ evidence: string;
217
+ render: typeof renderChildrenBlock;
218
+ };
219
+ workstreamLine: {
220
+ label: string;
221
+ owner: "readHeader";
222
+ invokeOnly: string;
223
+ evidence: string;
224
+ render: typeof renderWorkstreamLine;
225
+ };
189
226
  sessionsLine: {
190
227
  label: string;
191
228
  owner: "readHeader";
@@ -360,6 +397,13 @@ export declare const ITEMS: {
360
397
  evidence: string;
361
398
  text: string;
362
399
  };
400
+ healthProcedure: {
401
+ label: string;
402
+ owner: "hafezSkillBody";
403
+ invokeOnly: string;
404
+ evidence: string;
405
+ text: string;
406
+ };
363
407
  batchShape: {
364
408
  label: string;
365
409
  owner: "hafezSkillBody";
@@ -176,6 +176,35 @@ function renderArchivePointer({ slug, entries }) {
176
176
  return '';
177
177
  return `archive: ${entries} older log ${entries === 1 ? 'entry' : 'entries'} · hafez read ${slug} --archive`;
178
178
  }
179
+ /** How many children the header lists; the rest are one overflow line. */
180
+ export const CHILDREN_SHOWN = 5;
181
+ /** The `read` header's children block: the count line (children, open
182
+ * actions across all of them), one `hafez read` line per child (description,
183
+ * open count, last-touched day) for the first `CHILDREN_SHOWN`, and the
184
+ * overflow line naming the query that lists the rest. Children arrive sorted
185
+ * by last-touched descending. No children: no block. */
186
+ function renderChildrenBlock({ slug, children }) {
187
+ if (children.length === 0)
188
+ return '';
189
+ const open = children.reduce((sum, c) => sum + c.open_actions, 0);
190
+ const shown = children.slice(0, CHILDREN_SHOWN);
191
+ const width = Math.max(...shown.map(c => c.slug.length));
192
+ const lines = [`children: ${children.length} · ${open} open action${open === 1 ? '' : 's'}`];
193
+ for (const c of shown) {
194
+ const facts = [c.description, `${c.open_actions} open`, c.last_touched].filter(Boolean).join(' · ');
195
+ lines.push(` hafez read ${c.slug.padEnd(width)} ${facts}`);
196
+ }
197
+ const rest = children.length - shown.length;
198
+ if (rest > 0)
199
+ lines.push(` … and ${rest} more · hafez query --parent ${slug}`);
200
+ return lines.join('\n');
201
+ }
202
+ /** The `read` header's fact line for one unsplit prefix family (#230):
203
+ * its open-line count and the family, and that no child carries it. A
204
+ * fact, never a filled create line or a question. */
205
+ function renderWorkstreamLine({ prefix, open_actions }) {
206
+ return `workstream: ${open_actions} open action${open_actions === 1 ? '' : 's'} with \`${prefix}\`; no child named it`;
207
+ }
179
208
  /** The line after an update's success while Stale lines remain on the entity. */
180
209
  function renderStaleReminder({ handles }) {
181
210
  const n = handles.length;
@@ -193,7 +222,10 @@ function renderUpdateMisuse(input) {
193
222
  }
194
223
  const CREATE_TYPES = ['project', 'entity'];
195
224
  const RELATIONS = ['parent', 'related'];
196
- const createEntityLine = (name) => `hafez create entity "${name}" --type ${oneOf(CREATE_TYPES)} --description "<one sentence>" --brief "<where things stand>" --add-action "<next step>"`;
225
+ /** The entity create line, one text everywhere: the Block, the skill body and
226
+ * the `read` miss. `--parent` carries the placement rule in its placeholder
227
+ * (#226, #230); at a miss with nothing read the agent drops it. */
228
+ const createEntityLine = (name) => `hafez create entity "${name}" --type ${oneOf(CREATE_TYPES)} --parent <slug you read, or its parent> --description "<one sentence>" --brief "<where things stand>" --add-action "<next step>"`;
197
229
  const CREATE_LINES = fence([
198
230
  createEntityLine('<name>'),
199
231
  `hafez create knowledge "<name>" --synthesis "<the insight>" --confidence ${oneOf(CONFIDENCE_LEVELS)} --related <slug>`,
@@ -305,6 +337,20 @@ export const ITEMS = {
305
337
  * in brackets when it has one. */
306
338
  render: ({ slug, description }) => description ? `${slug} (${description})` : slug,
307
339
  },
340
+ childrenBlock: {
341
+ label: 'Children block: the Hub\'s children as read lines',
342
+ owner: 'readHeader',
343
+ invokeOnly: 'same',
344
+ evidence: '#230',
345
+ render: renderChildrenBlock,
346
+ },
347
+ workstreamLine: {
348
+ label: 'Workstream fact line: an unsplit prefix family',
349
+ owner: 'readHeader',
350
+ invokeOnly: 'same',
351
+ evidence: '#230',
352
+ render: renderWorkstreamLine,
353
+ },
308
354
  sessionsLine: {
309
355
  label: 'Sessions line: the look back from an entity',
310
356
  owner: 'readHeader',
@@ -320,14 +366,15 @@ export const ITEMS = {
320
366
  render: renderArchivePointer,
321
367
  },
322
368
  writeTriggers: {
323
- label: 'Write triggers: work landed, decision made, insight, weighing, "note this"',
369
+ label: 'Write triggers: work landed, a split, decision made, a lesson, weighing, "note this"',
324
370
  owner: 'block',
325
371
  invokeOnly: 'absent',
326
- evidence: 'M11, M2, never-ask decision #42, decision-record miss #159',
372
+ evidence: 'M11, M2, never-ask decision #42, decision-record miss #159; placement #226, #229',
327
373
  text: place => [
328
- '- Work landed (a commit, a merged PR, CI green): update the entity that owns the work. That is the entity you read, unless the work belongs to another: read that one first. The end of its `read` output shows the command.',
329
- '- A decision was made: if the repo keeps its own decisions record, write it there now and give Hafez one line that points at it (`hafez update <slug> --log "decision: <one line>, see decisions.md"`). Otherwise it goes in the save\'s `decisions`.',
330
- `- An insight emerged that is useful beyond this repo: create a knowledge note. ${place('knowledgeBoundary')}`,
374
+ '- Work landed (a commit, a merged PR, CI green): update the entity that owns the work. That is the entity you read; the entity you read has a child that owns it: write there; the work belongs to another: read that one first. The end of its `read` output shows the command.',
375
+ '- A workstream your own actions or the user already name, too big for one action, gets a Child with that name under the entity you read: create it with `--parent`, move its open actions there (one `update` on the parent: `--remove-action` each, `--log "decision: split <child> out, open actions moved"`), and write there from now on.',
376
+ '- A decision was made: one `decision` log entry, now, on the entity that owns the work. The repo keeps its own decisions record: write it there and the log entry points at it (`hafez update <slug> --log "decision: <one line>, see decisions.md"`).',
377
+ `- A lesson that holds without this entity: create a knowledge note; what we chose is a decision entry, not a note. ${place('knowledgeBoundary')}`,
331
378
  '- The user says "note this" or "record that": write it.',
332
379
  `- You are weighing whether something is worth writing: write it, as knowledge with \`--confidence ${CONFIDENCE_LEVELS[0]}\`.`,
333
380
  ].join('\n'),
@@ -390,7 +437,7 @@ export const ITEMS = {
390
437
  owner: 'block',
391
438
  fallback: 'hafezSkillBody',
392
439
  invokeOnly: 'via the skill body',
393
- evidence: 'fields: kind/name 18, synthesis 16, subtype 15',
440
+ evidence: 'fields: kind/name 18, synthesis 16, subtype 15; --parent #230',
394
441
  text: CREATE_LINES + '\n\nA plan is `--subtype plan` without `--synthesis`. Cannot tell entity from knowledge: `hafez capture "<name>"`.',
395
442
  /** The entity create line for one name: what the `read` miss places. */
396
443
  render: createEntityLine,
@@ -506,7 +553,7 @@ export const ITEMS = {
506
553
  + '\n\n`search` and `query` reach entities and knowledge notes; `--kind all` adds the session notes, `--kind session` lists them alone, newest first. `hafez read <slug> --archive` prints an entity\'s archived log entries.',
507
554
  },
508
555
  rareOps: {
509
- label: 'Rare ops: promote, link, unlink, status, delete, validate',
556
+ label: 'Rare ops: promote, link, unlink, status, delete, validate, health',
510
557
  owner: 'hafezSkillBody',
511
558
  invokeOnly: 'same',
512
559
  evidence: 'help decision #49; owner decision #43',
@@ -517,8 +564,20 @@ export const ITEMS = {
517
564
  `hafez update <slug> --status ${oneOf(ENTITY_STATUSES)}`,
518
565
  'hafez delete <slug>',
519
566
  'hafez validate',
567
+ 'hafez health',
520
568
  ].join('\n'))
521
- + `\n\nA plan's status is \`${PLAN_STATUSES.join('|')}\`. \`delete\` removes a knowledge or session note only; an entity is retired with \`--status done\`.`,
569
+ + `\n\nA plan's status is \`${PLAN_STATUSES.join('|')}\`. \`delete\` removes a knowledge or session note only; an entity is retired with \`--status done\`. \`health\` lists what placement would change and applies nothing.`,
570
+ },
571
+ healthProcedure: {
572
+ label: 'Health procedure: run with --json, propose by kind, apply nothing unconfirmed',
573
+ owner: 'hafezSkillBody',
574
+ invokeOnly: 'same',
575
+ evidence: '#248; health is agent input, the owner never reads the raw report',
576
+ text: [
577
+ 'A vault check ("check the vault", "clean up the vault", "what has drifted"): run `hafez health --json` and bring back proposals grouped by kind, never the rows. In a session that read an entity, lead with that entity\'s rows; the rest of the vault as counts.',
578
+ 'One line per kind, with the count: the Workstreams to split (a `create entity … --parent` line each, named as the actions name it); the one-way edges and the unlinked mentions as one `hafez batch` of `link` ops, one commit; the restated decisions and the leakage pairs as a short list for the user to judge, nothing proposed.',
579
+ 'Apply nothing until the user confirms a kind, not even a `link` line that looks safe; then one `batch` per kind.',
580
+ ].join('\n'),
522
581
  },
523
582
  batchShape: {
524
583
  label: 'Batch shape',
@@ -40,7 +40,7 @@ export const SURFACES = {
40
40
  },
41
41
  hafezSkillDescription: {
42
42
  label: '`hafez` skill description',
43
- compose: () => 'Hafez vault: the less common operations and the tool itself. Use when the user asks what is in the vault ("what am I working on?", "what\'s stale?"), wants to promote, link, unlink, delete, change a status or validate, or wants to update or reconfigure Hafez ("upgrade hafez", "set up hafez"). Also the reference to read when no Hafez block is in your instructions.',
43
+ compose: () => 'Hafez vault: the less common operations and the tool itself. Use when the user asks what is in the vault ("what am I working on?", "what\'s stale?"), wants to promote, link, unlink, delete, change a status or validate, asks to check the vault or clean it up, or wants to update or reconfigure Hafez ("upgrade hafez", "set up hafez"). Also the reference to read when no Hafez block is in your instructions.',
44
44
  },
45
45
  hafezSkillBody: {
46
46
  label: '`hafez` skill body',
@@ -68,6 +68,8 @@ export const SURFACES = {
68
68
  '',
69
69
  place('rareOps'),
70
70
  '',
71
+ place('healthProcedure'),
72
+ '',
71
73
  `${place('batchShape')} Machine-readable shape of any op: \`hafez schema <op>\`, with \`--examples\` for a sample.`,
72
74
  '',
73
75
  '## The tool itself',
@@ -11,4 +11,6 @@ export declare function knowledgeBodyTemplate(subtype?: KnowledgeSubtype, fields
11
11
  * every other section set, and this renders it. */
12
12
  export declare function sessionBodyTemplate(fields?: {
13
13
  synthesis?: string;
14
+ decisions?: string[];
15
+ entities_touched?: string[];
14
16
  }): string;
package/dist/templates.js CHANGED
@@ -35,5 +35,10 @@ export function knowledgeBodyTemplate(subtype, fields) {
35
35
  * structural for Document splitting — so the list lives in contracts with
36
36
  * every other section set, and this renders it. */
37
37
  export function sessionBodyTemplate(fields) {
38
- return render([...SESSION_SECTIONS], { Summary: fields?.synthesis });
38
+ const bullets = (items) => items?.map(i => `- ${i}`).join('\n');
39
+ return render([...SESSION_SECTIONS], {
40
+ Summary: fields?.synthesis,
41
+ 'Entities Touched': bullets(fields?.entities_touched?.map(slug => `[[${slug}]]`)),
42
+ Decisions: bullets(fields?.decisions),
43
+ });
39
44
  }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, PLAN_STATUSES } from './contracts.js';
1
+ import type { ENTITY_TYPES, ENTITY_STATUSES, CONFIDENCE_LEVELS, SESSION_LOG_TYPES, VALID_SUBTYPES, PLAN_STATUSES, GRAPH_LAYERS, SYNTHETIC_LAYERS, GRAPH_NOTE_RELATIONS, CHANGE_OPS } from './contracts.js';
2
2
  export type EntityType = typeof ENTITY_TYPES[number];
3
3
  export type EntityStatus = typeof ENTITY_STATUSES[number];
4
4
  export type ConfidenceLevel = typeof CONFIDENCE_LEVELS[number];
@@ -9,6 +9,10 @@ export type LinkRelation = 'parent' | 'related';
9
9
  export type KnowledgeSubtype = typeof VALID_SUBTYPES[number];
10
10
  /** Plans are the one knowledge subtype with a lifecycle (see PLAN_STATUSES). */
11
11
  export type PlanStatus = typeof PLAN_STATUSES[number];
12
+ /** The kinds of note the Vault holds, one directory each (the Layout,
13
+ * vault.ts, re-exports it). Declared here so this file imports no module
14
+ * with runtime dependencies: the graph page's bundle reads its types. */
15
+ export type VaultKind = 'entity' | 'knowledge' | 'session';
12
16
  /** Sort fields, and the two an entity query cannot honour — reinforcement
13
17
  * lives on knowledge notes only. Asking for one on the entity branch is a
14
18
  * validation error, not a silent fall back to recency, so the entity opts
@@ -208,6 +212,14 @@ export interface CreateKnowledgeFields {
208
212
  tags?: string[];
209
213
  'session-date'?: string;
210
214
  }
215
+ export interface CreateSessionFields {
216
+ synthesis?: string;
217
+ insight?: string;
218
+ related?: string[];
219
+ 'session-date'?: string;
220
+ decisions?: string[];
221
+ entities_touched?: string[];
222
+ }
211
223
  export interface QueryResult {
212
224
  slug: string;
213
225
  name: string;
@@ -267,6 +279,95 @@ export interface ValidationReport {
267
279
  total_knowledge: number;
268
280
  total_sessions: number;
269
281
  }
282
+ /** Check (i): an unsplit prefix family on an entity, from the family module. */
283
+ export interface HealthWorkstream {
284
+ entity: string;
285
+ prefix: string;
286
+ open_actions: number;
287
+ }
288
+ /** Check (ii): a name recurring in `units` units of one entity across `days`. */
289
+ export interface HealthRecurringName {
290
+ entity: string;
291
+ term: string;
292
+ units: number;
293
+ days: string[];
294
+ }
295
+ /** Check (iv): `hits` of the parent's log entries dated on or after the
296
+ * child's `created` (`entries` of them) name the child. */
297
+ export interface HealthLeak {
298
+ parent: string;
299
+ child: string;
300
+ relation: 'parent' | 'related';
301
+ hits: number;
302
+ entries: number;
303
+ }
304
+ /** Check (iii): `from`'s `related` names `to`, a note of the same kind,
305
+ * whose `related` does not name `from`. Check (v): `from`'s body mentions
306
+ * `[[to]]`, a note of the same kind, with no `related` edge either way.
307
+ * Neither lists a parent and its child: parent subsumes related. */
308
+ export interface HealthEdge {
309
+ from: string;
310
+ to: string;
311
+ }
312
+ /** Check (vi): the knowledge note `note`, created on `day` and related to
313
+ * `entity`, shares `share` (0..1) of the summary tokens of a `decision`
314
+ * entry of that day on the entity. */
315
+ export interface HealthRestatedDecision {
316
+ day: string;
317
+ entity: string;
318
+ note: string;
319
+ share: number;
320
+ summary: string;
321
+ }
322
+ export interface HealthReport {
323
+ workstreams: HealthWorkstream[];
324
+ recurring_names: HealthRecurringName[];
325
+ leakage: HealthLeak[];
326
+ one_way_edges: HealthEdge[];
327
+ unlinked_mentions: HealthEdge[];
328
+ restated_decisions: HealthRestatedDecision[];
329
+ }
330
+ export type GraphLayer = typeof GRAPH_LAYERS[number];
331
+ export type SyntheticLayer = typeof SYNTHETIC_LAYERS[number];
332
+ export interface GraphNode {
333
+ /** The slug; `tag:<t>` / `domain:<d>` for a layer node (a slug never holds `:`). */
334
+ id: string;
335
+ kind: VaultKind | SyntheticLayer;
336
+ name: string;
337
+ type?: EntityType;
338
+ status?: EntityStatus;
339
+ subtype?: KnowledgeSubtype;
340
+ confidence?: ConfidenceLevel;
341
+ last_touched?: string;
342
+ description?: string;
343
+ /** An active or paused Entity's open Next Actions. */
344
+ open_actions?: number;
345
+ /** An active Entity untouched past its staleness-days: the index's stale rule. */
346
+ stale?: boolean;
347
+ /** An active or paused Entity's open actions the Stale rule marks, undated ones included. */
348
+ stale_actions?: number;
349
+ /** Every Entity's effective staleness-days: its own, else 14. */
350
+ staleness_days?: number;
351
+ /** An Entity or Knowledge note with no Parent, Related or Wiki-link edge either way. */
352
+ unlinked?: true;
353
+ /** On an Unlinked note: the closest other linked note by name and description,
354
+ * the same kind when one matches; absent when none does. */
355
+ best?: string;
356
+ }
357
+ export interface GraphEdge {
358
+ source: string;
359
+ target: string;
360
+ relation: typeof GRAPH_NOTE_RELATIONS[number] | SyntheticLayer;
361
+ }
362
+ export interface Graph {
363
+ nodes: GraphNode[];
364
+ edges: GraphEdge[];
365
+ }
366
+ /** Any other opt is rejected, never dropped. */
367
+ export interface GraphOpts {
368
+ /** Optional layers added to the default graph; they combine. */
369
+ layers?: GraphLayer[];
370
+ }
270
371
  export interface SearchResult {
271
372
  slug: string;
272
373
  kind: 'entity' | 'knowledge' | 'session';
@@ -308,6 +409,48 @@ export interface ChangelogEntry {
308
409
  operation: 'created' | 'updated' | 'deleted';
309
410
  timestamp: string;
310
411
  commit_message: string;
412
+ /** The commit's hash: what tells the entries of one commit from another's. */
413
+ commit: string;
414
+ }
415
+ /** What a write did to a note, as history tells it. */
416
+ export type ChangeOp = typeof CHANGE_OPS[number];
417
+ /** A file history changed between two points, as the Journal names it:
418
+ * vault-relative and '/'-separated, any file (Layout decides which are notes). */
419
+ export interface FileChange {
420
+ path: string;
421
+ op: ChangeOp;
422
+ }
423
+ /** A note created, updated or deleted since a cursor (`changesSince`). */
424
+ export interface NoteChange {
425
+ slug: string;
426
+ kind: VaultKind;
427
+ op: ChangeOp;
428
+ }
429
+ /** What the graph server's `/events` pushes, one per poll that found
430
+ * changes (src/graph/live.ts); the page reads it too. */
431
+ export interface CommitEvent {
432
+ type: 'commit';
433
+ changes: NoteChange[];
434
+ }
435
+ /** One row of the graph server's `/activity` (#278): a note's run of
436
+ * consecutive writes, newest first. `op`, `kind` and `timestamp` are the
437
+ * newest write's; `name` is the slug once the note is gone; `agent` is the
438
+ * agent of the Entity's newest Session Log entry dated the write's local day,
439
+ * absent where the Vault records none. */
440
+ export interface ActivityRow {
441
+ slug: string;
442
+ kind: VaultKind;
443
+ op: ChangeOp;
444
+ timestamp: string;
445
+ name: string;
446
+ count: number;
447
+ agent?: string;
448
+ }
449
+ export interface ChangesSince {
450
+ /** The history point now: pass it back to get what changed after it.
451
+ * Empty when the Vault has no history yet. */
452
+ cursor: string;
453
+ changes: NoteChange[];
311
454
  }
312
455
  export interface BatchResult {
313
456
  op: string;
@@ -342,7 +485,7 @@ export type BatchOperation = {
342
485
  op: 'create';
343
486
  kind: 'session';
344
487
  name: string;
345
- fields?: CreateKnowledgeFields;
488
+ fields?: CreateSessionFields;
346
489
  } | {
347
490
  op: 'capture';
348
491
  name: string;
@@ -395,8 +538,10 @@ export interface GitConfig {
395
538
  }
396
539
  /**
397
540
  * The persistence seam (see CONTEXT.md: Journal) — everything core needs
398
- * from the history layer behind vault mutations. Git satisfies it in
399
- * production (src/git.ts); tests may inject an in-memory adapter.
541
+ * from the history layer: the commit behind every vault mutation, sync, and
542
+ * the history reads (changelog, file times, the changes since a cursor). Git
543
+ * satisfies it in production (src/git.ts); tests may inject an in-memory
544
+ * adapter.
400
545
  */
401
546
  export interface Journal {
402
547
  /** Persist one logical change: stage written+deleted paths (vault-relative,
@@ -411,12 +556,23 @@ export interface Journal {
411
556
  pushed: boolean;
412
557
  remote: boolean;
413
558
  }>;
414
- /** History-derived changelog for the indexed dirs since a date. Empty when
415
- * the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
416
- changelog(since: string): Promise<ChangelogEntry[]>;
559
+ /** History-derived changelog for the indexed dirs since a date ('' = all
560
+ * history), newest first; `limit` reads only the newest that many commits.
561
+ * Empty when the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
562
+ changelog(since: string, limit?: number): Promise<ChangelogEntry[]>;
417
563
  /** Newest history timestamp per vault file (modified = any change, added = creation).
418
564
  * Empty when the repo has no commits; any other git failure throws `GIT_READ_FAILED`. */
419
565
  fileTimes(mode: 'modified' | 'added'): Promise<Map<string, string>>;
566
+ /** The history point now (`cursor`, '' before the first commit) and the
567
+ * indexed-dir files whose content differs between `from` and it, each with
568
+ * its net status across the whole span (a rename is a delete and a
569
+ * create). `from` unset: the cursor only, no files; `from` '': every file
570
+ * in history now is a create. An unknown `from` or any other git failure
571
+ * throws `GIT_READ_FAILED`. */
572
+ changesSince(from?: string): Promise<{
573
+ cursor: string;
574
+ files: FileChange[];
575
+ }>;
420
576
  }
421
577
  export interface HafezConfig {
422
578
  vaultPath: string;
@@ -473,10 +629,24 @@ export interface Hafez {
473
629
  * archive is reached from its entity on purpose, never found by search. */
474
630
  archive(slug: string): Promise<string | null>;
475
631
  validate(): Promise<ValidationReport>;
632
+ /** The Health pass: what placement would change, from the files on disk
633
+ * (never the index); applies nothing. */
634
+ health(): Promise<HealthReport>;
476
635
  search(query: string, kind?: SearchKind): Promise<SearchResult[]>;
636
+ /** The Vault's structure: Entity and Knowledge nodes, Parent, Related (one
637
+ * per unordered pair) and Wiki-link edges among them; `layers` adds tag,
638
+ * domain and Session nodes with their edges. */
639
+ graph(opts?: GraphOpts): Promise<Graph>;
477
640
  rebuildIndex(): Promise<void>;
478
641
  stats(): Promise<VaultStats>;
479
- changelog(since: string): Promise<ChangelogEntry[]>;
642
+ /** The changes since a date ('' = all history), newest first; `limit`
643
+ * reads only the newest that many commits, a positive whole number
644
+ * (anything else is VALIDATION_FAILED). */
645
+ changelog(since: string, limit?: number): Promise<ChangelogEntry[]>;
646
+ /** The notes created, updated or deleted since `cursor`, from history, and
647
+ * the cursor to pass next time. No cursor: the current cursor and no
648
+ * changes, so a follower starts from now. */
649
+ changesSince(cursor?: string): Promise<ChangesSince>;
480
650
  /** Release the SQLite index handle. Idempotent; the next call that needs
481
651
  * the index reopens it. Call before deleting the vault: Windows cannot
482
652
  * remove a directory holding an open file. */
package/dist/vault.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { ParsedVaultFile } from './types.js';
1
+ import type { ParsedVaultFile, VaultKind } from './types.js';
2
2
  export declare function slugify(name: string): string;
3
- export type VaultKind = 'entity' | 'knowledge' | 'session';
3
+ export type { VaultKind };
4
4
  /** Kinds that live in the SQLite read index: every kind. Sessions joined in
5
5
  * #35 — before that they were files-and-git only, for no recorded reason. */
6
6
  export declare const INDEXED_KINDS: ReadonlyArray<VaultKind>;
@@ -22,6 +22,13 @@ export declare function classifyIndexedRelPath(relPath: string): {
22
22
  kind: VaultKind;
23
23
  slug: string;
24
24
  } | null;
25
+ /** The note a vault-relative ('/'-separated) path is, or null: the files
26
+ * scanVaultDir lists — a non-dot `.md` directly in a kind dir. Archive logs,
27
+ * scaffold files and anything outside the kind dirs are not notes. */
28
+ export declare function noteAtRelPath(relPath: string): {
29
+ kind: VaultKind;
30
+ slug: string;
31
+ } | null;
25
32
  /** All .md files directly in dir — non-recursive (archive/ subdirs are
26
33
  * invisible by construction) and dotfile-free: dotfile slugs are rejected by
27
34
  * resolveFilePath, so listing them would surface files no operation can reach. */