hafez 1.4.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 (63) hide show
  1. package/README.md +26 -33
  2. package/dist/apply.d.ts +5 -2
  3. package/dist/apply.js +225 -114
  4. package/dist/batch-ops.d.ts +89 -26
  5. package/dist/batch-ops.js +176 -18
  6. package/dist/cli/args.d.ts +43 -5
  7. package/dist/cli/args.js +69 -8
  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 +4 -3
  13. package/dist/cli/commands.js +300 -99
  14. package/dist/cli/format.d.ts +7 -3
  15. package/dist/cli/format.js +27 -10
  16. package/dist/cli/index.d.ts +18 -4
  17. package/dist/cli/index.js +72 -30
  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 +13 -4
  26. package/dist/cli/upgrade.js +90 -44
  27. package/dist/closest.d.ts +8 -0
  28. package/dist/closest.js +65 -0
  29. package/dist/contracts.d.ts +17 -1
  30. package/dist/contracts.js +44 -0
  31. package/dist/db.d.ts +15 -2
  32. package/dist/db.js +69 -48
  33. package/dist/digest.d.ts +6 -4
  34. package/dist/digest.js +28 -17
  35. package/dist/document.d.ts +41 -3
  36. package/dist/document.js +218 -62
  37. package/dist/export-okf.d.ts +13 -0
  38. package/dist/export-okf.js +11 -7
  39. package/dist/harness.d.ts +58 -0
  40. package/dist/harness.js +50 -0
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +94 -104
  43. package/dist/merge.js +7 -5
  44. package/dist/near-match.d.ts +9 -0
  45. package/dist/near-match.js +34 -0
  46. package/dist/stale.d.ts +41 -0
  47. package/dist/stale.js +43 -0
  48. package/dist/stopwords.d.ts +6 -0
  49. package/dist/stopwords.js +25 -0
  50. package/dist/surfaces/items.d.ts +366 -0
  51. package/dist/surfaces/items.js +523 -0
  52. package/dist/surfaces/surfaces.d.ts +32 -0
  53. package/dist/surfaces/surfaces.js +230 -0
  54. package/dist/types.d.ts +45 -16
  55. package/dist/validate.d.ts +7 -0
  56. package/dist/validate.js +109 -0
  57. package/dist/vault.js +1 -1
  58. package/package.json +4 -3
  59. package/skill/hafez/SKILL.md +68 -0
  60. package/skill/hafez-save/SKILL.md +29 -0
  61. package/dist/cli/help-agent.d.ts +0 -1
  62. package/dist/cli/help-agent.js +0 -210
  63. package/skill/SKILL.md +0 -137
package/README.md CHANGED
@@ -29,18 +29,15 @@ Set up Hafez, an agent-native knowledge vault (github.com/shrvn-r/hafez):
29
29
  3. Create my vault — a git repo with entities/ and knowledge/ directories and an
30
30
  initial commit. Suggest ~/vault but ask me where I want it first.
31
31
  4. Register it: hafez init --register <vault-path>
32
- 5. If you are Claude Code, install the bundled skill: copy skill/SKILL.md
33
- from the installed package ("$(npm root -g)/hafez/skill/SKILL.md") to
34
- ~/.claude/skills/hafez/SKILL.md. Any other harness: skip this — step 7
35
- covers integration.
36
- 6. Read hafez help --agent, then verify end-to-end: create a test capture,
32
+ 5. Read hafez help --agent, then verify end-to-end: create a test capture,
37
33
  hafez read it back, and show me hafez stats.
38
- 7. Run hafez onboard and follow what it prints: interview me to seed the
39
- vault with my current projects, then help me choose how deeply to
40
- integrate Hafez into my agent setup.
34
+ 6. Run hafez onboard and follow what it prints: ask me how Hafez should
35
+ run, install it (the two skills, and the Block in your instructions
36
+ file), and, if the vault is empty, interview me to seed it with my
37
+ current projects.
41
38
  ```
42
39
 
43
- From then on, just mention your projects by name in any session — the skill
40
+ From then on, just mention your projects by name in any session — the Block
44
41
  teaches the agent when to read and write the vault, no commands required.
45
42
 
46
43
  Upgrading from an earlier name of this tool? Remove the old skill directory
@@ -67,13 +64,9 @@ cd ~/vault && git init && git commit --allow-empty -m "init vault"
67
64
  hafez init --register ~/vault # remember this vault (one-time)
68
65
  hafez init # show current vault resolution
69
66
 
70
- # Claude Code only (optional): the bundled skill
71
- mkdir -p ~/.claude/skills/hafez
72
- cp "$(npm root -g)/hafez/skill/SKILL.md" ~/.claude/skills/hafez/SKILL.md
73
-
74
- # Then, in your agent — any harness: "run hafez onboard and follow it" —
75
- # guided first run (seed the vault with your current projects, choose an
76
- # integration level)
67
+ # Then, in Claude Code or Codex: "run hafez onboard and follow it" —
68
+ # guided setup (choose how Hafez runs, install the two skills and the Block,
69
+ # seed an empty vault)
77
70
  ```
