hafez 1.3.0 → 2.0.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 +83 -32
  2. package/dist/apply.d.ts +33 -0
  3. package/dist/apply.js +693 -0
  4. package/dist/batch-ops.d.ts +372 -26
  5. package/dist/batch-ops.js +355 -73
  6. package/dist/cli/args.d.ts +48 -6
  7. package/dist/cli/args.js +74 -9
  8. package/dist/cli/batch-errors.js +19 -11
  9. package/dist/cli/block-hook.d.ts +9 -0
  10. package/dist/cli/block-hook.js +90 -0
  11. package/dist/cli/commands-schema.js +2 -0
  12. package/dist/cli/commands.d.ts +28 -3
  13. package/dist/cli/commands.js +440 -157
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +55 -0
  17. package/dist/cli/index.js +215 -153
  18. package/dist/cli/install.d.ts +53 -0
  19. package/dist/cli/install.js +297 -0
  20. package/dist/cli/onboard.d.ts +15 -2
  21. package/dist/cli/onboard.js +265 -90
  22. package/dist/cli/resolve-vault.d.ts +11 -4
  23. package/dist/cli/resolve-vault.js +13 -11
  24. package/dist/cli/schema-introspect.js +4 -3
  25. package/dist/cli/upgrade.d.ts +51 -0
  26. package/dist/cli/upgrade.js +351 -0
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +65 -6
  30. package/dist/contracts.js +148 -23
  31. package/dist/dates.d.ts +10 -0
  32. package/dist/dates.js +27 -0
  33. package/dist/db.d.ts +40 -2
  34. package/dist/db.js +363 -66
  35. package/dist/digest.d.ts +6 -4
  36. package/dist/digest.js +28 -17
  37. package/dist/document.d.ts +135 -88
  38. package/dist/document.js +665 -379
  39. package/dist/export-okf.d.ts +13 -0
  40. package/dist/export-okf.js +50 -20
  41. package/dist/git.js +165 -48
  42. package/dist/harness.d.ts +58 -0
  43. package/dist/harness.js +50 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +301 -826
  46. package/dist/knowledge-index.d.ts +23 -1
  47. package/dist/knowledge-index.js +62 -6
  48. package/dist/merge.d.ts +1 -1
  49. package/dist/merge.js +98 -64
  50. package/dist/near-match.d.ts +9 -0
  51. package/dist/near-match.js +34 -0
  52. package/dist/schema.js +4 -4
  53. package/dist/stale.d.ts +41 -0
  54. package/dist/stale.js +43 -0
  55. package/dist/stopwords.d.ts +6 -0
  56. package/dist/stopwords.js +25 -0
  57. package/dist/surfaces/items.d.ts +366 -0
  58. package/dist/surfaces/items.js +523 -0
  59. package/dist/surfaces/surfaces.d.ts +32 -0
  60. package/dist/surfaces/surfaces.js +230 -0
  61. package/dist/templates.d.ts +6 -0
  62. package/dist/templates.js +27 -27
  63. package/dist/types.d.ts +78 -22
  64. package/dist/validate.d.ts +7 -0
  65. package/dist/validate.js +109 -0
  66. package/dist/vault.d.ts +18 -0
  67. package/dist/vault.js +51 -5
  68. package/package.json +4 -3
  69. package/skill/hafez/SKILL.md +68 -0
  70. package/skill/hafez-save/SKILL.md +29 -0
  71. package/dist/cli/help-agent.d.ts +0 -1
  72. package/dist/cli/help-agent.js +0 -186
  73. package/dist/query.d.ts +0 -24
  74. package/dist/query.js +0 -169
  75. package/skill/SKILL.md +0 -196
