hafez 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -1
- package/dist/apply.d.ts +12 -0
- package/dist/apply.js +82 -17
- package/dist/batch-ops.d.ts +31 -27
- package/dist/batch-ops.js +80 -15
- package/dist/cli/block-hook.d.ts +6 -0
- package/dist/cli/block-hook.js +3 -3
- package/dist/cli/commands.d.ts +2 -0
- package/dist/cli/commands.js +98 -46
- package/dist/cli/format.d.ts +4 -1
- package/dist/cli/format.js +19 -0
- package/dist/cli/index.js +11 -2
- package/dist/cli/upgrade.js +1 -1
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.js +9 -0
- package/dist/dates.d.ts +3 -0
- package/dist/dates.js +9 -0
- package/dist/db.d.ts +34 -4
- package/dist/db.js +163 -21
- package/dist/digest.js +14 -7
- package/dist/document.d.ts +2 -0
- package/dist/document.js +2 -2
- package/dist/git.d.ts +13 -2
- package/dist/git.js +47 -5
- package/dist/graph/activity.d.ts +5 -0
- package/dist/graph/activity.js +70 -0
- package/dist/graph/client.js +326 -0
- package/dist/graph/copy-fonts.d.ts +1 -0
- package/dist/graph/copy-fonts.js +13 -0
- package/dist/graph/fields.d.ts +8 -0
- package/dist/graph/fields.js +12 -0
- package/dist/graph/fonts/geist-OFL.txt +93 -0
- package/dist/graph/fonts/geist-mono-OFL.txt +93 -0
- package/dist/graph/fonts/geist-mono.woff2 +0 -0
- package/dist/graph/fonts/geist.woff2 +0 -0
- package/dist/graph/fonts/outfit-OFL.txt +93 -0
- package/dist/graph/fonts/outfit.woff2 +0 -0
- package/dist/graph/fonts.d.ts +14 -0
- package/dist/graph/fonts.js +17 -0
- package/dist/graph/live.d.ts +16 -0
- package/dist/graph/live.js +99 -0
- package/dist/graph/open.d.ts +1 -0
- package/dist/graph/open.js +20 -0
- package/dist/graph/server.d.ts +17 -0
- package/dist/graph/server.js +189 -0
- package/dist/health.d.ts +6 -0
- package/dist/health.js +310 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +37 -9
- package/dist/internal.d.ts +21 -0
- package/dist/internal.js +9 -0
- package/dist/read-json.d.ts +26 -0
- package/dist/read-json.js +53 -0
- package/dist/stale.d.ts +2 -2
- package/dist/stale.js +4 -9
- package/dist/surfaces/items.d.ts +44 -0
- package/dist/surfaces/items.js +68 -9
- package/dist/surfaces/surfaces.js +3 -1
- package/dist/templates.d.ts +2 -0
- package/dist/templates.js +6 -1
- package/dist/types.d.ts +178 -8
- package/dist/vault.d.ts +9 -2
- package/dist/vault.js +10 -0
- package/dist/workstream.d.ts +17 -0
- package/dist/workstream.js +78 -0
- package/package.json +9 -3
- package/skill/hafez/SKILL.md +12 -6
package/README.md
CHANGED
|
@@ -167,6 +167,9 @@ hafez query --kind knowledge --domain security
|
|
|
167
167
|
# Full-text search across the whole vault
|
|
168
168
|
hafez search "token rotation"
|
|
169
169
|
|
|
170
|
+
# See it in context: a live graph of the vault in your browser
|
|
171
|
+
hafez graph auth-refactor
|
|
172
|
+
|
|
170
173
|
# Record progress
|
|
171
174
|
hafez update auth-refactor --log "progress: JWT flow implemented"
|
|
172
175
|
hafez update auth-refactor --complete-action "migration plan"
|
|
@@ -194,7 +197,49 @@ Frontmatter at a glance:
|
|
|
194
197
|
| `resource` (canonical URI) | optional | — |
|
|
195
198
|
| `subtype`, `confidence` | — | optional |
|
|
196
199
|
|
|
197
|
-
Links (`parent`, `related`) are validated — they must point at slugs that exist. `hafez validate` checks vault integrity.
|
|
200
|
+
Links (`parent`, `related`) are validated — they must point at slugs that exist. A `related` link between two entities, or two knowledge notes, is kept on both files. `hafez validate` checks vault integrity.
|
|
201
|
+
|
|
202
|
+
## Where your judgement matters
|
|
203
|
+
|
|
204
|
+
The agent writes to your vault as work lands, without asking. Behind a few of those writes is a judgement no rule in the tool can make, and the Health pass's proposals wait for your yes. Each choice is below as the question, the test, and what happens once it is made, so you can make it, or check the one the agent made.
|
|
205
|
+
|
|
206
|
+
### 1. Child or related?
|
|
207
|
+
|
|
208
|
+
**Question:** one piece of work bears on another. Is it part of it, or beside it?
|
|
209
|
+
|
|
210
|
+
**Test:** would it carry on if the other were finished or dropped? If not, it is a **Child**. If so, the two are **Related**.
|
|
211
|
+
|
|
212
|
+
**Then:** a Child carries `parent`, the one mark of childhood, and the parent's `read` lists it. Related between two entities, or two knowledge notes, is one edge the tool keeps on both files. A pair is never both: a `related` naming a parent or a child is rejected, and so is a `--parent` naming a related entity. A parent whose own work is coordination only is a **Hub**.
|
|
213
|
+
|
|
214
|
+
### 2. Split a Workstream or not?
|
|
215
|
+
|
|
216
|
+
**Question:** an entity's `read` shows a `workstream:` line: its actions share an ALL-CAPS prefix that no Child or related entity carries in its name. Is it a real thread of work, or a habit of phrasing?
|
|
217
|
+
|
|
218
|
+
**Test:** would you track it, brief it and finish it apart from the parent?
|
|
219
|
+
|
|
220
|
+
**Then:** split, and the agent does it with ordinary writes: a Child is created under the parent, the open actions move to it, the log entries stay where they are, and one `decision` entry on the parent records the split. Don't, and nothing is written: the line is a fact, not a request, and it goes once those actions are done.
|
|
221
|
+
|
|
222
|
+
### 3. Decision or lesson?
|
|
223
|
+
|
|
224
|
+
**Question:** something was settled. Where does it live?
|
|
225
|
+
|
|
226
|
+
**Test:** is it still true with this entity gone, no slug, path or "we chose" needed? If not, it is a **Decision**; if so, a lesson.
|
|
227
|
+
|
|
228
|
+
**Then:** a decision is one `decision` entry on the log of the entity that owns the work (or an entry pointing at the repo's own record). A lesson is a knowledge note, related to where it came from. One fact, one place: a knowledge note that only repeats a decision is the Health pass's "restated decision".
|
|
229
|
+
|
|
230
|
+
### 4. Retire or delete?
|
|
231
|
+
|
|
232
|
+
**Question:** something in the vault is no longer wanted.
|
|
233
|
+
|
|
234
|
+
**Test:** is it an entity? Then it is retired. A knowledge or session note can be deleted.
|
|
235
|
+
|
|
236
|
+
**Then:** retiring sets `--status done`: the entity stays, readable and linked, and leaves the live views unless a Child of it is not yet done. Deleting removes the note in one commit, together with the back-edges it owns (the notes its own `related` names that name it back); any other entity or knowledge note naming it as `parent` or `related` refuses the delete until that link goes. Every write is a git commit, so either can be undone.
|
|
237
|
+
|
|
238
|
+
### 5. What `hafez health` lists
|
|
239
|
+
|
|
240
|
+
`hafez health` is the **Health pass**: it reads the vault and lists what placement would change, applying nothing. Run it, or ask the agent to check the vault; a sample of its output is [`health.md`](tests/fixtures/surfaces/health.md).
|
|
241
|
+
|
|
242
|
+
Its six groups take three kinds of answer. **One-way edges** (a `related` kept on one end only) and **unlinked mentions** (a `[[slug]]` with no edge either way) are mechanical: each comes with the `link` line that adds the edge the vault already implies, so one yes can cover the group. **Workstreams** are question 2, one entity at a time. **Leakage** (a parent's, or a related entity's, log naming an entity since that entity was created: was that its work?), **restated decisions** (question 3) and **recurring names** (a name that keeps coming back in one entity's log and actions) are judged one by one: nothing is proposed for them.
|
|
198
243
|
|
|
199
244
|
## CLI overview
|
|
200
245
|
|
|
@@ -212,8 +257,10 @@ Links (`parent`, `related`) are validated — they must point at slugs that exis
|
|
|
212
257
|
| `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 |
|
|
213
258
|
| `hafez stats` | Vault summary: counts, stale items, recents |
|
|
214
259
|
| `hafez changelog --since 7.days.ago` | Git-derived change history |
|
|
260
|
+
| `hafez graph [slug] [--port N] [--no-open]` | Interactive, live graph of the vault in the browser (served on 127.0.0.1); a slug opens it focused on that note; `--json [--layers tag,domain,session]` prints `{nodes, edges}` |
|
|
215
261
|
| `hafez sync` | Pull remote, push local commits (semantic merge on conflicts) |
|
|
216
262
|
| `hafez validate` / `hafez index rebuild` | Integrity check / rebuild the read index |
|
|
263
|
+
| `hafez health` | What placement would change: unsplit workstreams, recurring names, leakage, one-way `related` edges and unlinked `[[slug]]` mentions (each with its `hafez link` line), restated decisions; applies nothing |
|
|
217
264
|
| `hafez onboard` | Setup status, then the script your agent follows: Mode, install, permissions, seeding |
|
|
218
265
|
| `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 |
|
|
219
266
|
| `hafez onboard --cloud --vault-repo <owner>/<repo>` | Print the setup script for Claude Code cloud sessions |
|
package/dist/apply.d.ts
CHANGED
|
@@ -26,6 +26,18 @@ export interface ApplyContext {
|
|
|
26
26
|
export declare function validateKindFields(kind: 'entity' | 'knowledge', fields: UpdateFields, subtype?: KnowledgeSubtype): void;
|
|
27
27
|
export declare function buildEntityFrontmatter(name: string, ef: CreateEntityFields, existsFn: (slug: string) => boolean): Record<string, any>;
|
|
28
28
|
export declare function buildKnowledgeFrontmatter(name: string, kf: CreateKnowledgeFields, existsFn: (slug: string) => boolean): Record<string, any>;
|
|
29
|
+
/** The mirror file an edge op touched: what the arm appends to its
|
|
30
|
+
* filesWritten and indexUpserts. */
|
|
31
|
+
export interface EdgeTouch {
|
|
32
|
+
file: string;
|
|
33
|
+
upsert: {
|
|
34
|
+
slug: string;
|
|
35
|
+
kind: VaultKind;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Drop `a` from `b`'s related when the edge is same-kind. Exported for the
|
|
39
|
+
* delete arm (#243) as well. */
|
|
40
|
+
export declare function dropBackEdge(ctx: ApplyContext, a: string, aKind: VaultKind, b: string): EdgeTouch | null;
|
|
29
41
|
/** The single apply path for every mutation. Writes vault files but never
|
|
30
42
|
* touches git or SQLite: the Spine commits [...filesWritten, ...filesDeleted]
|
|
31
43
|
* under commitMessage (single ops) or one composed batch message, then runs
|
package/dist/apply.js
CHANGED
|
@@ -369,6 +369,42 @@ function withMechanisedLog(kind, fields) {
|
|
|
369
369
|
},
|
|
370
370
|
};
|
|
371
371
|
}
|
|
372
|
+
function mirrorEdge(ctx, a, aKind, b, mode) {
|
|
373
|
+
if (a === b)
|
|
374
|
+
return null;
|
|
375
|
+
const bPath = findVaultFile(ctx.vaultPath, b);
|
|
376
|
+
if (!bPath || kindFromPath(bPath) !== aKind)
|
|
377
|
+
return null;
|
|
378
|
+
const parsed = parseFilePath(bPath);
|
|
379
|
+
const fm = parsed.frontmatter;
|
|
380
|
+
const related = fm.related ?? [];
|
|
381
|
+
if (mode === 'write' ? related.includes(a) : !related.includes(a))
|
|
382
|
+
return null;
|
|
383
|
+
ctx.captureOriginal(bPath);
|
|
384
|
+
if (mode === 'write')
|
|
385
|
+
fm.related = [...related, a];
|
|
386
|
+
else {
|
|
387
|
+
fm.related = related.filter(r => r !== a);
|
|
388
|
+
if (fm.related.length === 0)
|
|
389
|
+
delete fm.related;
|
|
390
|
+
}
|
|
391
|
+
const body = documentForFile(parsed).syncRelatedSection(parsed.body, fm);
|
|
392
|
+
writeFileSync(bPath, serializeFile(fm, body));
|
|
393
|
+
return { file: vaultRel(ctx.vaultPath, bPath), upsert: { slug: b, kind: aKind } };
|
|
394
|
+
}
|
|
395
|
+
/** Write `a` into `b`'s related when the edge is same-kind. */
|
|
396
|
+
function writeBackEdge(ctx, a, aKind, b) {
|
|
397
|
+
return mirrorEdge(ctx, a, aKind, b, 'write');
|
|
398
|
+
}
|
|
399
|
+
/** Write `a` into every same-kind target's related. */
|
|
400
|
+
function backEdges(ctx, a, aKind, targets) {
|
|
401
|
+
return targets.flatMap(b => writeBackEdge(ctx, a, aKind, b) ?? []);
|
|
402
|
+
}
|
|
403
|
+
/** Drop `a` from `b`'s related when the edge is same-kind. Exported for the
|
|
404
|
+
* delete arm (#243) as well. */
|
|
405
|
+
export function dropBackEdge(ctx, a, aKind, b) {
|
|
406
|
+
return mirrorEdge(ctx, a, aKind, b, 'drop');
|
|
407
|
+
}
|
|
372
408
|
// --- The arms ---
|
|
373
409
|
function applyUpdate(op, ctx) {
|
|
374
410
|
const { vaultPath } = ctx;
|
|
@@ -379,6 +415,9 @@ function applyUpdate(op, ctx) {
|
|
|
379
415
|
const doc = documentForFile(parsedFile);
|
|
380
416
|
let fm = parsedFile.frontmatter;
|
|
381
417
|
let body = parsedFile.body;
|
|
418
|
+
// The related list before the fields replace it: the diff against the new
|
|
419
|
+
// one is what the edge writer and dropper mirror on the targets.
|
|
420
|
+
const relatedBefore = fm.related ?? [];
|
|
382
421
|
const applied = applyUpdateFields(doc, fm, body, withMechanisedLog(kind, op.fields));
|
|
383
422
|
fm = applied.fm;
|
|
384
423
|
body = applied.body;
|
|
@@ -400,19 +439,30 @@ function applyUpdate(op, ctx) {
|
|
|
400
439
|
}
|
|
401
440
|
body = doc.syncRelatedSection(body, fm);
|
|
402
441
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
442
|
+
const touches = [];
|
|
443
|
+
if (op.fields.related !== undefined) {
|
|
444
|
+
const relatedAfter = fm.related ?? [];
|
|
445
|
+
touches.push(...backEdges(ctx, op.slug, kind, relatedAfter.filter(r => !relatedBefore.includes(r))));
|
|
446
|
+
for (const r of relatedBefore.filter(r => !relatedAfter.includes(r))) {
|
|
447
|
+
const t = dropBackEdge(ctx, op.slug, kind, r);
|
|
448
|
+
if (t)
|
|
449
|
+
touches.push(t);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
403
452
|
// Only the files THIS update wrote. Keying off the archive file merely
|
|
404
453
|
// existing swept any external hand-edit sitting in entities/archive/
|
|
405
454
|
// into an unrelated commit.
|
|
406
455
|
const files = [vaultRel(vaultPath, filePath)];
|
|
407
456
|
if (wroteArchive)
|
|
408
457
|
files.push(vaultRel(vaultPath, archivePath));
|
|
458
|
+
files.push(...touches.map(t => t.file));
|
|
409
459
|
return {
|
|
410
460
|
filesWritten: files,
|
|
411
461
|
filesDeleted: [],
|
|
412
462
|
result: { op: 'update', slug: op.slug, status: 'ok' },
|
|
413
463
|
commitMessage: `update: ${op.slug}`,
|
|
414
464
|
indexRemovals: [],
|
|
415
|
-
indexUpserts: [{ slug: op.slug, kind }],
|
|
465
|
+
indexUpserts: [{ slug: op.slug, kind }, ...touches.map(t => t.upsert)],
|
|
416
466
|
updateResult: applied.updateResult,
|
|
417
467
|
};
|
|
418
468
|
}
|
|
@@ -432,13 +482,14 @@ function applyCreateEntity(op, ctx) {
|
|
|
432
482
|
mkdirSync(join(vaultPath, kindDir('entity')), { recursive: true });
|
|
433
483
|
body = documentFor({ kind: 'entity' }).syncRelatedSection(body, fm);
|
|
434
484
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
485
|
+
const touches = backEdges(ctx, slug, 'entity', fm.related ?? []);
|
|
435
486
|
return {
|
|
436
|
-
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
487
|
+
filesWritten: [vaultRel(vaultPath, filePath), ...touches.map(t => t.file)],
|
|
437
488
|
filesDeleted: [],
|
|
438
489
|
result: { op: 'create', slug, status: 'ok', created: true },
|
|
439
490
|
commitMessage: `create: ${op.name}`,
|
|
440
491
|
indexRemovals: [],
|
|
441
|
-
indexUpserts: [{ slug, kind: 'entity' }],
|
|
492
|
+
indexUpserts: [{ slug, kind: 'entity' }, ...touches.map(t => t.upsert)],
|
|
442
493
|
};
|
|
443
494
|
}
|
|
444
495
|
function applyCreateKnowledge(op, ctx) {
|
|
@@ -454,13 +505,14 @@ function applyCreateKnowledge(op, ctx) {
|
|
|
454
505
|
mkdirSync(join(vaultPath, kindDir('knowledge')), { recursive: true });
|
|
455
506
|
body = documentFor({ kind: 'knowledge' }).syncRelatedSection(body, fm);
|
|
456
507
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
508
|
+
const touches = backEdges(ctx, slug, 'knowledge', fm.related ?? []);
|
|
457
509
|
return {
|
|
458
|
-
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
510
|
+
filesWritten: [vaultRel(vaultPath, filePath), ...touches.map(t => t.file)],
|
|
459
511
|
filesDeleted: [],
|
|
460
512
|
result: { op: 'create', slug, status: 'ok', created: true },
|
|
461
513
|
commitMessage: `create: ${op.name}`,
|
|
462
514
|
indexRemovals: [],
|
|
463
|
-
indexUpserts: [{ slug, kind: 'knowledge' }],
|
|
515
|
+
indexUpserts: [{ slug, kind: 'knowledge' }, ...touches.map(t => t.upsert)],
|
|
464
516
|
};
|
|
465
517
|
}
|
|
466
518
|
function applyCreateSession(op, ctx) {
|
|
@@ -480,14 +532,14 @@ function applyCreateSession(op, ctx) {
|
|
|
480
532
|
appeared(slug);
|
|
481
533
|
ctx.captureOriginal(filePath);
|
|
482
534
|
const fm = { name: op.name, created: today() };
|
|
483
|
-
const kf =
|
|
535
|
+
const kf = op.fields ?? {};
|
|
484
536
|
if (kf['session-date'])
|
|
485
537
|
fm['session-date'] = kf['session-date'];
|
|
486
538
|
if (kf.related?.length)
|
|
487
539
|
fm.related = kf.related;
|
|
488
540
|
// Session sections come from contracts.SESSION_SECTIONS — the same list
|
|
489
541
|
// Document splits on, so the written body and the structural set agree.
|
|
490
|
-
let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis });
|
|
542
|
+
let sessionBody = sessionBodyTemplate({ synthesis: kf.synthesis, decisions: kf.decisions, entities_touched: kf.entities_touched });
|
|
491
543
|
sessionBody = documentFor({ kind: 'session' }).syncRelatedSection(sessionBody, fm);
|
|
492
544
|
writeFileSync(filePath, serializeFile(fm, sessionBody));
|
|
493
545
|
return {
|
|
@@ -531,6 +583,8 @@ function applyLink(op, ctx) {
|
|
|
531
583
|
const parsedFile = parseFilePath(filePath);
|
|
532
584
|
const fm = parsedFile.frontmatter;
|
|
533
585
|
let body = parsedFile.body;
|
|
586
|
+
const kind = kindFromPath(filePath);
|
|
587
|
+
let touch = null;
|
|
534
588
|
if (op.relation === 'parent') {
|
|
535
589
|
fm.parent = op.target;
|
|
536
590
|
}
|
|
@@ -539,16 +593,17 @@ function applyLink(op, ctx) {
|
|
|
539
593
|
if (!related.includes(op.target))
|
|
540
594
|
related.push(op.target);
|
|
541
595
|
fm.related = related;
|
|
596
|
+
touch = writeBackEdge(ctx, op.slug, kind, op.target);
|
|
542
597
|
}
|
|
543
598
|
body = documentForFile(parsedFile).syncRelatedSection(body, fm);
|
|
544
599
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
545
600
|
return {
|
|
546
|
-
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
601
|
+
filesWritten: [vaultRel(vaultPath, filePath), ...(touch ? [touch.file] : [])],
|
|
547
602
|
filesDeleted: [],
|
|
548
603
|
result: { op: 'link', slug: op.slug, status: 'ok' },
|
|
549
604
|
commitMessage: `link: ${op.slug} → ${op.target} (${op.relation})`,
|
|
550
605
|
indexRemovals: [],
|
|
551
|
-
indexUpserts: [{ slug: op.slug, kind
|
|
606
|
+
indexUpserts: [{ slug: op.slug, kind }, ...(touch ? [touch.upsert] : [])],
|
|
552
607
|
};
|
|
553
608
|
}
|
|
554
609
|
function applyUnlink(op, ctx) {
|
|
@@ -558,23 +613,30 @@ function applyUnlink(op, ctx) {
|
|
|
558
613
|
const parsedFile = parseFilePath(filePath);
|
|
559
614
|
const fm = parsedFile.frontmatter;
|
|
560
615
|
let body = parsedFile.body;
|
|
616
|
+
const kind = kindFromPath(filePath);
|
|
617
|
+
let touch = null;
|
|
561
618
|
if (op.relation === 'parent' && fm.parent === op.target) {
|
|
562
619
|
delete fm.parent;
|
|
563
620
|
}
|
|
564
|
-
else if (op.relation === 'related'
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
621
|
+
else if (op.relation === 'related') {
|
|
622
|
+
if (fm.related) {
|
|
623
|
+
fm.related = fm.related.filter((r) => r !== op.target);
|
|
624
|
+
if (fm.related.length === 0)
|
|
625
|
+
delete fm.related;
|
|
626
|
+
}
|
|
627
|
+
// The target's end goes too, even when this file never carried the edge
|
|
628
|
+
// (a legacy one-way edge).
|
|
629
|
+
touch = dropBackEdge(ctx, op.slug, kind, op.target);
|
|
568
630
|
}
|
|
569
631
|
body = documentForFile(parsedFile).syncRelatedSection(body, fm);
|
|
570
632
|
writeFileSync(filePath, serializeFile(fm, body));
|
|
571
633
|
return {
|
|
572
|
-
filesWritten: [vaultRel(vaultPath, filePath)],
|
|
634
|
+
filesWritten: [vaultRel(vaultPath, filePath), ...(touch ? [touch.file] : [])],
|
|
573
635
|
filesDeleted: [],
|
|
574
636
|
result: { op: 'unlink', slug: op.slug, status: 'ok' },
|
|
575
637
|
commitMessage: `unlink: ${op.slug} ✕ ${op.target} (${op.relation})`,
|
|
576
638
|
indexRemovals: [],
|
|
577
|
-
indexUpserts: [{ slug: op.slug, kind
|
|
639
|
+
indexUpserts: [{ slug: op.slug, kind }, ...(touch ? [touch.upsert] : [])],
|
|
578
640
|
};
|
|
579
641
|
}
|
|
580
642
|
function applyPromote(op, ctx) {
|
|
@@ -681,14 +743,17 @@ function applyDelete(op, ctx) {
|
|
|
681
743
|
vanished(op.slug);
|
|
682
744
|
// The snapshot is the rollback: a commit-stage failure writes it back.
|
|
683
745
|
ctx.captureOriginal(filePath);
|
|
746
|
+
// The other end of every same-kind edge the note owns goes with it (#243).
|
|
747
|
+
const own = kind === 'knowledge' ? parseFilePath(filePath).frontmatter.related ?? [] : [];
|
|
684
748
|
unlinkSync(filePath);
|
|
749
|
+
const touches = own.flatMap(r => dropBackEdge(ctx, op.slug, 'knowledge', r) ?? []);
|
|
685
750
|
return {
|
|
686
|
-
filesWritten:
|
|
751
|
+
filesWritten: touches.map(t => t.file),
|
|
687
752
|
filesDeleted: [vaultRel(vaultPath, filePath)],
|
|
688
753
|
result: { op: 'delete', slug: op.slug, status: 'ok' },
|
|
689
754
|
commitMessage: `delete: ${op.slug} (${kind})`,
|
|
690
755
|
indexRemovals: [op.slug],
|
|
691
|
-
indexUpserts:
|
|
756
|
+
indexUpserts: touches.map(t => t.upsert),
|
|
692
757
|
deletedKind: kind,
|
|
693
758
|
};
|
|
694
759
|
}
|
package/dist/batch-ops.d.ts
CHANGED
|
@@ -34,12 +34,12 @@ export declare const UpdateFieldsSchema: z.ZodObject<{
|
|
|
34
34
|
parent: z.ZodOptional<z.ZodString>;
|
|
35
35
|
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
36
36
|
resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
37
|
-
domain: z.ZodOptional<z.
|
|
37
|
+
domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
38
38
|
confidence: z.ZodOptional<z.ZodEnum<{
|
|
39
39
|
[x: string]: string;
|
|
40
40
|
}>>;
|
|
41
|
-
tags: z.ZodOptional<z.
|
|
42
|
-
related: z.ZodOptional<z.
|
|
41
|
+
tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
42
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
43
43
|
synthesis: z.ZodOptional<z.ZodString>;
|
|
44
44
|
insight: z.ZodOptional<z.ZodString>;
|
|
45
45
|
add_evidence: z.ZodOptional<z.ZodString>;
|
|
@@ -52,10 +52,10 @@ export declare const CreateEntityFieldsSchema: z.ZodObject<{
|
|
|
52
52
|
purpose: z.ZodOptional<z.ZodString>;
|
|
53
53
|
description: z.ZodOptional<z.ZodString>;
|
|
54
54
|
resource: z.ZodOptional<z.ZodString>;
|
|
55
|
-
domain: z.ZodOptional<z.
|
|
55
|
+
domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
56
56
|
parent: z.ZodOptional<z.ZodString>;
|
|
57
|
-
related: z.ZodOptional<z.
|
|
58
|
-
tags: z.ZodOptional<z.
|
|
57
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
58
|
+
tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
59
59
|
brief: z.ZodOptional<z.ZodString>;
|
|
60
60
|
add_action: z.ZodOptional<z.ZodString>;
|
|
61
61
|
add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -73,16 +73,18 @@ export declare const CreateKnowledgeFieldsSchema: z.ZodObject<{
|
|
|
73
73
|
synthesis: z.ZodOptional<z.ZodString>;
|
|
74
74
|
insight: z.ZodOptional<z.ZodString>;
|
|
75
75
|
description: z.ZodOptional<z.ZodString>;
|
|
76
|
-
domain: z.ZodOptional<z.
|
|
77
|
-
related: z.ZodOptional<z.
|
|
78
|
-
tags: z.ZodOptional<z.
|
|
76
|
+
domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
77
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
78
|
+
tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
79
79
|
'session-date': z.ZodOptional<z.ZodString>;
|
|
80
80
|
}, z.core.$strict>;
|
|
81
81
|
export declare const CreateSessionFieldsSchema: z.ZodObject<{
|
|
82
82
|
synthesis: z.ZodOptional<z.ZodString>;
|
|
83
83
|
insight: z.ZodOptional<z.ZodString>;
|
|
84
|
-
related: z.ZodOptional<z.
|
|
84
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
85
85
|
'session-date': z.ZodOptional<z.ZodString>;
|
|
86
|
+
decisions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
87
|
+
entities_touched: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
86
88
|
}, z.core.$strict>;
|
|
87
89
|
/** What `digest` may put in a session-create op — typechecked against the
|
|
88
90
|
* schema so the two can never silently diverge again. */
|
|
@@ -119,12 +121,12 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
|
|
119
121
|
parent: z.ZodOptional<z.ZodString>;
|
|
120
122
|
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
121
123
|
resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
122
|
-
domain: z.ZodOptional<z.
|
|
124
|
+
domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
123
125
|
confidence: z.ZodOptional<z.ZodEnum<{
|
|
124
126
|
[x: string]: string;
|
|
125
127
|
}>>;
|
|
126
|
-
tags: z.ZodOptional<z.
|
|
127
|
-
related: z.ZodOptional<z.
|
|
128
|
+
tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
129
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
128
130
|
synthesis: z.ZodOptional<z.ZodString>;
|
|
129
131
|
insight: z.ZodOptional<z.ZodString>;
|
|
130
132
|
add_evidence: z.ZodOptional<z.ZodString>;
|
|
@@ -290,12 +292,12 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
290
292
|
parent: z.ZodOptional<z.ZodString>;
|
|
291
293
|
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
292
294
|
resource: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
293
|
-
domain: z.ZodOptional<z.
|
|
295
|
+
domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
294
296
|
confidence: z.ZodOptional<z.ZodEnum<{
|
|
295
297
|
[x: string]: string;
|
|
296
298
|
}>>;
|
|
297
|
-
tags: z.ZodOptional<z.
|
|
298
|
-
related: z.ZodOptional<z.
|
|
299
|
+
tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
300
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
299
301
|
synthesis: z.ZodOptional<z.ZodString>;
|
|
300
302
|
insight: z.ZodOptional<z.ZodString>;
|
|
301
303
|
add_evidence: z.ZodOptional<z.ZodString>;
|
|
@@ -326,10 +328,10 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
326
328
|
purpose: z.ZodOptional<z.ZodString>;
|
|
327
329
|
description: z.ZodOptional<z.ZodString>;
|
|
328
330
|
resource: z.ZodOptional<z.ZodString>;
|
|
329
|
-
domain: z.ZodOptional<z.
|
|
331
|
+
domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
330
332
|
parent: z.ZodOptional<z.ZodString>;
|
|
331
|
-
related: z.ZodOptional<z.
|
|
332
|
-
tags: z.ZodOptional<z.
|
|
333
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
334
|
+
tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
333
335
|
brief: z.ZodOptional<z.ZodString>;
|
|
334
336
|
add_action: z.ZodOptional<z.ZodString>;
|
|
335
337
|
add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -367,9 +369,9 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
367
369
|
synthesis: z.ZodOptional<z.ZodString>;
|
|
368
370
|
insight: z.ZodOptional<z.ZodString>;
|
|
369
371
|
description: z.ZodOptional<z.ZodString>;
|
|
370
|
-
domain: z.ZodOptional<z.
|
|
371
|
-
related: z.ZodOptional<z.
|
|
372
|
-
tags: z.ZodOptional<z.
|
|
372
|
+
domain: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
373
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
374
|
+
tags: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
373
375
|
'session-date': z.ZodOptional<z.ZodString>;
|
|
374
376
|
}, z.core.$strict>>;
|
|
375
377
|
}, z.core.$strict>;
|
|
@@ -395,11 +397,13 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
395
397
|
fields: z.ZodOptional<z.ZodObject<{
|
|
396
398
|
synthesis: z.ZodOptional<z.ZodString>;
|
|
397
399
|
insight: z.ZodOptional<z.ZodString>;
|
|
398
|
-
related: z.ZodOptional<z.
|
|
400
|
+
related: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
399
401
|
'session-date': z.ZodOptional<z.ZodString>;
|
|
402
|
+
decisions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
403
|
+
entities_touched: z.ZodOptional<z.ZodPreprocess<z.ZodArray<z.ZodString>, unknown>>;
|
|
400
404
|
}, z.core.$strict>>;
|
|
401
405
|
}, z.core.$strict>;
|
|
402
|
-
readonly description: "Create a new session note linked to an entity. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).";
|
|
406
|
+
readonly description: "Create a new session note linked to an entity. `synthesis` writes Summary, `decisions` one bullet each under Decisions, `entities_touched` one `[[slug]]` bullet each under Entities Touched. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).";
|
|
403
407
|
readonly exampleInclude: readonly ["synthesis"];
|
|
404
408
|
readonly check: (op: Extract<BatchOperation, {
|
|
405
409
|
op: "create";
|
|
@@ -445,7 +449,7 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
445
449
|
related: "related";
|
|
446
450
|
}>;
|
|
447
451
|
}, z.core.$strict>;
|
|
448
|
-
readonly description: "Add a parent or related link
|
|
452
|
+
readonly description: "Add a parent or related link. A same-kind related link (entity↔entity, knowledge↔knowledge) is written on both files; a related naming the entity's parent or a child is rejected (parent subsumes related).";
|
|
449
453
|
readonly exampleInclude: readonly [];
|
|
450
454
|
readonly check: (op: Extract<BatchOperation, {
|
|
451
455
|
op: "link";
|
|
@@ -466,7 +470,7 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
466
470
|
related: "related";
|
|
467
471
|
}>;
|
|
468
472
|
}, z.core.$strict>;
|
|
469
|
-
readonly description: "Remove a parent or related link
|
|
473
|
+
readonly description: "Remove a parent or related link. A same-kind related link is dropped from both files, a legacy one-way edge from whichever file carries it.";
|
|
470
474
|
readonly exampleInclude: readonly [];
|
|
471
475
|
readonly check: (op: Extract<BatchOperation, {
|
|
472
476
|
op: "unlink";
|
|
@@ -503,7 +507,7 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
503
507
|
op: z.ZodLiteral<"delete">;
|
|
504
508
|
slug: z.ZodString;
|
|
505
509
|
}, z.core.$strict>;
|
|
506
|
-
readonly description: "Delete a knowledge or session note. An entity is never deleted — retire it with `hafez update <slug> --status done`. Rejected while
|
|
510
|
+
readonly description: "Delete a knowledge or session note. An entity is never deleted — retire it with `hafez update <slug> --status done`. The back-edges the note owns (a knowledge note in its own related that names it back) are dropped with it, in the same commit. Rejected while any other entity or knowledge note links to the note (parent/related): unlink or re-point those first — an earlier op in the same batch counts.";
|
|
507
511
|
readonly exampleInclude: readonly [];
|
|
508
512
|
readonly addressesSessions: true;
|
|
509
513
|
readonly check: (op: Extract<BatchOperation, {
|
package/dist/batch-ops.js
CHANGED
|
@@ -101,6 +101,9 @@ export const CreateSessionFieldsSchema = z.object({
|
|
|
101
101
|
insight: nonEmptyText.optional(),
|
|
102
102
|
related: stringList.optional(),
|
|
103
103
|
'session-date': z.string().optional(),
|
|
104
|
+
// Decisions are prose: a list only, never a comma string split apart.
|
|
105
|
+
decisions: z.array(nonEmptyText).optional(),
|
|
106
|
+
entities_touched: stringList.optional(),
|
|
104
107
|
}).strict();
|
|
105
108
|
const CreateEntityBranch = z.object({
|
|
106
109
|
op: z.literal('create'),
|
|
@@ -311,6 +314,48 @@ function parentCycleError(slug, parent, ctx) {
|
|
|
311
314
|
const cycle = parentCycle(slug, parent, s => ctx.linksOf(s).parent);
|
|
312
315
|
return cycle ? `Parent '${parent}' would make a cycle: ${cycle.join(' → ')}` : null;
|
|
313
316
|
}
|
|
317
|
+
/** An entity and its own parent or child are never `related` (ADR 0002):
|
|
318
|
+
* `parent` subsumes it. The errors for the links `slug` would carry after
|
|
319
|
+
* the op — the parent named in its related, a related whose parent is
|
|
320
|
+
* `slug`, or a parent whose related names `slug` (a legacy one-way edge) —
|
|
321
|
+
* against the batch so far. */
|
|
322
|
+
function parentSubsumesErrors(slug, links, ctx) {
|
|
323
|
+
const errors = [];
|
|
324
|
+
for (const r of links.related ?? []) {
|
|
325
|
+
if (r === links.parent)
|
|
326
|
+
errors.push(`'${r}' is the parent of '${slug}' — parent subsumes related`);
|
|
327
|
+
else if (ctx.existsAnywhere(r) && ctx.linksOf(r).parent === slug)
|
|
328
|
+
errors.push(`'${r}' is a child of '${slug}' — parent subsumes related`);
|
|
329
|
+
}
|
|
330
|
+
const p = links.parent;
|
|
331
|
+
if (p && ctx.existsAnywhere(p) && ctx.linksOf(p).related?.includes(slug))
|
|
332
|
+
errors.push(`'${p}' names '${slug}' in its related — parent subsumes related`);
|
|
333
|
+
return errors;
|
|
334
|
+
}
|
|
335
|
+
/** Record what the edge writer and dropper will do to the targets: a
|
|
336
|
+
* same-kind `related` edge has two ends, so after `slug`'s related changes
|
|
337
|
+
* from `before` to `after`, every added same-kind target carries `slug` and
|
|
338
|
+
* every dropped one loses it. Cross-kind and unknown targets are left as
|
|
339
|
+
* they are, like the files. */
|
|
340
|
+
function simulateBackEdges(slug, before, after, ctx) {
|
|
341
|
+
const kind = ctx.resolve(slug)?.kind;
|
|
342
|
+
const was = before ?? [];
|
|
343
|
+
const now = after ?? [];
|
|
344
|
+
for (const r of [...now.filter(r => !was.includes(r)), ...was.filter(r => !now.includes(r))]) {
|
|
345
|
+
if (r === slug || !kind || !ctx.existsAnywhere(r) || ctx.resolve(r)?.kind !== kind)
|
|
346
|
+
continue;
|
|
347
|
+
const links = ctx.linksOf(r);
|
|
348
|
+
const rest = (links.related ?? []).filter(x => x !== slug);
|
|
349
|
+
const related = now.includes(r) ? [...rest, slug] : rest;
|
|
350
|
+
ctx.simulateLinks(r, { ...links, related: related.length > 0 ? related : undefined });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** The link fields after an op, simulated for `slug` and mirrored on the
|
|
354
|
+
* targets its related gained or lost. */
|
|
355
|
+
function simulateLinkChange(slug, before, after, ctx) {
|
|
356
|
+
ctx.simulateLinks(slug, after);
|
|
357
|
+
simulateBackEdges(slug, before.related, after.related, ctx);
|
|
358
|
+
}
|
|
314
359
|
// `as const satisfies` rather than a `: OpSpec[]` annotation: it keeps each
|
|
315
360
|
// row's `name` a literal, which is what OpSpecName (and through it the Apply
|
|
316
361
|
// table's key type) is derived from. OP_SPECS below re-exports the rows under
|
|
@@ -383,8 +428,12 @@ const OP_SPEC_ROWS = [
|
|
|
383
428
|
}
|
|
384
429
|
if (target) {
|
|
385
430
|
const links = linkFieldsOf(op.fields);
|
|
386
|
-
if (links)
|
|
387
|
-
|
|
431
|
+
if (links) {
|
|
432
|
+
const before = ctx.linksOf(op.slug);
|
|
433
|
+
const after = { ...before, ...links };
|
|
434
|
+
errors.push(...parentSubsumesErrors(op.slug, after, ctx));
|
|
435
|
+
simulateLinkChange(op.slug, before, after, ctx);
|
|
436
|
+
}
|
|
388
437
|
}
|
|
389
438
|
return { slug: op.slug, errors, primaryCode };
|
|
390
439
|
},
|
|
@@ -400,8 +449,11 @@ const OP_SPEC_ROWS = [
|
|
|
400
449
|
const errors = [];
|
|
401
450
|
const slug = slugify(op.name);
|
|
402
451
|
const primaryCode = claimNew(ctx, errors, slug, { kind: 'entity', type: op.fields.type }, () => ctx.buildEntityFrontmatter(op.name, op.fields));
|
|
403
|
-
if (!primaryCode)
|
|
404
|
-
|
|
452
|
+
if (!primaryCode) {
|
|
453
|
+
const links = { parent: op.fields.parent, related: op.fields.related };
|
|
454
|
+
errors.push(...parentSubsumesErrors(slug, links, ctx));
|
|
455
|
+
simulateLinkChange(slug, {}, links, ctx);
|
|
456
|
+
}
|
|
405
457
|
return { slug, errors, created: errors.length === 0, primaryCode };
|
|
406
458
|
},
|
|
407
459
|
},
|
|
@@ -417,7 +469,7 @@ const OP_SPEC_ROWS = [
|
|
|
417
469
|
const slug = slugify(op.name);
|
|
418
470
|
const primaryCode = claimNew(ctx, errors, slug, { kind: 'knowledge', type: 'knowledge', subtype: op.fields?.subtype }, () => ctx.buildKnowledgeFrontmatter(op.name, (op.fields || {})));
|
|
419
471
|
if (!primaryCode)
|
|
420
|
-
|
|
472
|
+
simulateLinkChange(slug, {}, { related: op.fields?.related }, ctx);
|
|
421
473
|
return { slug, errors, created: errors.length === 0, primaryCode };
|
|
422
474
|
},
|
|
423
475
|
},
|
|
@@ -426,7 +478,7 @@ const OP_SPEC_ROWS = [
|
|
|
426
478
|
op: 'create',
|
|
427
479
|
kind: 'session',
|
|
428
480
|
schema: CreateSessionBranch,
|
|
429
|
-
description: 'Create a new session note linked to an entity. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).',
|
|
481
|
+
description: 'Create a new session note linked to an entity. `synthesis` writes Summary, `decisions` one bullet each under Decisions, `entities_touched` one `[[slug]]` bullet each under Entities Touched. `insight` is an alias for `synthesis` (ignored when `synthesis` is also given).',
|
|
430
482
|
exampleInclude: ['synthesis'],
|
|
431
483
|
check(op, ctx) {
|
|
432
484
|
const slug = slugify(op.name);
|
|
@@ -454,7 +506,7 @@ const OP_SPEC_ROWS = [
|
|
|
454
506
|
name: 'link',
|
|
455
507
|
op: 'link',
|
|
456
508
|
schema: LinkBranch,
|
|
457
|
-
description: 'Add a parent or related link
|
|
509
|
+
description: 'Add a parent or related link. A same-kind related link (entity↔entity, knowledge↔knowledge) is written on both files; a related naming the entity\'s parent or a child is rejected (parent subsumes related).',
|
|
458
510
|
exampleInclude: [],
|
|
459
511
|
check(op, ctx) {
|
|
460
512
|
const errors = [];
|
|
@@ -472,9 +524,11 @@ const OP_SPEC_ROWS = [
|
|
|
472
524
|
}
|
|
473
525
|
if (!primaryCode) {
|
|
474
526
|
const links = ctx.linksOf(op.slug);
|
|
475
|
-
|
|
527
|
+
const after = op.relation === 'parent'
|
|
476
528
|
? { ...links, parent: op.target }
|
|
477
|
-
: { ...links, related: [...(links.related ?? []).filter(r => r !== op.target), op.target] }
|
|
529
|
+
: { ...links, related: [...(links.related ?? []).filter(r => r !== op.target), op.target] };
|
|
530
|
+
errors.push(...parentSubsumesErrors(op.slug, after, ctx));
|
|
531
|
+
simulateLinkChange(op.slug, links, after, ctx);
|
|
478
532
|
}
|
|
479
533
|
return { slug: op.slug, errors, primaryCode };
|
|
480
534
|
},
|
|
@@ -483,7 +537,7 @@ const OP_SPEC_ROWS = [
|
|
|
483
537
|
name: 'unlink',
|
|
484
538
|
op: 'unlink',
|
|
485
539
|
schema: UnlinkBranch,
|
|
486
|
-
description: 'Remove a parent or related link
|
|
540
|
+
description: 'Remove a parent or related link. A same-kind related link is dropped from both files, a legacy one-way edge from whichever file carries it.',
|
|
487
541
|
exampleInclude: [],
|
|
488
542
|
check(op, ctx) {
|
|
489
543
|
const errors = [];
|
|
@@ -494,9 +548,13 @@ const OP_SPEC_ROWS = [
|
|
|
494
548
|
}
|
|
495
549
|
else {
|
|
496
550
|
const links = ctx.linksOf(op.slug);
|
|
497
|
-
|
|
551
|
+
simulateLinkChange(op.slug, links, op.relation === 'parent'
|
|
498
552
|
? { ...links, parent: links.parent === op.target ? undefined : links.parent }
|
|
499
|
-
: { ...links, related: links.related?.filter(r => r !== op.target) });
|
|
553
|
+
: { ...links, related: links.related?.filter(r => r !== op.target) }, ctx);
|
|
554
|
+
// A legacy one-way edge may live on the target alone: the dropper
|
|
555
|
+
// takes it there too.
|
|
556
|
+
if (op.relation === 'related' && !links.related?.includes(op.target))
|
|
557
|
+
simulateBackEdges(op.slug, [op.target], [], ctx);
|
|
500
558
|
}
|
|
501
559
|
return { slug: op.slug, errors, primaryCode };
|
|
502
560
|
},
|
|
@@ -540,7 +598,7 @@ const OP_SPEC_ROWS = [
|
|
|
540
598
|
name: 'delete',
|
|
541
599
|
op: 'delete',
|
|
542
600
|
schema: DeleteBranch,
|
|
543
|
-
description: 'Delete a knowledge or session note. An entity is never deleted — retire it with `hafez update <slug> --status done`. Rejected while
|
|
601
|
+
description: 'Delete a knowledge or session note. An entity is never deleted — retire it with `hafez update <slug> --status done`. The back-edges the note owns (a knowledge note in its own related that names it back) are dropped with it, in the same commit. Rejected while any other entity or knowledge note links to the note (parent/related): unlink or re-point those first — an earlier op in the same batch counts.',
|
|
544
602
|
exampleInclude: [],
|
|
545
603
|
addressesSessions: true,
|
|
546
604
|
check(op, ctx) {
|
|
@@ -560,12 +618,19 @@ const OP_SPEC_ROWS = [
|
|
|
560
618
|
}
|
|
561
619
|
else {
|
|
562
620
|
// Only knowledge can be linked to: a parent/related slug must name an
|
|
563
|
-
// entity or knowledge note, never a session.
|
|
564
|
-
|
|
621
|
+
// entity or knowledge note, never a session. A same-kind back-edge
|
|
622
|
+
// the note owns — a note in its own related that names it back — is
|
|
623
|
+
// the other end of its edge, dropped with it (#243), not a blocker.
|
|
624
|
+
const own = target ? ctx.linksOf(op.slug).related ?? [] : [];
|
|
625
|
+
const referrers = target
|
|
626
|
+
? ctx.referrers(op.slug).filter(r => !(own.includes(r) && ctx.resolve(r)?.kind === target.kind))
|
|
627
|
+
: [];
|
|
565
628
|
if (referrers.length > 0) {
|
|
566
629
|
errors.push(`'${op.slug}' is still linked from ${referrers.join(', ')} (parent/related) — unlink or re-point those first; an earlier op in the same batch counts`);
|
|
567
630
|
}
|
|
568
631
|
else {
|
|
632
|
+
if (target)
|
|
633
|
+
simulateBackEdges(op.slug, own, [], ctx);
|
|
569
634
|
ctx.simulateDelete(op.slug, target ? 'knowledge' : 'session');
|
|
570
635
|
}
|
|
571
636
|
}
|