78
71
 
79
72
  Prefer installing without the npm registry? The GitHub release tarball is the
@@ -107,14 +100,11 @@ npm install -g hafez@<version> --allow-scripts=better-sqlite3
107
100
  # From the GitHub release tarball instead (npm 12+ needs --allow-remote):
108
101
  npm install -g https://github.com/shrvn-r/hafez/releases/download/v<version>/hafez.tgz --allow-scripts=better-sqlite3 --allow-remote=root
109
102
 
110
- # 2. Claude Code only: re-copy the bundled skill. It ships inside the package,
111
- # so an upgraded package leaves the installed copy stale.
112
- mkdir -p ~/.claude/skills/hafez
113
- cp "$(npm root -g)/hafez/skill/SKILL.md" ~/.claude/skills/hafez/SKILL.md
114
-
115
- # Under nvm, or for a symlinked dev install, `npm root -g` points at the
116
- # wrong tree. Resolve it from the binary you actually run instead:
117
- cp "$(dirname "$(dirname "$(readlink -f "$(which hafez)")")")/skill/SKILL.md" ~/.claude/skills/hafez/SKILL.md
103
+ # 2. The skills, and the Block if you have one, ship inside the package, so
104
+ # an upgraded package leaves the installed copies stale. The new binary
105
+ # has `hafez upgrade`: it checks them by hash and prints the
106
+ # `hafez onboard --install` line that refreshes them.
107
+ hafez upgrade
118
108
 
119
109
  # 3. Rebuild the read index. A schema bump invalidates it rather than
120
110
  # rebuilding it, so the first query after an upgrade can answer empty.
@@ -126,8 +116,9 @@ probe your npm — `hafez upgrade` does probe it and prints the exact form your
126
116
  npm accepts. Same instruction, two renderings; the extra flags are harmless
127
117
  (`npm warn Unknown cli config`, then it proceeds).
128
118
 
129
- Also compare the Hafez block in your global agent instructions against the one
130
- `hafez upgrade` prints, and reconcile any differences.
119
+ A Hafez section pasted into your instructions file by hand, before the Block
120
+ had markers, is not checked: delete it, then install the Block with
121
+ `hafez onboard`.
131
122
 
132
123
  Your vault is untouched by an upgrade: the files, the git history, and the
133
124
  registered vault path all survive. Only the read index is invalidated.
@@ -158,7 +149,7 @@ hafez query --kind knowledge --domain security
158
149
  hafez search "token rotation"
159
150
 
160
151
  # Record progress
161
- hafez update auth-refactor --log "progress: JWT flow implemented" --agent claude
152
+ hafez update auth-refactor --log "progress: JWT flow implemented"
162
153
  hafez update auth-refactor --complete-action "migration plan"
163
154
  ```
164
155
 
@@ -196,18 +187,20 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
196
187
  | `hafez create <entity\|knowledge> <name> [flags]` | Create a document |
197
188
  | `hafez capture <name> [--notes]` | Quick inbox capture |
198
189
  | `hafez promote <slug> <entity\|project\|knowledge>` | Promote a capture upward |
190
+ | `hafez delete <slug>` | Delete a knowledge or session note (entities retire with `--status done`) |
199
191
  | `hafez update <slug> [flags]` | Status, brief, actions, session log, synthesis, evidence… |
200
192
  | `hafez link / unlink <slug> <target> --relation <parent\|related>` | Manage relationships |
201
- | `hafez batch [--dry-run] [--file payload.json]` | JSON ops (stdin or `--file`) → atomic multi-op, single git commit, rollback on failure; dry-run runs the same validation as apply and reports derived slugs |
193
+ | `hafez batch [--dry-run] [--data '<json>' \| --file payload.json]` | JSON ops (stdin, `--data` or `--file`) → atomic multi-op, single git commit, rollback on failure; dry-run runs the same validation as apply and reports derived slugs |
202
194
  | `hafez stats` | Vault summary: counts, stale items, recents |
203
195
  | `hafez changelog --since 7.days.ago` | Git-derived change history |
204
196
  | `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