@@ -0,0 +1,523 @@
1
+ // src/surfaces/items.ts
2
+ //
3
+ // The typed Item table of the Agent Contract (docs/agent-contract/README.md,
4
+ // ADR 0001). One Item per row of the contract's owner table: each carries its
5
+ // Owner, at most one declared fallback, its Invoke-only behaviour, an evidence
6
+ // pointer, and its own text (placed by a static Surface) or a render function
7
+ // for what the binary computes. Two Items carry neither: the session log on
8
+ // update is behaviour, and `schema` output is the Op Spec's own rendering. Surfaces compose Items in
9
+ // surfaces.ts; nothing instructional is written anywhere else.
10
+ //
11
+ // Edit an Item here, then re-render: `npx tsx scripts/render-surfaces.ts`.
12
+ import { CONFIDENCE_LEVELS, ENTITY_STATUSES, PLAN_STATUSES, SESSION_LOG_TYPES, WRITE_LINE_FIELDS, getContract, statusesFor, footerForm, } from '../contracts.js';
13
+ import { renderActionHandles } from '../document.js';
14
+ import { slugify } from '../vault.js';
15
+ import { ONBOARD_HARNESSES } from '../harness.js';
16
+ const fence = (body) => '```\n' + body + '\n```';
17
+ const oneOf = (values) => `<${values.join('|')}>`;
18
+ /** A value as one shell word: bare when nothing in it is special, otherwise
19
+ * double-quoted with `\ " $ `` escaped. `always` quotes plain words too. */
20
+ const shellArg = (value, always = false) => !always && /^[\w.,:@/=+-]+$/.test(value) ? value : `"${value.replace(/[\\"$`]/g, '\\$&')}"`;
21
+ // The create line is spelled out here; the update lines are Write Lines.
22
+ function renderNearMatch(input) {
23
+ if (input.form === 'relate') {
24
+ return `Optional, it resembles ${input.resembles.join(', ')}: ${updateLine(input.slug, { related: [...input.related, ...input.resembles] })}`;
25
+ }
26
+ if (input.form === 'batchWarning') {
27
+ return `Warning: op[${input.index}] create knowledge ${shellArg(input.name, true)} near-matches ${input.matches.join(', ')}; batch writes it anyway.`;
28
+ }
29
+ const { name, flags, evidence, matches } = input;
30
+ const one = matches.length === 1;
31
+ const matchLine = (m) => m.subtype === 'plan'
32
+ ? updateLine(m.slug, { status: oneOf(PLAN_STATUSES) })
33
+ : updateLine(m.slug, { add_evidence: evidence ?? '<evidence>' });
34
+ const createLine = [
35
+ `hafez create knowledge ${shellArg(name, true)}`,
36
+ ...flags.map(([flag, value]) => value === undefined ? `--${flag}` : `--${flag} ${shellArg(value)}`),
37
+ '--new',
38
+ ].join(' ');
39
+ return [
40
+ `${shellArg(name, true)} near-matches ${one ? 'an existing knowledge note' : `${matches.length} existing knowledge notes`}. Nothing was written.`,
41
+ '',
42
+ one ? 'Use it instead:' : 'Use one of them instead:',
43
+ ...matches.flatMap(m => [
44
+ '',
45
+ `${m.name} (${m.slug})${m.description ? `: ${m.description}` : ''}`,
46
+ fence(matchLine(m)),
47
+ ]),
48
+ '',
49
+ 'A different note after all: create it anyway.',
50
+ fence(createLine),
51
+ ].join('\n');
52
+ }
53
+ // --- The Write Line and the read Footer ---
54
+ /** Update fields whose value is free text: their flag quotes it. Every other
55
+ * value is a slug, Handle, enum or placeholder, written bare. */
56
+ const TEXT_FIELDS = new Set(['brief', 'current_state', 'add_action', 'add_evidence', 'add_source', 'synthesis']);
57
+ /** `hafez update <slug>` with one flag per field, in field order: the batch
58
+ * field with dashes; the log is type and summary in one value plus `--body`
59
+ * (its agent is detected, never shown); the action list repeats its flag;
60
+ * other lists are comma-joined. */
61
+ function updateLine(slug, fields) {
62
+ const flag = (field, value) => `--${field.replace(/_/g, '-')} ${TEXT_FIELDS.has(field) ? shellArg(value, true) : value}`;
63
+ const flags = Object.entries(fields).flatMap(([field, value]) => {
64
+ if (field === 'session_log') {
65
+ const log = value;
66
+ return [`--log ${shellArg(`${log.type}: ${log.summary}`, true)}`, ...(log.body ? [`--body ${shellArg(log.body, true)}`] : [])];
67
+ }
68
+ if (field === 'add_actions')
69
+ return value.map(v => flag('add_action', v));
70
+ return [flag(field, Array.isArray(value) ? value.join(',') : String(value))];
71
+ });
72
+ return [`hafez update ${slug}`, ...flags].join(' ');
73
+ }
74
+ /** Each Footer field's placeholder. `undefined` leaves the field out. */
75
+ const PLACEHOLDERS = {
76
+ session_log: ({ agent }) => ({ type: oneOf(SESSION_LOG_TYPES), summary: '<one line>', body: '<detail>', agent }),
77
+ complete_action: ({ handle }) => handle,
78
+ remove_action: () => '<handle>',
79
+ add_actions: () => ['<text>'],
80
+ brief: () => '<text>',
81
+ current_state: () => '<text>',
82
+ related: () => ['<slug>'],
83
+ parent: () => '<slug>',
84
+ add_evidence: () => '<what you saw>',
85
+ add_source: () => '<url or citation>',
86
+ synthesis: () => '<the whole insight, rewritten>',
87
+ confidence: () => oneOf(CONFIDENCE_LEVELS),
88
+ domain: () => ['<domain>'],
89
+ status: ({ target }) => oneOf(statusesFor(target)),
90
+ };
91
+ export function footerOp(input) {
92
+ const form = footerForm(input.target);
93
+ if (form === 'capture') {
94
+ return { op: 'promote', slug: input.slug, target: oneOf(getContract('capture').canPromoteTo) };
95
+ }
96
+ const fields = {};
97
+ for (const field of WRITE_LINE_FIELDS[form]) {
98
+ const value = PLACEHOLDERS[field](input);
99
+ if (value !== undefined)
100
+ fields[field] = value;
101
+ }
102
+ return { op: 'update', slug: input.slug, fields };
103
+ }
104
+ /** The Footer: a blank line, a rule line, plain prose and one fenced block. */
105
+ function renderFooter(input) {
106
+ const op = footerOp(input);
107
+ const [prose, line] = op.op === 'promote'
108
+ ? ['A capture waits to be sorted. Once you can tell what it is, one command:', `hafez promote ${op.slug} ${op.target}`]
109
+ : ['Write back with one command. Keep the flags you need, drop the rest:', updateLine(op.slug, op.fields)];
110
+ return ['', '---', prose, '', fence(line)].join('\n');
111
+ }
112
+ /** A field that is set or, given empty, cleared: `<name> set` / `<name> cleared`. */
113
+ const setOrCleared = (field, name = field.replace(/_/g, ' ')) => f => {
114
+ const value = f[field];
115
+ if (value === undefined)
116
+ return undefined;
117
+ return value === null || value === '' || (Array.isArray(value) && value.length === 0) ? `${name} cleared` : `${name} set`;
118
+ };
119
+ /** What each update field did, for the success line; `null` for a field that
120
+ * changes nothing of its own. Keyed by every update field, so a new field
121
+ * without a phrase fails the build. Key order is the line's order: the
122
+ * entity Footer's, then the rest. */
123
+ const CHANGE_PHRASES = {
124
+ session_log: (_f, r) => r.logged ? 'log added' : undefined,
125
+ clear_actions: f => f.clear_actions ? 'actions cleared' : undefined,
126
+ complete_action: f => f.complete_action !== undefined ? '1 action ticked' : undefined,
127
+ remove_action: f => f.remove_action !== undefined ? '1 action removed' : undefined,
128
+ add_actions: f => {
129
+ const n = (f.add_action !== undefined ? 1 : 0) + (f.add_actions?.length ?? 0);
130
+ return n > 0 ? `${n} action${n === 1 ? '' : 's'} added` : undefined;
131
+ },
132
+ add_action: null, // counted with add_actions
133
+ brief: setOrCleared('brief'),
134
+ current_state: f => f.current_state !== undefined ? 'current state set' : undefined,
135
+ related: setOrCleared('related'),
136
+ parent: f => f.parent !== undefined ? `parent set to ${f.parent}` : undefined,
137
+ status: f => f.status !== undefined ? `status set to ${f.status}` : undefined,
138
+ description: setOrCleared('description'),
139
+ resource: setOrCleared('resource'),
140
+ domain: setOrCleared('domain'),
141
+ tags: setOrCleared('tags'),
142
+ confidence: f => f.confidence !== undefined ? `confidence set to ${f.confidence}` : undefined,
143
+ synthesis: f => f.synthesis !== undefined || f.insight !== undefined ? 'synthesis set' : undefined,
144
+ insight: null, // the alias of synthesis
145
+ add_evidence: f => f.add_evidence !== undefined ? 'evidence added' : undefined,
146
+ add_source: f => f.add_source !== undefined ? 'source added' : undefined,
147
+ agent: null, // names the log entry's agent
148
+ };
149
+ /** `Updated <slug>: <what changed>.` — one phrase per change, nothing else. */
150
+ function renderUpdateSummary({ slug, fields, result }) {
151
+ const phrases = Object.values(CHANGE_PHRASES).flatMap(phrase => phrase?.(fields, result) ?? []);
152
+ return phrases.length > 0 ? `Updated ${slug}: ${phrases.join(', ')}.` : `Updated ${slug}.`;
153
+ }
154
+ /** The suffix a Stale open line carries on `read`: its age, or that it is
155
+ * undated. A fresh line carries none. */
156
+ function renderStaleMarker({ stale, age_days }) {
157
+ if (!stale)
158
+ return '';
159
+ return age_days === null ? ' · stale, undated' : ` · stale ${age_days}d`;
160
+ }
161
+ /** The line after an update's success while Stale lines remain on the entity. */
162
+ function renderStaleReminder({ handles }) {
163
+ const n = handles.length;
164
+ return `${n} stale action${n === 1 ? '' : 's'} ${n === 1 ? 'remains' : 'remain'}: ${handles.join(', ')}. Tick what landed, drop what is dead.`;
165
+ }
166
+ /** The error line stating nothing was written, any detail lines, then the
167
+ * Footer (which brings its own blank line) or, after a blank line, the miss. */
168
+ function renderUpdateMisuse(input) {
169
+ const error = /[.!?]$/.test(input.error) ? input.error : `${input.error}.`;
170
+ return [
171
+ `Error: ${error} Nothing was written.`,
172
+ ...(input.details ?? []).map(d => ` - ${d}`),
173
+ 'footer' in input ? ITEMS.writeLine.render(input.footer) : `\n${ITEMS.slugMissRecovery.render(input.miss)}`,
174
+ ].join('\n');
175
+ }
176
+ const CREATE_TYPES = ['project', 'entity'];
177
+ const RELATIONS = ['parent', 'related'];
178
+ const createEntityLine = (name) => `hafez create entity "${name}" --type ${oneOf(CREATE_TYPES)} --description "<one sentence>" --brief "<where things stand>" --add-action "<next step>"`;
179
+ const CREATE_LINES = fence([
180
+ createEntityLine('<name>'),
181
+ `hafez create knowledge "<name>" --synthesis "<the insight>" --confidence ${oneOf(CONFIDENCE_LEVELS)} --related <slug>`,
182
+ ].join('\n'));
183
+ /** Step 3 of the onboard script: the exact entries that let `hafez` run and
184
+ * write the vault without a prompt. Claude Code runs it from an allow entry;
185
+ * Codex from a command rule, with the vault a writable root of the sandbox. */
186
+ function renderHarnessPermissions({ harness, vault }) {
187
+ const lead = 'A permission prompt on a hafez command means setup is unfinished.';
188
+ if (harness === 'claude-code') {
189
+ const { hooks, allowEntry } = ONBOARD_HARNESSES[harness];
190
+ return `${lead} Show the user this entry, then add it to ~/${hooks} under permissions.allow:\n\n${JSON.stringify(allowEntry)}`;
191
+ }
192
+ const { rules, rule, config } = ONBOARD_HARNESSES[harness];
193
+ return [
194
+ `${lead} Show the user these two entries, then add the first to ~/${rules}:`,
195
+ '',
196
+ rule,
197
+ '',
198
+ `and the second to ~/${config} under [sandbox_workspace_write], merged into any writable_roots already there:`,
199
+ '',
200
+ `writable_roots = [${JSON.stringify(vault)}]`,
201
+ ].join('\n');
202
+ }
203
+ const TEACHING_LINE_2 = {
204
+ 'always-on': '2. Hafez is always on: the agent loads your work at the start of a session and writes to the vault as work lands.',
205
+ 'invoke-only': '2. Hafez is invoke-only: nothing is loaded and nothing is written unless you ask. Say "read <name>" to load, "note this" to write.',
206
+ };
207
+ /** The four lines said to the user at hand-over, or line 2 alone. Line 1
208
+ * carries the save keystroke of the Harness `onboard` ran in; line 2 is the
209
+ * Mode. */
210
+ function renderTeachingLines({ harness, mode, line2Only = false }) {
211
+ if (line2Only)
212
+ return TEACHING_LINE_2[mode];
213
+ return [
214
+ `1. Nothing saves itself. Run ${ONBOARD_HARNESSES[harness].keystroke} before you end a session.`,
215
+ TEACHING_LINE_2[mode],
216
+ '3. The agent writes without asking and tells you in one line. Every write is a git commit, so any of them can be undone.',
217
+ '4. If a hafez command ever asks for permission, setup is unfinished: run `hafez onboard` again.',
218
+ ].join('\n');
219
+ }
220
+ const BATCH_EXAMPLE_OP = '{"op":"create","kind":"entity","name":"Token Rotation","fields":{"type":"entity"}}';
221
+ export const ITEMS = {
222
+ loadTriggers: {
223
+ label: 'Load triggers: read the best guess, create when there is no entity, load nothing when nothing is named',
224
+ owner: 'block',
225
+ invokeOnly: 'absent',
226
+ evidence: 'L1, M1',
227
+ text: 'At the start of a session, if the first message or the working folder names a piece of work, run `hafez read <slug>` on your best guess at the slug before anything else. A miss prints the closest slugs: read the one that fits. The work has no entity: create one with the line below. Nothing is named: load nothing.',
228
+ },
229
+ slugMissRecovery: {
230
+ label: 'Slug miss recovery',
231
+ owner: 'readMiss',
232
+ invokeOnly: 'same',
233
+ evidence: 'L1; Codex corpus, 6 of 13 reads missed, none recovered',
234
+ // The closest slugs read-ready, else the search line; then the create
235
+ // line, named by the miss's slug words so it creates the slug it missed.
236
+ render: ({ slug, closest }) => {
237
+ const name = slugify(slug).replace(/-/g, ' ');
238
+ const miss = `Error: no entity or knowledge note '${slug}'.`;
239
+ // No word to search for or name a note by: the miss line alone.
240
+ if (!name)
241
+ return miss;
242
+ return [
243
+ miss,
244
+ ...(closest.length > 0 ? closest.map(s => `hafez read ${s}`) : [`hafez search "${name}"`]),
245
+ ITEMS.createLines.render(name),
246
+ ].join('\n');
247
+ },
248
+ },
249
+ parentDescription: {
250
+ label: 'Parent description',
251
+ owner: 'readHeader',
252
+ invokeOnly: 'same',
253
+ evidence: 'L2',
254
+ /** The `parent:` line's value: the slug, with the parent's description
255
+ * in brackets when it has one. */
256
+ render: ({ slug, description }) => description ? `${slug} (${description})` : slug,
257
+ },
258
+ writeTriggers: {
259
+ label: 'Write triggers: work landed, insight, weighing, "note this"',
260
+ owner: 'block',
261
+ invokeOnly: 'absent',
262
+ evidence: 'M11, M2, never-ask decision #42',
263
+ text: place => [
264
+ '- Work landed (a commit, a merged PR, CI green): update the entity you read. The end of its `read` output shows the command.',
265
+ `- An insight emerged that is useful beyond this repo: create a knowledge note. ${place('knowledgeBoundary')}`,
266
+ '- The user says "note this" or "record that": write it.',
267
+ `- You are weighing whether something is worth writing: write it, as knowledge with \`--confidence ${CONFIDENCE_LEVELS[0]}\`.`,
268
+ ].join('\n'),
269
+ },
270
+ knowledgeBoundary: {
271
+ label: 'Knowledge boundary',
272
+ owner: 'block',
273
+ invokeOnly: 'absent',
274
+ evidence: 'M3',
275
+ text: "An insight about this repo's code alone goes in the repo's own docs instead.",
276
+ },
277
+ neverAsk: {
278
+ label: 'Never ask; the tell line',
279
+ owner: 'block',
280
+ fallback: 'hafezSkillBody',
281
+ invokeOnly: 'via the skill body',
282
+ evidence: 'audit §3',
283
+ text: (_place, at) => 'Never ask the user whether to write to the vault. Write, then say so in one line naming the slug and what you did.'
284
+ + (at === 'owner' ? ' Every write is a git commit and can be undone.' : ''),
285
+ },
286
+ readBeforeWrite: {
287
+ label: 'Read before write-back',
288
+ owner: 'block',
289
+ fallback: 'hafezSkillBody',
290
+ invokeOnly: 'via the skill body',
291
+ evidence: 'makes the footer sufficient',
292
+ text: 'Update only what you have read this session. Not read yet: read it first.',
293
+ },
294
+ oneCommand: {
295
+ label: 'One command per write',
296
+ owner: 'block',
297
+ invokeOnly: 'absent',
298
+ evidence: 'M8',
299
+ text: 'One command per write.',
300
+ },
301
+ openActions: {
302
+ label: 'Open actions are the agent\'s; stale lines are due',
303
+ owner: 'block',
304
+ fallback: 'hafezSkillBody',
305
+ invokeOnly: 'via the skill body and the write reminder',
306
+ evidence: 'M11',
307
+ text: 'Open actions on an entity you read are yours: tick what landed, drop what is dead. Lines `read` marks stale are due.',
308
+ },
309
+ neverEditByHand: {
310
+ label: 'Never edit vault files by hand',
311
+ owner: 'block',
312
+ invokeOnly: 'absent',
313
+ evidence: 'help decision #49',
314
+ text: 'Never edit vault files by hand.',
315
+ },
316
+ neverSaveUnprompted: {
317
+ label: 'Never save unprompted',
318
+ owner: 'block',
319
+ invokeOnly: 'not applicable',
320
+ evidence: 'D1, D8',
321
+ text: 'Never save the session unprompted. The user saves with the `hafez-save` skill.',
322
+ },
323
+ createLines: {
324
+ label: 'Create lines, their enums, plan, capture',
325
+ owner: 'block',
326
+ fallback: 'hafezSkillBody',
327
+ invokeOnly: 'via the skill body',
328
+ evidence: 'fields: kind/name 18, synthesis 16, subtype 15',
329
+ text: CREATE_LINES + '\n\nA plan is `--subtype plan` without `--synthesis`. Cannot tell entity from knowledge: `hafez capture "<name>"`.',
330
+ /** The entity create line for one name: what the `read` miss places. */
331
+ render: createEntityLine,
332
+ },
333
+ writeLine: {
334
+ label: 'Update field vocabulary, log type enum, the Write Line',
335
+ owner: 'readFooter',
336
+ fallback: 'updateMisuse',
337
+ invokeOnly: 'same',
338
+ evidence: 'fields: session_log 26, add_actions 27, complete_action 19, current_state 19, brief 14, related 14, remove_action 8',
339
+ render: renderFooter,
340
+ },
341
+ handles: {
342
+ label: 'Handles',
343
+ owner: 'read',
344
+ invokeOnly: 'same',
345
+ evidence: 'M11: 131 ticks, 28 matched a line the agent had read',
346
+ render: renderActionHandles,
347
+ },
348
+ staleMarker: {
349
+ label: 'Stale marker',
350
+ owner: 'read',
351
+ invokeOnly: 'same',
352
+ evidence: 'M11',
353
+ render: renderStaleMarker,
354
+ },
355
+ staleReminder: {
356
+ label: 'Stale reminder',
357
+ owner: 'updateSuccess',
358
+ invokeOnly: 'same',
359
+ evidence: 'M11',
360
+ render: renderStaleReminder,
361
+ },
362
+ updateSummary: {
363
+ label: 'What an update changed, in one line: the tell line',
364
+ owner: 'updateSuccess',
365
+ invokeOnly: 'same',
366
+ evidence: 'never-ask decision #42: one tell line per write',
367
+ render: renderUpdateSummary,
368
+ },
369
+ updateMisuse: {
370
+ label: 'The corrected shape after a misuse: nothing written, then the Footer',
371
+ owner: 'updateMisuse',
372
+ invokeOnly: 'same',
373
+ evidence: '19 of 84 writes errored after reading the reference',
374
+ render: renderUpdateMisuse,
375
+ },
376
+ sessionLogOnUpdate: {
377
+ label: 'Session log on update',
378
+ owner: 'mechanised',
379
+ invokeOnly: 'same',
380
+ evidence: 'M7',
381
+ },
382
+ nearMatch: {
383
+ label: 'Near-match on create knowledge',
384
+ owner: 'createKnowledge',
385
+ invokeOnly: 'same',
386
+ evidence: 'M4',
387
+ render: renderNearMatch,
388
+ },
389
+ saveTrigger: {
390
+ label: 'Save trigger',
391
+ owner: 'hafezSaveDescription',
392
+ invokeOnly: 'same',
393
+ evidence: 'D1; 111 digest calls, 7 errors when invoked',
394
+ text: 'Save this session to the Hafez vault. Use when the user invokes hafez-save or says "save", "save the session" or "wrap up". Never run it unprompted.',
395
+ },
396
+ saveMechanics: {
397
+ label: 'Save mechanics: shape, date, known slugs, no-pipe form',
398
+ owner: 'hafezSaveBody',
399
+ invokeOnly: 'same',
400
+ evidence: 'fields: digest keys 14 each; D2, D3, D4; harness research; #135',
401
+ text: [
402
+ '1. List the slugs you read, updated or created this session. Use only those; a slug you did not see in `hafez` output this session does not go in. No slugs: tell the user there is nothing to save and stop.',
403
+ '2. Run this once, with today\'s date:',
404
+ '',
405
+ fence([
406
+ "hafez digest --data '{",
407
+ ' "entities_touched": ["<slug>", "<slug>"],',
408
+ ' "decisions": ["<a decision made this session>"],',
409
+ ' "narrative": "<one to three sentences: what happened and how it connects>",',
410
+ ' "session_date": "<YYYY-MM-DD>"',
411
+ "}' | hafez batch",
412
+ ].join('\n')),
413
+ '',
414
+ 'In the JSON, write each apostrophe as `’` (`isn’t`): a straight `\'` ends the quoted argument and the save fails.',
415
+ '',
416
+ 'No pipe (PowerShell 5.1): put the JSON in `summary.json`, then `hafez digest --file summary.json > ops.json`, then `hafez batch --file ops.json`.',
417
+ '',
418
+ '3. Tell the user in one line: the session note\'s slug and the entities it logged to.',
419
+ '',
420
+ '`decisions` may be an empty array. Each slug in `entities_touched` gets a session log entry and the session note links them all. An unknown slug fails the save and names the slug: fix the list and run it again. A session already saved is skipped, so a second run does no harm.',
421
+ ].join('\n'),
422
+ },
423
+ unknownSlugAtSave: {
424
+ label: 'Unknown slug at save',
425
+ owner: 'digestError',
426
+ invokeOnly: 'same',
427
+ evidence: 'D2',
428
+ render: (slugs) => `Unknown slug${slugs.length === 1 ? '' : 's'} in entities_touched: ${slugs.map(s => `'${s}'`).join(', ')}. Nothing was saved: fix the list and run the save again.`,
429
+ },
430
+ find: {
431
+ label: 'Find: query, search, stats',
432
+ owner: 'hafezSkillBody',
433
+ invokeOnly: 'same',
434
+ evidence: 'help decision #49',
435
+ text: fence([
436
+ `hafez query --filter ${oneOf([...ENTITY_STATUSES, 'stale', 'capture'])}`,
437
+ 'hafez search "<terms>" --kind <entity|knowledge>',
438
+ 'hafez stats',
439
+ ].join('\n')),
440
+ },
441
+ rareOps: {
442
+ label: 'Rare ops: promote, link, unlink, status, delete, validate',
443
+ owner: 'hafezSkillBody',
444
+ invokeOnly: 'same',
445
+ evidence: 'help decision #49; owner decision #43',
446
+ text: fence([
447
+ `hafez promote <slug> ${oneOf(getContract('capture').canPromoteTo)}`,
448
+ `hafez link <slug> <target> --relation ${oneOf(RELATIONS)}`,
449
+ `hafez unlink <slug> <target> --relation ${oneOf(RELATIONS)}`,
450
+ `hafez update <slug> --status ${oneOf(ENTITY_STATUSES)}`,
451
+ 'hafez delete <slug>',
452
+ 'hafez validate',
453
+ ].join('\n'))
454
+ + `\n\nA plan's status is \`${PLAN_STATUSES.join('|')}\`. \`delete\` removes a knowledge or session note only; an entity is retired with \`--status done\`.`,
455
+ },
456
+ batchShape: {
457
+ label: 'Batch shape',
458
+ owner: 'hafezSkillBody',
459
+ fallback: 'batchMisuse',
460
+ invokeOnly: 'same',
461
+ evidence: 'M8; batch-examples needed by 73 write events; #145',
462
+ // The fallback is the `batch` misuse error's tail: the shape alone, one op.
463
+ text: (_place, at) => at === 'owner'
464
+ ? [
465
+ 'Several writes that must land together go in one batch: a bare array, no wrapper. A slug is the name lowercased, apostrophes dropped, each other run of characters that are not letters or digits turned into one `-` and none left at either end, so a later op can name what an earlier one creates.',
466
+ '',
467
+ fence([
468
+ "hafez batch --data '[",
469
+ ` ${BATCH_EXAMPLE_OP},`,
470
+ ' {"op":"link","slug":"token-rotation","target":"auth-refactor","relation":"parent"}',
471
+ "]'",
472
+ ].join('\n')),
473
+ '',
474
+ 'In the JSON, write each apostrophe as `’` (`isn’t`): a straight `\'` ends the quoted argument and the batch fails.',
475
+ '',
476
+ 'No pipe (PowerShell 5.1): `hafez batch --file ops.json`.',
477
+ ].join('\n')
478
+ : `A batch is a bare JSON array of ops, no wrapper:\n\n${fence(`[\n ${BATCH_EXAMPLE_OP}\n]`)}`,
479
+ },
480
+ upgradeReconfigure: {
481
+ label: 'Upgrade, reconfigure',
482
+ owner: 'hafezSkillBody',
483
+ invokeOnly: 'same',
484
+ evidence: 'help decision #49; onboard decision #58',
485
+ text: [
486
+ '- Upgrade: `hafez upgrade`. It checks and prints, it never installs. Show the user the printed steps and run them only after a yes.',
487
+ '- Set up or reconfigure: `hafez onboard`.',
488
+ ].join('\n'),
489
+ },
490
+ opShape: {
491
+ label: 'Machine-readable op shape',
492
+ owner: 'schema',
493
+ invokeOnly: 'same',
494
+ evidence: 'help decision #49',
495
+ },
496
+ folderMatch: {
497
+ label: 'The working folder is an entity: its read line, after the Block the hook re-delivers',
498
+ owner: 'onboard',
499
+ invokeOnly: 'not offered',
500
+ evidence: 'L1; onboard decision #58',
501
+ // Every match, in the order given (active first); the agent picks and reads.
502
+ render: (matches) => [
503
+ matches.length === 1
504
+ ? 'This folder is a Hafez entity. Read it first:'
505
+ : 'This folder matches several Hafez entities. Read the one that fits first:',
506
+ ...matches.map(m => `hafez read ${m.slug}${m.description ? ` (${m.description})` : ''}`),
507
+ ].join('\n'),
508
+ },
509
+ harnessPermissions: {
510
+ label: 'Binary on the harness allowlist; vault path writable',
511
+ owner: 'onboard',
512
+ invokeOnly: 'same',
513
+ evidence: 'never-ask decision #42; onboard decision #58',
514
+ render: renderHarnessPermissions,
515
+ },
516
+ teachingLines: {
517
+ label: 'The four teaching lines (to the user, not the agent)',
518
+ owner: 'onboard',
519
+ invokeOnly: 'same, line 2 differs',
520
+ evidence: 'onboard decision #58',
521
+ render: renderTeachingLines,
522
+ },
523
+ };
@@ -0,0 +1,32 @@
1
+ import { type FolderMatch, type Item, type ItemId, type SurfaceId } from './items.js';
2
+ export interface Surface<Id extends string = ItemId> {
3
+ /** How the owner table names this Surface. */
4
+ label: string;
5
+ /** Static Surfaces only: the text, built by placing Items. */
6
+ compose?: (place: (id: Id) => string) => string;
7
+ }
8
+ export declare const SURFACES: Record<SurfaceId, Surface>;
9
+ export declare const contentHash: (text: string) => string;
10
+ /** An Item's text as a binary Surface places it at run time, in the form its
11
+ * role there (Owner or fallback) calls for. */
12
+ export declare function placeIn(surfaceId: SurfaceId, id: ItemId): string;
13
+ export declare function renderBlock(): string;
14
+ /** `onboard --block`, what the SessionStart hook prints: the Block, then the
15
+ * read line for each entity matching the folder. */
16
+ export declare function renderBlockHook(matches: FolderMatch[]): string;
17
+ export declare const renderHafezSkill: () => string;
18
+ /** `help --agent`: the `hafez` skill body, the one text, without the blank
19
+ * line that parts it from the skill file's frontmatter or the newline that
20
+ * ends the file (the CLI adds one to every output). */
21
+ export declare const renderAgentHelp: () => string;
22
+ export declare const renderHafezSaveSkill: () => string;
23
+ export declare const OWNER_TABLE_BEGIN = "<!-- owner-table:begin (rendered from src/surfaces/items.ts; edit the Item, then run scripts/render-surfaces.ts) -->";
24
+ export declare const OWNER_TABLE_END = "<!-- owner-table:end -->";
25
+ export declare function renderOwnerTable(): string;
26
+ /** Replace the marked owner-table region of `doc` with a fresh render. */
27
+ export declare function spliceOwnerTable(doc: string): string;
28
+ /** 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.
31
+ * Placement is checked for static Surfaces only; binary ones place at run time. */
32
+ export declare function itemProblems(items: Record<string, Item>, surfaces: Record<string, Surface<string>>): string[];