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
@@ -162,6 +162,49 @@ function renderStaleMarker({ stale, age_days }) {
162
162
  return '';
163
163
  return age_days === null ? ' · stale, undated' : ` · stale ${age_days}d`;
164
164
  }
165
+ /** The `read` header line naming the sessions that touched an entity, with
166
+ * the look back from it. None: no line. */
167
+ function renderSessionsLine({ slug, count, latest }) {
168
+ if (count === 0)
169
+ return '';
170
+ return `sessions: ${count}${latest ? ` · latest ${latest}` : ''} · hafez query --kind session --related-to ${slug}`;
171
+ }
172
+ /** The `read` header line naming an entity's archived log entries, with the
173
+ * command that reads them. No archive: no line. */
174
+ function renderArchivePointer({ slug, entries }) {
175
+ if (entries === 0)
176
+ return '';
177
+ return `archive: ${entries} older log ${entries === 1 ? 'entry' : 'entries'} · hafez read ${slug} --archive`;
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
+ }
165
208
  /** The line after an update's success while Stale lines remain on the entity. */
166
209
  function renderStaleReminder({ handles }) {
167
210
  const n = handles.length;
@@ -179,7 +222,10 @@ function renderUpdateMisuse(input) {
179
222
  }
180
223
  const CREATE_TYPES = ['project', 'entity'];
181
224
  const RELATIONS = ['parent', 'related'];
182
- 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>"`;
183
229
  const CREATE_LINES = fence([
184
230
  createEntityLine('<name>'),
185
231
  `hafez create knowledge "<name>" --synthesis "<the insight>" --confidence ${oneOf(CONFIDENCE_LEVELS)} --related <slug>`,
@@ -221,6 +267,38 @@ function renderTeachingLines({ harness, mode, line2Only = false }) {
221
267
  '4. If a hafez command ever asks for permission, setup is unfinished: run `hafez onboard` again.',
222
268
  ].join('\n');
223
269
  }
270
+ /** A GitHub `<owner>/<repo>`, as one shell word safe to print in a command
271
+ * and to name a directory by: not `.` or `..`, not a clone URL's `.git`. */
272
+ export const isVaultRepo = (value) => /^[A-Za-z0-9-]+\/[\w.-]+$/.test(value) && !/\/\.\.?$/.test(value) && !value.endsWith('.git');
273
+ /** Where a cloud container clones a repo attached to the session. */
274
+ const CLOUD_HOME = '/home/user';
275
+ const repoName = (vaultRepo) => vaultRepo.slice(vaultRepo.indexOf('/') + 1);
276
+ /** The Block's self-heal rule in a cloud container: a session started without
277
+ * the vault attached attaches, clones and registers it (verified, #160). */
278
+ function renderCloudVault(vaultRepo) {
279
+ const path = `${CLOUD_HOME}/${repoName(vaultRepo)}`;
280
+ return `If \`hafez init\` shows no registered vault: attach \`${vaultRepo}\` to this session (add_repo, push access), clone it to ${path}, run \`hafez init --register ${path}\`, then continue. Never create a throwaway vault.`;
281
+ }
282
+ /** `onboard --cloud`: the cloud environment's setup script. It cannot clone
283
+ * (it runs without GitHub credentials) and no step may fail session start: a
284
+ * failed install exits 0, leaving a session without Hafez rather than none. */
285
+ function renderCloudSetup({ vaultRepo }) {
286
+ return [
287
+ `Each Claude Code cloud session is a fresh container: no \`hafez\`, no vault, no Block. Paste this into the cloud environment's Setup script; the Trusted network policy is enough.`,
288
+ '',
289
+ fence([
290
+ '#!/bin/bash',
291
+ 'npm install -g hafez --allow-scripts=better-sqlite3 || exit 0',
292
+ `VAULT=${CLOUD_HOME}/${repoName(vaultRepo)}`,
293
+ 'if [ -d "$VAULT/.git" ]; then',
294
+ ' hafez init --register "$VAULT"',
295
+ 'fi',
296
+ `hafez onboard --install --mode always-on --harness claude-code --vault-repo ${vaultRepo} || true`,
297
+ ].join('\n')),
298
+ '',
299
+ `Attach ${vaultRepo} when you start a session, with push access, and the script registers it. The script cannot clone it: it runs without GitHub credentials. A session started without it attached is told by the Block to attach, clone and register it.`,
300
+ ].join('\n');
301
+ }
224
302
  const BATCH_EXAMPLE_OP = '{"op":"create","kind":"entity","name":"Token Rotation","fields":{"type":"entity"}}';
225
303
  export const ITEMS = {
226
304
  loadTriggers: {
@@ -259,14 +337,44 @@ export const ITEMS = {
259
337
  * in brackets when it has one. */
260
338
  render: ({ slug, description }) => description ? `${slug} (${description})` : slug,
261
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
+ },
354
+ sessionsLine: {
355
+ label: 'Sessions line: the look back from an entity',
356
+ owner: 'readHeader',
357
+ invokeOnly: 'same',
358
+ evidence: '#35',
359
+ render: renderSessionsLine,
360
+ },
361
+ archivePointer: {
362
+ label: 'Archive pointer',
363
+ owner: 'readHeader',
364
+ invokeOnly: 'same',
365
+ evidence: '#34',
366
+ render: renderArchivePointer,
367
+ },
262
368
  writeTriggers: {
263
- label: 'Write triggers: work landed, insight, weighing, "note this"',
369
+ label: 'Write triggers: work landed, a split, decision made, a lesson, weighing, "note this"',
264
370
  owner: 'block',
265
371
  invokeOnly: 'absent',
266
- evidence: 'M11, M2, never-ask decision #42',
372
+ evidence: 'M11, M2, never-ask decision #42, decision-record miss #159; placement #226, #229',
267
373
  text: place => [
268
- '- Work landed (a commit, a merged PR, CI green): update the entity you read. The end of its `read` output shows the command.',
269
- `- 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')}`,
270
378
  '- The user says "note this" or "record that": write it.',
271
379
  `- You are weighing whether something is worth writing: write it, as knowledge with \`--confidence ${CONFIDENCE_LEVELS[0]}\`.`,
272
380
  ].join('\n'),
@@ -329,7 +437,7 @@ export const ITEMS = {
329
437
  owner: 'block',
330
438
  fallback: 'hafezSkillBody',
331
439
  invokeOnly: 'via the skill body',
332
- evidence: 'fields: kind/name 18, synthesis 16, subtype 15',
440
+ evidence: 'fields: kind/name 18, synthesis 16, subtype 15; --parent #230',
333
441
  text: CREATE_LINES + '\n\nA plan is `--subtype plan` without `--synthesis`. Cannot tell entity from knowledge: `hafez capture "<name>"`.',
334
442
  /** The entity create line for one name: what the `read` miss places. */
335
443
  render: createEntityLine,
@@ -438,12 +546,14 @@ export const ITEMS = {
438
546
  evidence: 'help decision #49',
439
547
  text: fence([
440
548
  `hafez query --filter ${oneOf([...ENTITY_STATUSES, 'stale', 'capture'])}`,
441
- 'hafez search "<terms>" --kind <entity|knowledge>',
549
+ 'hafez search "<terms>" --kind <entity|knowledge|session|all>',
550
+ 'hafez query --kind session --related-to <slug>',
442
551
  'hafez stats',
443
- ].join('\n')),
552
+ ].join('\n'))
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.',
444
554
  },
445
555
  rareOps: {
446
- label: 'Rare ops: promote, link, unlink, status, delete, validate',
556
+ label: 'Rare ops: promote, link, unlink, status, delete, validate, health',
447
557
  owner: 'hafezSkillBody',
448
558
  invokeOnly: 'same',
449
559
  evidence: 'help decision #49; owner decision #43',
@@ -454,8 +564,20 @@ export const ITEMS = {
454
564
  `hafez update <slug> --status ${oneOf(ENTITY_STATUSES)}`,
455
565
  'hafez delete <slug>',
456
566
  'hafez validate',
567
+ 'hafez health',
457
568
  ].join('\n'))
458
- + `\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'),
459
581
  },
460
582
  batchShape: {
461
583
  label: 'Batch shape',
@@ -517,6 +639,22 @@ export const ITEMS = {
517
639
  evidence: 'never-ask decision #42; onboard decision #58',
518
640
  render: renderHarnessPermissions,
519
641
  },
642
+ cloudVault: {
643
+ label: 'Cloud session: no vault registered, attach the vault repo, clone, register',
644
+ owner: 'block',
645
+ invokeOnly: 'absent',
646
+ evidence: '#160, verified 2026-10-16',
647
+ // Placed only in a Block composed for a cloud container, whose begin
648
+ // marker names the repo (renderBlock).
649
+ text: (_place, _at, { vaultRepo = '<owner>/<repo>' }) => renderCloudVault(vaultRepo),
650
+ },
651
+ cloudSetup: {
652
+ label: 'The cloud environment\'s setup script',
653
+ owner: 'onboard',
654
+ invokeOnly: 'not offered',
655
+ evidence: '#160, verified 2026-10-16',
656
+ render: renderCloudSetup,
657
+ },
520
658
  teachingLines: {
521
659
  label: 'The four teaching lines (to the user, not the agent)',
522
660
  owner: 'onboard',
@@ -1,16 +1,19 @@
1
- import { type FolderMatch, type Item, type ItemId, type SurfaceId } from './items.js';
1
+ import { type FolderMatch, type Item, type ItemId, type SurfaceCtx, type SurfaceId } from './items.js';
2
2
  export interface Surface<Id extends string = ItemId> {
3
3
  /** How the owner table names this Surface. */
4
4
  label: string;
5
5
  /** Static Surfaces only: the text, built by placing Items. */
6
- compose?: (place: (id: Id) => string) => string;
6
+ compose?: (place: (id: Id) => string, ctx: SurfaceCtx) => string;
7
7
  }
8
8
  export declare const SURFACES: Record<SurfaceId, Surface>;
9
9
  export declare const contentHash: (text: string) => string;
10
10
  /** An Item's text as a binary Surface places it at run time, in the form its
11
11
  * role there (Owner or fallback) calls for. */
12
12
  export declare function placeIn(surfaceId: SurfaceId, id: ItemId): string;
13
- export declare function renderBlock(): string;
13
+ /** The Block; with `vaultRepo`, the Block for a cloud container whose vault
14
+ * is that repo. Its begin marker names the repo, so a check reads it from
15
+ * there and never from the Block's wording, which a release may change. */
16
+ export declare function renderBlock(vaultRepo?: string): string;
14
17
  /** `onboard --block`, what the SessionStart hook prints: the Block, then the
15
18
  * read line for each entity matching the folder. */
16
19
  export declare function renderBlockHook(matches: FolderMatch[]): string;
@@ -26,7 +29,7 @@ export declare function renderOwnerTable(): string;
26
29
  /** Replace the marked owner-table region of `doc` with a fresh render. */
27
30
  export declare function spliceOwnerTable(doc: string): string;
28
31
  /** Every problem with the Item table: an Item with no Owner or no evidence
29
- * pointer, an Item its Owner or static fallback never places, or one placed more than once in a
30
- * Surface or in a Surface that is neither its Owner nor its declared fallback.
32
+ * pointer, an Item its Owner or static fallback never places in any context, or one placed more
33
+ * than once in a Surface or in a Surface that is neither its Owner nor its declared fallback.
31
34
  * Placement is checked for static Surfaces only; binary ones place at run time. */
32
35
  export declare function itemProblems(items: Record<string, Item>, surfaces: Record<string, Surface<string>>): string[];
@@ -13,11 +13,12 @@ import { ITEMS } from './items.js';
13
13
  export const SURFACES = {
14
14
  block: {
15
15
  label: 'Block',
16
- compose: place => [
16
+ compose: (place, { vaultRepo }) => [
17
17
  '## Hafez',
18
18
  '',
19
19
  'Hafez is this user\'s vault: what they are working on (entities) and what they have learned (knowledge). You reach it only through the `hafez` command.',
20
20
  '',
21
+ ...(vaultRepo === undefined ? [] : [`**Cloud session.** ${place('cloudVault')}`, '']),
21
22
  `**Load.** ${place('loadTriggers')}`,
22
23
  '',
23
24
  '**Write as it happens.** Each of these is due the moment it occurs:',
@@ -39,7 +40,7 @@ export const SURFACES = {
39
40
  },
40
41
  hafezSkillDescription: {
41
42
  label: '`hafez` skill description',
42
- 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.',
43
44
  },
44
45
  hafezSkillBody: {
45
46
  label: '`hafez` skill body',
@@ -67,6 +68,8 @@ export const SURFACES = {
67
68
  '',
68
69
  place('rareOps'),
69
70
  '',
71
+ place('healthProcedure'),
72
+ '',
70
73
  `${place('batchShape')} Machine-readable shape of any op: \`hafez schema <op>\`, with \`--examples\` for a sample.`,
71
74
  '',
72
75
  '## The tool itself',
@@ -96,11 +99,14 @@ export const SURFACES = {
96
99
  schema: { label: '`schema`' },
97
100
  onboard: { label: '`onboard`' },
98
101
  };
102
+ /** Every context a static Surface is composed in: the default, and the
103
+ * Block for a cloud container. */
104
+ const CONTEXTS = [{}, { vaultRepo: '<owner>/<repo>' }];
99
105
  export const contentHash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
100
106
  /** Compose a static Surface, placing each Item's text in the form its role
101
107
  * (Owner or fallback) calls for. `onPlace` sees every placement, nested ones
102
108
  * included. */
103
- function composeWith(items, surfaceId, surface, onPlace = () => { }) {
109
+ function composeWith(items, surfaceId, surface, ctx = {}, onPlace = () => { }) {
104
110
  const place = (id) => {
105
111
  onPlace(id);
106
112
  const item = items[id];
@@ -108,16 +114,16 @@ function composeWith(items, surfaceId, surface, onPlace = () => { }) {
108
114
  throw new Error(`${surfaceId} places unknown Item ${id}`);
109
115
  const text = item.text;
110
116
  if (typeof text === 'function')
111
- return text(place, item.owner === surfaceId ? 'owner' : 'fallback');
117
+ return text(place, item.owner === surfaceId ? 'owner' : 'fallback', ctx);
112
118
  if (text === undefined)
113
119
  throw new Error(`${surfaceId} places ${id}, which has no text`);
114
120
  return text;
115
121
  };
116
122
  if (!surface.compose)
117
123
  throw new Error(`${surfaceId} is not a static surface`);
118
- return surface.compose(place);
124
+ return surface.compose(place, ctx);
119
125
  }
120
- const compose = (id) => composeWith(ITEMS, id, SURFACES[id]);
126
+ const compose = (id, ctx = {}) => composeWith(ITEMS, id, SURFACES[id], ctx);
121
127
  /** An Item's text as a binary Surface places it at run time, in the form its
122
128
  * role there (Owner or fallback) calls for. */
123
129
  export function placeIn(surfaceId, id) {
@@ -147,9 +153,13 @@ function skillFile(name, description, body) {
147
153
  const text = compose(body);
148
154
  return `---\nname: ${name}\ndescription: >\n${fold(compose(description))}\nhash: ${contentHash(text)}\n---\n${text}`;
149
155
  }
150
- export function renderBlock() {
151
- const inner = compose('block');
152
- return `<!-- hafez:begin ${contentHash(inner)} -->\n${inner}<!-- hafez:end -->\n`;
156
+ /** The Block; with `vaultRepo`, the Block for a cloud container whose vault
157
+ * is that repo. Its begin marker names the repo, so a check reads it from
158
+ * there and never from the Block's wording, which a release may change. */
159
+ export function renderBlock(vaultRepo) {
160
+ const inner = compose('block', { vaultRepo });
161
+ const repo = vaultRepo === undefined ? '' : ` vault-repo=${vaultRepo}`;
162
+ return `<!-- hafez:begin ${contentHash(inner)}${repo} -->\n${inner}<!-- hafez:end -->\n`;
153
163
  }
154
164
  /** `onboard --block`, what the SessionStart hook prints: the Block, then the
155
165
  * read line for each entity matching the folder. */
@@ -185,21 +195,28 @@ export function spliceOwnerTable(doc) {
185
195
  }
186
196
  // --- Drift check 2: the typed Item table ---
187
197
  /** Every problem with the Item table: an Item with no Owner or no evidence
188
- * pointer, an Item its Owner or static fallback never places, or one placed more than once in a
189
- * Surface or in a Surface that is neither its Owner nor its declared fallback.
198
+ * pointer, an Item its Owner or static fallback never places in any context, or one placed more
199
+ * than once in a Surface or in a Surface that is neither its Owner nor its declared fallback.
190
200
  * Placement is checked for static Surfaces only; binary ones place at run time. */
191
201
  export function itemProblems(items, surfaces) {
192
202
  const problems = [];
203
+ // Per Surface, the most times each Item is placed in one of its contexts.
193
204
  const placed = {};
194
205
  for (const [sid, surface] of Object.entries(surfaces)) {
195
206
  if (!surface.compose)
196
207
  continue;
197
- const counts = (placed[sid] = {});
198
- try {
199
- composeWith(items, sid, surface, id => { counts[id] = (counts[id] ?? 0) + 1; });
200
- }
201
- catch (err) {
202
- problems.push(err.message);
208
+ const most = (placed[sid] = {});
209
+ for (const ctx of CONTEXTS) {
210
+ const counts = {};
211
+ try {
212
+ composeWith(items, sid, surface, ctx, id => { counts[id] = (counts[id] ?? 0) + 1; });
213
+ }
214
+ catch (err) {
215
+ if (!problems.includes(err.message))
216
+ problems.push(err.message);
217
+ }
218
+ for (const [id, n] of Object.entries(counts))
219
+ most[id] = Math.max(most[id] ?? 0, n);
203
220
  }
204
221
  }
205
222
  for (const [id, item] of Object.entries(items)) {
@@ -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
  }