205
197
  | `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
206
- | `hafez onboard` | Agent-directed first run: seeding interview + integration setup |
198
+ | `hafez onboard` | Setup status, then the script your agent follows: Mode, install, permissions, seeding |
199
+ | `hafez onboard --install --mode <always-on\|invoke-only>` / `--remove` | Write (or remove) the two skills and, always-on, the marked Block in the harness's instructions file |
207
200
  | `hafez schema [op] [--examples]` | Machine-readable JSON schema for every operation |
208
201
  | `hafez export --okf [--out <dir>]` | Read-only OKF v0.1 bundle export |
209
202
 
210
- This is not exhaustive — `hafez help --agent` prints the full, always-current reference. Add `--json` to any command for structured output.
203
+ This is not exhaustive — `hafez --help` lists every command, and `hafez schema <op>` prints the machine-readable shape of any op. Add `--json` to any command for structured output.
211
204
 
212
205
  ## Library usage
213
206
 
@@ -246,9 +239,9 @@ for everything on the [AGENTS.md standard](https://agents.md) (Codex, Cursor,
246
239
  Copilot, …). This repo follows the same convention: `AGENTS.md` is canonical
247
240
  and `CLAUDE.md` imports it.
248
241
 
249
- - **`hafez onboard`** — an agent-directed first-run guide: a seeding interview for the empty vault, then a choice of integration level (an always-active block for the harness's global instructions file, or invoke-only). Re-running it later is the intended way to seed newly started work — on a set-up vault it's just the interview.
250
- - **`hafez help --agent`** — a complete API reference designed to be loaded into an agent's context.
251
- - **Claude Code skill** — an optional adapter for one harness: the package bundles a skill (installed in [Setup](#setup)) covering the full session lifecycle: loading context at session start, vault ops mid-session, and a session-end digest.
242
+ - **`hafez onboard`** — agent-directed setup, run from inside Claude Code or Codex (any other harness: `onboard --install --harness <h>`): where things stand (the vault, and per harness found the Block, skills, hook and allowlist), then the script the agent follows — choose always-on or invoke-only, install, allow the command, optionally add the session-start hook, seed an empty vault, and four lines for the user. Re-running it is reconfigure: it walks only what is not current.
243
+ - **`hafez help --agent`** — the `hafez` skill body, one text from the same source: the reference an agent reads when the skill is not installed.
244
+ - **Two skills, `hafez` and `hafez-save`** — written by `hafez onboard --install` to `~/.claude/skills` and `~/.agents/skills`, so every harness reading either directory gets them: the less common operations and the tool itself, and the session save. The Block, the skills and every sample of command output are rendered from one source and checked byte for byte in CI; the samples are in [`tests/fixtures/surfaces/`](tests/fixtures/surfaces/).
252
245
  - **`hafez schema <op> --examples`** — JSON schemas with working examples for every batch operation, so agents can self-correct.
253
246
  - **`hafez batch`** — apply a whole session's worth of updates atomically from a single JSON payload. Validation errors report every bad op at once with did-you-mean hints.
254
247
  - **Markdown-native output** by default, `--json` everywhere for machine consumption.
package/dist/apply.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BatchOperation, BatchResult, CreateEntityFields, CreateKnowledgeFields, KnowledgeSubtype, UpdateFields } from './types.js';
1
+ import type { BatchOperation, BatchResult, CreateEntityFields, DeletableKind, CreateKnowledgeFields, KnowledgeSubtype, UpdateFields, UpdateResult } from './types.js';
2
2
  export interface AppliedOp {
3
3
  filesWritten: string[];
4
4
  filesDeleted: string[];
@@ -9,7 +9,10 @@ export interface AppliedOp {
9
9
  slug: string;
10
10
  kind: 'entity' | 'knowledge';
11
11
  }>;
12
- matchedAction?: string;
12
+ /** Set by the update arm: the line each Next Actions query matched. */
13
+ updateResult?: UpdateResult;
14
+ /** Set by the delete arm: the kind of note it removed. */
15
+ deletedKind?: DeletableKind;
13
16
  }
14
17
  /** Everything an apply arm needs from its caller: where the vault is, and how
15
18
  * to hand the Spine a pre-write snapshot for rollback. Path resolution, the
package/dist/apply.js CHANGED
@@ -22,10 +22,11 @@ import { join, relative, dirname } from 'path';
22
22
  import { parseFilePath, serializeFile, slugify, resolveFilePath, kindFromPath, kindDir, archiveLogPath, findVaultFile, vaultFileExists } from './vault.js';
23
23
  import { validateEntityFrontmatter, validateKnowledgeFrontmatter } from './schema.js';
24
24
  import { bodyTemplate, knowledgeBodyTemplate, sessionBodyTemplate } from './templates.js';
25
- import { formatSessionLogEntry, documentFor, documentForFile } from './document.js';
25
+ import { formatSessionLogEntry, stampAction, documentFor, documentForFile } from './document.js';
26
26
  import { sectionsFor, statusesFor, unreachableFields, describeTarget } from './contracts.js';
27
27
  import { localDay } from './dates.js';
28
- import { specFor } from './batch-ops.js';
28
+ import { detectAgent } from './harness.js';
29
+ import { specFor, loggedFields } from './batch-ops.js';
29
30
  import { HafezError } from './types.js';
30
31
  /** A vault file that validation resolved but is gone at apply time. The vault
31
32
  * lock excludes other hafez processes, not external actors (Obsidian, a hand
@@ -49,7 +50,7 @@ function entityPath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, '
49
50
  function knowledgePath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
50
51
  function today() { return localDay(); }
51
52
  // --- Field policy ---
52
- const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'resource'];
53
+ const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'agent', 'resource', 'parent'];
53
54
  const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
54
55
  // An entity-only field is still reachable on a knowledge note when THAT
55
56
  // note's contract declares the capability the field needs. The gate asks the
@@ -131,6 +132,11 @@ export function buildEntityFrontmatter(name, ef, existsFn) {
131
132
  throw new HafezError('VALIDATION_FAILED', 'Invalid entity frontmatter', errors);
132
133
  return fm;
133
134
  }
135
+ // Every action add — create, update, batch, digest — lands here, so this is
136
+ // the one place the added-date stamp is written (local day, never twice).
137
+ function addStampedAction(doc, body, action) {
138
+ return doc.addNextAction(body, stampAction(action, today()));
139
+ }
134
140
  // Body-side optional fields (brief, actions) must behave identically in both
135
141
  // paths, like buildEntityFrontmatter's.
136
142
  function buildEntityBody(ef) {
@@ -139,15 +145,15 @@ function buildEntityBody(ef) {
139
145
  if (ef.brief)
140
146
  body = doc.setBrief(body, ef.brief);
141
147
  if (ef.add_action !== undefined)
142
- body = doc.addNextAction(body, ef.add_action);
148
+ body = addStampedAction(doc, body, ef.add_action);
143
149
  for (const action of ef.add_actions ?? [])
144
- body = doc.addNextAction(body, action);
150
+ body = addStampedAction(doc, body, action);
145
151
  return body;
146
152
  }
147
153
  export function buildKnowledgeFrontmatter(name, kf, existsFn) {
148
154
  const target = { kind: 'knowledge', subtype: kf.subtype };
149
155
  assertFieldsReachable(target, kf);
150
- const fm = { name, confidence: 'observation', 'reinforcement-count': 0, created: today() };
156
+ const fm = { name, confidence: kf.confidence ?? 'observation', 'reinforcement-count': 0, created: today() };
151
157
  if (kf.domain?.length)
152
158
  fm.domain = kf.domain;
153
159
  if (kf.related?.length) {
@@ -177,6 +183,67 @@ export function buildKnowledgeFrontmatter(name, kf, existsFn) {
177
183
  fm['session-date'] = kf['session-date'];
178
184
  return fm;
179
185
  }
186
+ // The Next Actions fields are the one group in UpdateFields that all touch
187
+ // the same section, so their order within an op IS their meaning. The
188
+ // contract: prune, tick, add — clear_actions → remove_action →
189
+ // complete_action → add_action → add_actions. Every query is resolved
190
+ // against the list as it stands after the clear and before any edit, so a
191
+ // query never sees a line another field of the same op removed or added;
192
+ // two queries naming one line is a collision, not a silent overwrite. The
193
+ // section is pruned once at the end, after the adds — a tick that empties
194
+ // the open list and an add in the same op edit the section in place rather
195
+ // than removing and recreating it. The "rebuild the list" payload
196
+ // (`clear_actions` + `add_actions`) does what it reads as. Contradictory
197
+ // payloads (clear + a query) are rejected in the validate phase, not here.
198
+ //
199
+ // Lines leaving the list go to the archive log, never nowhere: the removed
200
+ // line, a cleared list, the ticked lines a prune takes with the section, and
201
+ // — once the section holds ARCHIVE_LIMIT or more ticked lines — the oldest of
202
+ // them, leaving one fewer than the limit (the session log's rule). Each line
203
+ // takes the lines nested under it (up to the next checkbox line). Run on
204
+ // every update, so a list grown by hand is trimmed by the next write; never
205
+ // on read.
206
+ const ARCHIVE_LIMIT = 10;
207
+ function applyNextActionFields(doc, body, fields) {
208
+ const result = {};
209
+ const cleared = fields.clear_actions ? doc.actionLines(body) : [];
210
+ let removedLine;
211
+ if (fields.clear_actions)
212
+ body = doc.clearNextActions(body);
213
+ const removed = fields.remove_action !== undefined ? doc.matchNextAction(body, fields.remove_action) : undefined;
214
+ const completed = fields.complete_action !== undefined ? doc.matchNextAction(body, fields.complete_action) : undefined;
215
+ // Caller misuse, not a Spine bug: like no-match and ambiguous (matchNextAction),
216
+ // it depends on the list as the earlier ops of a batch leave it, so it is
217
+ // resolved here rather than in the validate phase.
218
+ if (removed && completed && removed.line === completed.line) {
219
+ throw new HafezError('VALIDATION_FAILED', `remove_action and complete_action both match "${removed.text}" — one line, one field`);
220
+ }
221
+ if (removed)
222
+ result.removed_action = removed.text;
223
+ if (completed)
224
+ result.completed_action = completed.text;
225
+ // Both matches are line positions in the pre-edit list. A tick leaves every
226
+ // position where it was; a removal shifts the lines below it — so tick first.
227
+ if (completed)
228
+ body = doc.completeNextAction(body, completed).body;
229
+ if (removed)
230
+ ({ body, line: removedLine } = doc.removeNextAction(body, removed));
231
+ if (fields.add_action !== undefined)
232
+ body = addStampedAction(doc, body, fields.add_action);
233
+ if (fields.add_actions !== undefined) {
234
+ for (const action of fields.add_actions) {
235
+ body = addStampedAction(doc, body, action);
236
+ }
237
+ }
238
+ const overflow = doc.extractOldestTickedActions(body, ARCHIVE_LIMIT - 1);
239
+ // With no open line left the prune takes the section, ticked lines and all.
240
+ const pruned = doc.getNextActions(overflow.body).length === 0 ? doc.actionLines(overflow.body) : [];
241
+ return {
242
+ body: doc.pruneNextActions(overflow.body),
243
+ result,
244
+ archived: { cleared, removed: removedLine, ticked: [...overflow.lines, ...pruned] },
245
+ };
246
+ }
180
247
  // Pure field application, like every arm above it: the Spine has already run
181
248
  // Op Spec validation (shape + semantics). `doc` is the Document bound to the
182
249
  // file's kind (see documentFor).
@@ -189,26 +256,8 @@ function applyUpdateFields(doc, fm, body, fields) {
189
256
  if (fields.brief !== undefined) {
190
257
  body = fields.brief === null ? doc.removeBrief(body) : doc.setBrief(body, fields.brief);
191
258
  }
192
- let matchedAction;
193
- if (fields.add_action !== undefined)
194
- body = doc.addNextAction(body, fields.add_action);
195
- if (fields.add_actions !== undefined) {
196
- for (const action of fields.add_actions) {
197
- body = doc.addNextAction(body, action);
198
- }
199
- }
200
- if (fields.complete_action !== undefined) {
201
- const r = doc.completeNextAction(body, fields.complete_action);
202
- body = r.body;
203
- matchedAction = r.matched;
204
- }
205
- if (fields.remove_action !== undefined) {
206
- const r = doc.removeNextAction(body, fields.remove_action);
207
- body = r.body;
208
- matchedAction = r.matched;
209
- }
210
- if (fields.clear_actions)
211
- body = doc.clearNextActions(body);
259
+ const actions = applyNextActionFields(doc, body, fields);
260
+ body = actions.body;
212
261
  if (fields.session_log) {
213
262
  body = doc.prependSessionLogEntry(body, formatSessionLogEntry(fields.session_log));
214
263
  }
@@ -233,6 +282,8 @@ function applyUpdateFields(doc, fm, body, fields) {
233
282
  }
234
283
  if (fields.confidence !== undefined)
235
284
  fm.confidence = fields.confidence;
285
+ if (fields.parent !== undefined)
286
+ fm.parent = fields.parent;
236
287
  if (fields.tags !== undefined)
237
288
  fm.tags = fields.tags;
238
289
  if (fields.related !== undefined)
@@ -253,85 +304,46 @@ function applyUpdateFields(doc, fm, body, fields) {
253
304
  }
254
305
  if (fm['last-touched'] !== today())
255
306
  fm['last-touched'] = today();
256
- return { fm, body, matchedAction };
307
+ const updateResult = { ...actions.result, ...(fields.session_log && { logged: true }) };
308
+ return { fm, body, updateResult, archivedActions: archivedActionEntries(actions.archived) };
257
309
  }
258
- function promoteCore(ctx, slug, target, filePath, fm, body, kind) {
259
- const { vaultPath } = ctx;
260
- const currentType = kind === 'knowledge' ? 'knowledge' : fm.type;
261
- const modifiedFiles = [];
262
- const deletedFiles = [];
263
- if (target === 'knowledge') {
264
- // Cross-kind: move file from entities/ to knowledge/. The source body
265
- // is read under the entity Document binding.
266
- const sourceDoc = documentFor({ kind: 'entity' });
267
- const newPath = knowledgePath(vaultPath, slug);
268
- // Promote must behave like create AT THE TARGET. A diverged vault can
269
- // hold the same slug as both an entity and a knowledge note (findFile
270
- // resolves entities first, so the validate phase sees only the entity);
271
- // without this check the write below silently destroys the note.
272
- if (existsSync(newPath)) {
273
- throw new HafezError('SLUG_EXISTS', `Slug '${slug}' already exists in ${kindDir('knowledge')}/`);
274
- }
275
- const notesSection = sourceDoc.findSection(body, 'Notes');
276
- const notesContent = notesSection?.content ?? '';
277
- const newFm = {
278
- name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
279
- };
280
- if (fm.description)
281
- newFm.description = fm.description;
282
- // resource is intentionally dropped on entity→knowledge promote (entity-only field)
283
- if (fm.domain)
284
- newFm.domain = fm.domain;
285
- if (fm.related)
286
- newFm.related = fm.related;
287
- if (fm.tags)
288
- newFm.tags = fm.tags;
289
- const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
290
- const finalBody = documentFor({ kind: 'knowledge' }).syncRelatedSection(knBody, newFm);
291
- // An entities-only vault has no knowledge/ yet — create it like the
292
- // create-knowledge arm does, and map any fs failure onto a HafezError
293
- // so no raw ENOENT/ENOTDIR escapes the promote path.
294
- try {
295
- mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
296
- writeFileSync(newPath, serializeFile(newFm, finalBody));
297
- unlinkSync(filePath);
298
- }
299
- catch (err) {
300
- throw new HafezError('VALIDATION_FAILED', `Failed to promote '${slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
301
- }
302
- modifiedFiles.push(relative(vaultPath, newPath));
303
- // The deletion must be staged too — `git add` of a removed path stages
304
- // the removal; omitting it leaves the vault permanently dirty.
305
- deletedFiles.push(relative(vaultPath, filePath));
310
+ /** The archive log's entries for action lines leaving the list. Entry-shaped
311
+ * like the session log entries archived beside them, so the file stays one
312
+ * entry stream (merge.ts unions it by entry). */
313
+ function archivedActionEntries(archived) {
314
+ const entries = [];
315
+ const plural = (n) => n === 1 ? '' : 's';
316
+ if (archived.cleared.length > 0) {
317
+ const n = archived.cleared.length;
318
+ entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: `Cleared ${n} action${plural(n)}`, body: archived.cleared.join('\n') }));
306
319
  }
307
- else {
308
- // Same-kind: capture→entity, capture→project, entity→project
309
- fm.type = target;
310
- fm['last-touched'] = today();
311
- // All body reshaping goes through Document — structural sections only,
312
- // so quoted heading mentions inside content can no longer suppress or
313
- // hijack the splice points.
314
- const doc = documentFor({ kind: 'entity' });
315
- let newBody = body;
316
- if (target === 'entity') {
317
- newBody = doc.renameSection(newBody, 'Notes', 'Context');
318
- }
319
- else {
320
- // project
321
- newBody = doc.renameSection(newBody, 'Notes', 'Purpose');
322
- newBody = doc.renameSection(newBody, 'Context', 'Purpose');
323
- newBody = doc.ensureSection(newBody, 'Goals', { after: 'Purpose' });
324
- }
325
- newBody = doc.prependSessionLogEntry(newBody, formatSessionLogEntry({
326
- type: 'decision',
327
- agent: 'system',
328
- summary: `Promoted from ${currentType} to ${target}`,
329
- }));
330
- newBody = doc.syncRelatedSection(newBody, fm);
331
- writeFileSync(filePath, serializeFile(fm, newBody));
332
- modifiedFiles.push(relative(vaultPath, filePath));
320
+ if (archived.removed !== undefined) {
321
+ entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body: archived.removed }));
322
+ }
323
+ const n = archived.ticked.length;
324
+ if (n > 0) {
325
+ entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: `Archived ${n} ticked action${plural(n)}`, body: archived.ticked.join('\n') }));
333
326
  }
334
- return { modifiedFiles, deletedFiles };
327
+ return entries;
328
+ }
329
+ /** Mechanised session log (#85, moved into the Spine by #107): an entity
330
+ * update with no session_log gets a `progress` entry naming the fields
331
+ * written, so history is complete whichever caller wrote it. Action-only
332
+ * updates are exempt, and only entities carry a Session Log. */
333
+ function withMechanisedLog(kind, fields) {
334
+ if (kind !== 'entity' || fields.session_log)
335
+ return fields;
336
+ const logged = loggedFields(fields);
337
+ if (logged.length === 0)
338
+ return fields;
339
+ return {
340
+ ...fields,
341
+ session_log: {
342
+ type: 'progress',
343
+ summary: `Updated ${logged.map(f => f.replace(/_/g, '-')).join(', ')}`,
344
+ agent: fields.agent ?? detectAgent(),
345
+ },
346
+ };
335
347
  }
336
348
  // --- The arms ---
337
349
  function applyUpdate(op, ctx) {
@@ -343,29 +355,32 @@ function applyUpdate(op, ctx) {
343
355
  const doc = documentForFile(parsedFile);
344
356
  let fm = parsedFile.frontmatter;
345
357
  let body = parsedFile.body;
346
- const applied = applyUpdateFields(doc, fm, body, op.fields);
358
+ const applied = applyUpdateFields(doc, fm, body, withMechanisedLog(kind, op.fields));
347
359
  fm = applied.fm;
348
360
  body = applied.body;
349
- // Session log archival
350
- const archivePath = archiveLogPath(vaultPath, op.slug);
351
- let archived = false;
352
- if (doc.countSessionLogEntries(body) >= 10) {
361
+ // Archival: action lines that left the list, and the oldest session log entry
362
+ const archiveEntries = [...applied.archivedActions];
363
+ if (doc.countSessionLogEntries(body) >= ARCHIVE_LIMIT) {
353
364
  const extracted = doc.extractOldestSessionLogEntry(body);
354
365
  if (extracted) {
355
- ctx.captureOriginal(archivePath);
356
- mkdirSync(dirname(archivePath), { recursive: true });
357
- appendFileSync(archivePath, extracted.entry + '\n\n');
366
+ archiveEntries.push(extracted.entry);
358
367
  body = extracted.remaining;
359
- archived = true;
360
368
  }
361
369
  }
370
+ const archivePath = archiveLogPath(vaultPath, op.slug);
371
+ const wroteArchive = archiveEntries.length > 0;
372
+ if (wroteArchive) {
373
+ ctx.captureOriginal(archivePath);
374
+ mkdirSync(dirname(archivePath), { recursive: true });
375
+ appendFileSync(archivePath, archiveEntries.map(e => e + '\n\n').join(''));
376
+ }
362
377
  body = doc.syncRelatedSection(body, fm);
363
378
  writeFileSync(filePath, serializeFile(fm, body));
364
379
  // Only the files THIS update wrote. Keying off the archive file merely
365
380
  // existing swept any external hand-edit sitting in entities/archive/
366
381
  // into an unrelated commit.
367
382
  const files = [relative(vaultPath, filePath)];
368
- if (archived)
383
+ if (wroteArchive)
369
384
  files.push(relative(vaultPath, archivePath));
370
385
  return {
371
386
  filesWritten: files,
@@ -374,7 +389,7 @@ function applyUpdate(op, ctx) {
374
389
  commitMessage: `update: ${op.slug}`,
375
390
  indexRemovals: [],
376
391
  indexUpserts: [{ slug: op.slug, kind }],
377
- matchedAction: applied.matchedAction,
392
+ updateResult: applied.updateResult,
378
393
  };
379
394
  }
380
395
  function applyCreateEntity(op, ctx) {
@@ -551,7 +566,79 @@ function applyPromote(op, ctx) {
551
566
  const fm = parsedFile.frontmatter;
552
567
  const body = parsedFile.body;
553
568
  const sourceType = kind === 'knowledge' ? 'knowledge' : fm.type;
554
- const { modifiedFiles, deletedFiles } = promoteCore(ctx, op.slug, op.target, filePath, fm, body, kind);
569
+ const modifiedFiles = [];
570
+ const deletedFiles = [];
571
+ if (op.target === 'knowledge') {
572
+ // Cross-kind: move file from entities/ to knowledge/. The source body
573
+ // is read under the entity Document binding.
574
+ const sourceDoc = documentFor({ kind: 'entity' });
575
+ const newPath = knowledgePath(vaultPath, op.slug);
576
+ // Promote must behave like create AT THE TARGET. A diverged vault can
577
+ // hold the same slug as both an entity and a knowledge note (findFile
578
+ // resolves entities first, so the validate phase sees only the entity);
579
+ // without this check the write below silently destroys the note.
580
+ if (existsSync(newPath)) {
581
+ throw new HafezError('SLUG_EXISTS', `Slug '${op.slug}' already exists in ${kindDir('knowledge')}/`);
582
+ }
583
+ const notesSection = sourceDoc.findSection(body, 'Notes');
584
+ const notesContent = notesSection?.content ?? '';
585
+ const newFm = {
586
+ name: fm.name, subtype: 'insight', confidence: 'observation', created: fm.created || today(),
587
+ };
588
+ if (fm.description)
589
+ newFm.description = fm.description;
590
+ // resource is intentionally dropped on entity→knowledge promote (entity-only field)
591
+ if (fm.domain)
592
+ newFm.domain = fm.domain;
593
+ if (fm.related)
594
+ newFm.related = fm.related;
595
+ if (fm.tags)
596
+ newFm.tags = fm.tags;
597
+ const knBody = knowledgeBodyTemplate('insight', { synthesis: notesContent || undefined });
598
+ const finalBody = documentFor({ kind: 'knowledge' }).syncRelatedSection(knBody, newFm);
599
+ // An entities-only vault has no knowledge/ yet — create it like the
600
+ // create-knowledge arm does, and map any fs failure onto a HafezError
601
+ // so no raw ENOENT/ENOTDIR escapes the promote path.
602
+ try {
603
+ mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
604
+ writeFileSync(newPath, serializeFile(newFm, finalBody));
605
+ unlinkSync(filePath);
606
+ }
607
+ catch (err) {
608
+ throw new HafezError('VALIDATION_FAILED', `Failed to promote '${op.slug}' to knowledge: ${err instanceof Error ? err.message : String(err)}`);
609
+ }
610
+ modifiedFiles.push(relative(vaultPath, newPath));
611
+ // The deletion must be staged too — `git add` of a removed path stages
612
+ // the removal; omitting it leaves the vault permanently dirty.
613
+ deletedFiles.push(relative(vaultPath, filePath));
614
+ }
615
+ else {
616
+ // Same-kind: capture→entity, capture→project, entity→project
617
+ fm.type = op.target;
618
+ fm['last-touched'] = today();
619
+ // All body reshaping goes through Document — structural sections only,
620
+ // so quoted heading mentions inside content can no longer suppress or
621
+ // hijack the splice points.
622
+ const doc = documentFor({ kind: 'entity' });
623
+ let newBody = body;
624
+ if (op.target === 'entity') {
625
+ newBody = doc.renameSection(newBody, 'Notes', 'Context');
626
+ }
627
+ else {
628
+ // project
629
+ newBody = doc.renameSection(newBody, 'Notes', 'Purpose');
630
+ newBody = doc.renameSection(newBody, 'Context', 'Purpose');
631
+ newBody = doc.ensureSection(newBody, 'Goals', { after: 'Purpose' });
632
+ }
633
+ newBody = doc.prependSessionLogEntry(newBody, formatSessionLogEntry({
634
+ type: 'decision',
635
+ agent: 'system',
636
+ summary: `Promoted from ${sourceType} to ${op.target}`,
637
+ }));
638
+ newBody = doc.syncRelatedSection(newBody, fm);
639
+ writeFileSync(filePath, serializeFile(fm, newBody));
640
+ modifiedFiles.push(relative(vaultPath, filePath));
641
+ }
555
642
  return {
556
643
  filesWritten: modifiedFiles,
557
644
  filesDeleted: deletedFiles,
@@ -561,6 +648,29 @@ function applyPromote(op, ctx) {
561
648
  indexUpserts: [{ slug: op.slug, kind: op.target === 'knowledge' ? 'knowledge' : 'entity' }],
562
649
  };
563
650
  }
651
+ function applyDelete(op, ctx) {
652
+ const { vaultPath } = ctx;
653
+ // By path, not findVaultFile's entity-first order: the Op Spec ruled out
654
+ // an entity, and resolved the slug to exactly one of these two.
655
+ const knowledgeFile = knowledgePath(vaultPath, op.slug);
656
+ const kind = existsSync(knowledgeFile) ? 'knowledge' : 'session';
657
+ const filePath = kind === 'knowledge' ? knowledgeFile : resolveFilePath(vaultPath, op.slug, 'session');
658
+ if (!existsSync(filePath))
659
+ vanished(op.slug);
660
+ // The snapshot is the rollback: a commit-stage failure writes it back.
661
+ ctx.captureOriginal(filePath);
662
+ unlinkSync(filePath);
663
+ return {
664
+ filesWritten: [],
665
+ filesDeleted: [relative(vaultPath, filePath)],
666
+ result: { op: 'delete', slug: op.slug, status: 'ok' },
667
+ commitMessage: `delete: ${op.slug} (${kind})`,
668
+ // Sessions are not indexed; a knowledge row goes with its file.
669
+ indexRemovals: kind === 'knowledge' ? [op.slug] : [],
670
+ indexUpserts: [],
671
+ deletedKind: kind,
672
+ };
673
+ }
564
674
  const APPLY = {
565
675
  'update': applyUpdate,
566
676
  'create-entity': applyCreateEntity,
@@ -570,6 +680,7 @@ const APPLY = {
570
680
  'link': applyLink,
571
681
  'unlink': applyUnlink,
572
682
  'promote': applyPromote,
683
+ 'delete': applyDelete,
573
684
  };
574
685
  /** The single apply path for every mutation. Writes vault files but never
575
686
  * touches git or SQLite: the Spine commits [...filesWritten, ...filesDeleted]