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/dist/digest.js CHANGED
@@ -1,9 +1,13 @@
1
1
  // src/digest.ts
2
+ import { createHash } from 'crypto';
2
3
  import { z } from 'zod';
4
+ import { HafezError } from './types.js';
5
+ import { detectAgent } from './harness.js';
6
+ import { ITEMS } from './surfaces/items.js';
3
7
  // --- Schema ---
4
8
  export const DigestInputSchema = z.object({
5
9
  entities_touched: z.array(z.string())
6
- .describe('Slugs of entities worked on. Each gets a session log entry; the session note links them all. Unknown slugs warn on stderr and are omitted.'),
10
+ .describe('Slugs of entities worked on. Each gets a session log entry; the session note links them all. An unknown slug fails the digest and is named.'),
7
11
  decisions: z.array(z.string())
8
12
  .describe('Key decisions made this session (may be empty). Recorded in the session note.')
9
13
  .default([]),
@@ -12,8 +16,8 @@ export const DigestInputSchema = z.object({
12
16
  session_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD')
13
17
  .describe('Session date, YYYY-MM-DD.'),
14
18
  agent: z.string()
15
- .describe('Agent identifier recorded in session logs.')
16
- .default('claude'),
19
+ .describe('Agent identifier recorded in session logs. Detected from the Harness when absent.')
20
+ .optional(),
17
21
  });
18
22
  /**
19
23
  * Validate and parse raw digest input from stdin.
@@ -39,27 +43,32 @@ export function parseDigestInput(raw) {
39
43
  /**
40
44
  * Convert session context into a hafez batch payload.
41
45
  * Pure function — no file I/O, no vault access.
42
- * Unknown slugs in entities_touched are silently omitted (caller should warn).
46
+ * An unknown slug in entities_touched is a NOT_FOUND naming it (and only the
47
+ * unknown ones), never a session log that points nowhere. A knowledge slug is
48
+ * known: the note links it, but a session log is entity-only.
43
49
  */
44
- export function digest(input, existingSlugs, now = new Date()) {
50
+ export function digest(input, entitySlugs, knowledgeSlugs = new Set()) {
51
+ const touched = [...new Set(input.entities_touched)];
52
+ const unknown = touched.filter(s => !entitySlugs.has(s) && !knowledgeSlugs.has(s));
53
+ if (unknown.length > 0)
54
+ throw new HafezError('NOT_FOUND', ITEMS.unknownSlugAtSave.render(unknown));
45
55
  const ops = [];
46
- // 1. Session log updates for each known slug
47
- const knownTouched = input.entities_touched.filter(s => existingSlugs.has(s));
48
- for (const slug of knownTouched) {
56
+ const agent = input.agent || detectAgent();
57
+ // 1. Session log updates for each touched entity
58
+ for (const slug of touched.filter(s => entitySlugs.has(s))) {
49
59
  const sessionLogSummary = buildSessionLogSummary(input);
50
60
  const fields = {
51
61
  session_log: {
52
62
  type: 'progress',
53
63
  summary: sessionLogSummary,
54
- agent: input.agent,
64
+ agent,
55
65
  },
56
66
  };
57
67
  ops.push({ op: 'update', slug, fields });
58
68
  }
59
- // 2. Session note — built from known slugs only, so unknown slugs never
60
- // leak into the note body (the schema promises they are omitted).
61
- const noteBody = buildSessionNoteBody(input, knownTouched);
62
- const related = knownTouched;
69
+ // 2. Session note
70
+ const noteBody = buildSessionNoteBody(input, touched);
71
+ const related = touched;
63
72
  // Typechecked against the Op Spec's session-create schema so digest output
64
73
  // can never silently diverge from what batch validation accepts.
65
74
  const noteFields = {
@@ -67,7 +76,9 @@ export function digest(input, existingSlugs, now = new Date()) {
67
76
  'session-date': input.session_date,
68
77
  ...(related.length > 0 ? { related } : {}),
69
78
  };
70
- const noteName = `Session ${input.session_date} ${now.toISOString().slice(11, 19)}`;
79
+ // Named by the session date and its note, so a second save of the same
80
+ // session derives the same slug and batch skips it as a duplicate.
81
+ const noteName = `Session ${input.session_date} ${createHash('sha256').update(noteBody).digest('hex').slice(0, 8)}`;
71
82
  ops.push({ op: 'create', kind: 'session', name: noteName, fields: noteFields });
72
83
  return ops;
73
84
  }
@@ -79,7 +90,7 @@ function buildSessionLogSummary(input) {
79
90
  }
80
91
  return parts.join(' | ');
81
92
  }
82
- function buildSessionNoteBody(input, knownTouched) {
93
+ function buildSessionNoteBody(input, touched) {
83
94
  const lines = [input.narrative.trim()];
84
95
  if (input.decisions.length > 0) {
85
96
  lines.push('');
@@ -88,9 +99,9 @@ function buildSessionNoteBody(input, knownTouched) {
88
99
  lines.push(`- ${d}`);
89
100
  }
90
101
  }
91
- if (knownTouched.length > 0) {
102
+ if (touched.length > 0) {
92
103
  lines.push('');
93
- lines.push(`**Entities touched:** ${knownTouched.join(', ')}`);
104
+ lines.push(`**Entities touched:** ${touched.join(', ')}`);
94
105
  }
95
106
  return lines.join('\n');
96
107
  }
@@ -1,4 +1,29 @@
1
- import type { SessionLogEntry, SessionLogType } from './types.js';
1
+ import { type SessionLogEntry, type SessionLogType } from './types.js';
2
+ /** The action text without its trailing added-date stamp, if any. */
3
+ export declare function stripActionStamp(text: string): string;
4
+ /** The day an action's stamp names; null when the line is undated. */
5
+ export declare function actionAdded(text: string): string | null;
6
+ /** Stamp an action with its added date; text that already ends in a stamp is
7
+ * returned as is, so a carried-over line is never double-stamped. */
8
+ export declare function stampAction(text: string, day: string): string;
9
+ /** One checkbox line of Next Actions as a reader sees it: unstamped text, and
10
+ * a Handle and added day (null when undated) when the line is open. */
11
+ export type ActionItem = {
12
+ handle: string;
13
+ text: string;
14
+ added: string | null;
15
+ } | {
16
+ handle?: undefined;
17
+ text: string;
18
+ };
19
+ /** The open action a query named: its line's position within the Next
20
+ * Actions section, and its unstamped text. Editing by position rather than
21
+ * re-matching the text keeps a Handle-named line unambiguous when its text
22
+ * is a substring of another line's, or a duplicate of one. */
23
+ export interface ActionMatch {
24
+ line: number;
25
+ text: string;
26
+ }
2
27
  export declare function parseSessionLogHeading(line: string): {
3
28
  date: string;
4
29
  agent: string;
@@ -84,16 +109,27 @@ export interface Doc {
84
109
  setBrief(body: string, content: string): string;
85
110
  removeBrief(body: string): string;
86
111
  getNextActions(body: string): string[];
112
+ getActionItems(body: string): ActionItem[];
113
+ renderActionHandles(body: string, mark?: (added: string | null) => string): string;
87
114
  addNextAction(body: string, action: string): string;
88
- completeNextAction(body: string, query: string): {
115
+ matchNextAction(body: string, query: string): ActionMatch;
116
+ completeNextAction(body: string, action: string | ActionMatch): {
89
117
  matched: string;
90
118
  body: string;
91
119
  };
92
- removeNextAction(body: string, query: string): {
120
+ removeNextAction(body: string, action: string | ActionMatch): {
93
121
  matched: string;
122
+ line: string;
123
+ body: string;
124
+ };
125
+ actionLines(body: string): string[];
126
+ extractOldestTickedActions(body: string, keep: number): {
127
+ lines: string[];
94
128
  body: string;
95
129
  };
130
+ pruneNextActions(body: string): string;
96
131
  clearNextActions(body: string): string;
132
+ stripNextActionStamps(body: string): string;
97
133
  syncRelatedSection(body: string, frontmatter: {
98
134
  parent?: string;
99
135
  related?: string[];
@@ -101,6 +137,8 @@ export interface Doc {
101
137
  }
102
138
  export declare function getBrief(body: string): string | null;
103
139
  export declare function getNextActions(body: string): string[];
140
+ export declare function getActionItems(body: string): ActionItem[];
141
+ export declare function renderActionHandles(body: string, mark?: (added: string | null) => string): string;
104
142
  export declare function parseSessionLog(body: string): Array<{
105
143
  date: string;
106
144
  agent: string;
package/dist/document.js CHANGED
@@ -9,9 +9,65 @@
9
9
  // and returns the Document operations bound to it. There is deliberately no
10
10
  // global fallback set — a session-only heading like '## Decisions' quoted
11
11
  // inside an entity's Brief must NOT terminate that section.
12
+ import { createHash } from 'crypto';
13
+ import { HafezError } from './types.js';
12
14
  import { ALL_CONTRACTS, SUBTYPE_SECTIONS, SESSION_SECTIONS, getContract } from './contracts.js';
13
15
  import { localDay } from './dates.js';
14
- const UNCHECKED_RE = /^- \[ \] (.+)$/gm;
16
+ // One checkbox line of Next Actions, tested line by line. `m` so a CRLF
17
+ // line's trailing \r is not taken as action text.
18
+ const OPEN_ACTION_RE = /^- \[ \] (.+)$/m;
19
+ const ACTION_RE = /^- \[([ xX])\] (.+)$/m;
20
+ // --- Next-action added-date stamp ---
21
+ // Adding an action stamps its line `(added YYYY-MM-DD)` with the local day
22
+ // (Apply does the stamping — every add path goes through it). The stamp is
23
+ // file format only: every reader of action text — the index, query, matching,
24
+ // export, merge identity — works on the unstamped text.
25
+ const ACTION_STAMP_RE = /\s*\(added (\d{4}-\d{2}-\d{2})\)\s*$/;
26
+ /** The action text without its trailing added-date stamp, if any. */
27
+ export function stripActionStamp(text) {
28
+ return text.replace(ACTION_STAMP_RE, '');
29
+ }
30
+ /** The day an action's stamp names; null when the line is undated. */
31
+ export function actionAdded(text) {
32
+ return ACTION_STAMP_RE.exec(text)?.[1] ?? null;
33
+ }
34
+ /** Stamp an action with its added date; text that already ends in a stamp is
35
+ * returned as is, so a carried-over line is never double-stamped. */
36
+ export function stampAction(text, day) {
37
+ return ACTION_STAMP_RE.test(text) ? text : `${text.trimEnd()} (added ${day})`;
38
+ }
39
+ // --- Next-action Handles ---
40
+ // `read` renders a Handle in front of each open action so an agent can tick
41
+ // or drop a line without quoting its text. Derived at read time, never stored.
42
+ const HANDLE_MIN_LENGTH = 4;
43
+ /** A query that may name a line by Handle: lowercase hex, at least the minimum. */
44
+ const HANDLE_QUERY_RE = new RegExp(`^[0-9a-f]{${HANDLE_MIN_LENGTH},}$`);
45
+ /** The full hashes Handles are cut from, one per open action line, in order:
46
+ * bare hex of the SHA-256 of the normalised, unstamped text. A repeated text
47
+ * hashes with its occurrence number, so identical lines get distinct hashes. */
48
+ function actionHashes(lines) {
49
+ const seen = new Map();
50
+ return lines.map(line => {
51
+ const text = normalizeForMatch(stripActionStamp(line));
52
+ const n = seen.get(text) ?? 0;
53
+ seen.set(text, n + 1);
54
+ return createHash('sha256').update(n === 0 ? text : `${text}\0${n}`).digest('hex');
55
+ });
56
+ }
57
+ /** The Handles of a list of open action lines, in order: each line's hash cut
58
+ * to the shortest prefix (at least four) that no other line's hash shares. */
59
+ function actionHandles(lines) {
60
+ const hashes = actionHashes(lines);
61
+ return hashes.map((hash, i) => {
62
+ let length = HANDLE_MIN_LENGTH;
63
+ hashes.forEach((other, j) => {
64
+ // Capped at the full hash: two identical hashes share every prefix.
65
+ while (j !== i && length < hash.length && other.startsWith(hash.slice(0, length)))
66
+ length++;
67
+ });
68
+ return hash.slice(0, length);
69
+ });
70
+ }
15
71
  // --- Set-independent helpers ---
16
72
  const HEADING_RE = /^### (\d{4}-\d{2}-\d{2}) (?:—|--) (.+?)(?:\s+\[(\w+)\])?$/;
17
73
  export function parseSessionLogHeading(line) {
@@ -351,31 +407,71 @@ function makeDoc(structuralSections) {
351
407
  // After-target not found — fall back to insertBefore logic
352
408
  return insertSectionBefore(body, heading, content, beforeFallbacks);
353
409
  }
410
+ /** The Next Actions section's raw lines (from the heading's line end to the
411
+ * section end) — the line positions an ActionMatch counts in. */
412
+ function actionSectionLines(body) {
413
+ const section = findSection(body, 'Next Actions');
414
+ return section ? body.slice(section.headingEnd, section.end).split('\n') : [];
415
+ }
416
+ /** A section line as it leaves the file (archive): without a CRLF line's \r. */
417
+ function withoutCR(line) {
418
+ return line.replace(/\r$/, '');
419
+ }
420
+ /** Where the action at `lines[i]` ends: past every line it carries (sub-
421
+ * bullets, notes — anything up to the next checkbox line), trailing blank
422
+ * lines left out. The item rule merge.ts parses by; whatever moves the
423
+ * action moves these with it. */
424
+ function actionEnd(lines, i) {
425
+ let end = i + 1;
426
+ for (let j = i + 1; j < lines.length && !ACTION_RE.test(lines[j]); j++) {
427
+ if (lines[j].trim())
428
+ end = j + 1;
429
+ }
430
+ return end;
431
+ }
432
+ /** The action at `lines[i]` and the lines it carries, as they leave the file. */
433
+ function actionBlock(lines, i) {
434
+ return lines.slice(i, actionEnd(lines, i)).map(withoutCR).join('\n');
435
+ }
436
+ /** Every open line of Next Actions: its position and raw text, stamp included. */
437
+ function openActions(body) {
438
+ return actionSectionLines(body).flatMap((l, line) => {
439
+ const m = OPEN_ACTION_RE.exec(l);
440
+ return m ? [{ line, raw: m[1] }] : [];
441
+ });
442
+ }
354
443
  /**
355
- * Match an action by case-insensitive substring against unchecked items.
356
- * Throws on 0 or >1 matches.
444
+ * Find the open action a query names. Four or more lowercase hex that
445
+ * prefix exactly one open line's hash name that line, so a Handle from an
446
+ * earlier read still works after a sibling changed its rendered length;
447
+ * prefixing two or more is ambiguous. Anything else is text: a
448
+ * case-insensitive substring of the unstamped text, the query's own stamp
449
+ * (copied from a read) ignored. Throws on 0 or >1 matches; error text
450
+ * lists the unstamped actions.
357
451
  */
358
452
  function matchAction(body, query) {
359
- const section = findSection(body, 'Next Actions');
360
- if (!section)
361
- throw new Error(`No matching action for "${query}"`);
362
- const normalizedQuery = normalizeForMatch(query).toLowerCase();
363
- const matches = [];
364
- const allActions = [];
365
- let m;
366
- const re = new RegExp(UNCHECKED_RE.source, 'gm');
367
- while ((m = re.exec(section.content)) !== null) {
368
- allActions.push(m[1]);
369
- if (normalizeForMatch(m[1]).toLowerCase().includes(normalizedQuery)) {
370
- matches.push(m[1]);
371
- }
453
+ if (!findSection(body, 'Next Actions'))
454
+ throw new HafezError('VALIDATION_FAILED', `No matching action for "${query}"`);
455
+ const open = openActions(body);
456
+ const toMatch = (a) => ({ line: a.line, text: stripActionStamp(a.raw) });
457
+ const ambiguous = (matches) => new HafezError('VALIDATION_FAILED', `Ambiguous match for "${query}": ${matches.map(a => stripActionStamp(a.raw)).join(', ')}`);
458
+ if (HANDLE_QUERY_RE.test(query)) {
459
+ const hashes = actionHashes(open.map(a => a.raw));
460
+ const byHandle = open.filter((_, i) => hashes[i].startsWith(query));
461
+ if (byHandle.length === 1)
462
+ return toMatch(byHandle[0]);
463
+ if (byHandle.length > 1)
464
+ throw ambiguous(byHandle);
372
465
  }
466
+ // A query that is only a stamp keeps it, so it cannot strip to '' and match every line.
467
+ const normalizedQuery = normalizeForMatch(stripActionStamp(query) || query).toLowerCase();
468
+ const matches = open.filter(a => normalizeForMatch(stripActionStamp(a.raw)).toLowerCase().includes(normalizedQuery));
373
469
  if (matches.length === 0) {
374
- throw new Error(`No matching action for "${query}". Actions: ${JSON.stringify(allActions)}`);
470
+ throw new HafezError('VALIDATION_FAILED', `No matching action for "${query}". Actions: ${JSON.stringify(open.map(a => stripActionStamp(a.raw)))}`);
375
471
  }
376
472
  if (matches.length > 1)
377
- throw new Error(`Ambiguous match for "${query}": ${matches.join(', ')}`);
378
- return matches[0];
473
+ throw ambiguous(matches);
474
+ return toMatch(matches[0]);
379
475
  }
380
476
  // --- Brief functions ---
381
477
  function getBrief(body) {
@@ -399,16 +495,29 @@ function makeDoc(structuralSections) {
399
495
  }
400
496
  // --- Next Actions functions ---
401
497
  function getNextActions(body) {
402
- const section = findSection(body, 'Next Actions');
403
- if (!section)
404
- return [];
405
- const actions = [];
406
- let m;
407
- const re = new RegExp(UNCHECKED_RE.source, 'gm');
408
- while ((m = re.exec(section.content)) !== null) {
409
- actions.push(m[1]);
410
- }
411
- return actions;
498
+ return openActions(body).map(a => stripActionStamp(a.raw));
499
+ }
500
+ /** Every checkbox line of Next Actions in file order, open lines with their Handle. */
501
+ function getActionItems(body) {
502
+ const handles = actionHandles(openActions(body).map(a => a.raw));
503
+ let open = 0;
504
+ return actionSectionLines(body).flatMap(l => {
505
+ const m = ACTION_RE.exec(l);
506
+ if (!m)
507
+ return [];
508
+ const text = stripActionStamp(m[2]);
509
+ return [m[1] === ' ' ? { handle: handles[open++], text, added: actionAdded(m[2]) } : { text }];
510
+ });
511
+ }
512
+ /** The body with each open Next Actions line rendered `- [ ] <handle> <text>`
513
+ * plus whatever `mark` gives for its added day — what `read` shows. Ticked
514
+ * lines and everything outside the section are left as they are. */
515
+ function renderActionHandles(body, mark = () => '') {
516
+ const open = openActions(body);
517
+ if (open.length === 0)
518
+ return body;
519
+ const handles = actionHandles(open.map(a => a.raw));
520
+ return editActionLines(body, lines => open.forEach((a, i) => { lines[a.line] = `- [ ] ${handles[i]} ${a.raw}${mark(actionAdded(a.raw))}`; }));
412
521
  }
413
522
  function addNextAction(body, action) {
414
523
  const section = findSection(body, 'Next Actions');
@@ -416,7 +525,7 @@ function makeDoc(structuralSections) {
416
525
  // Add the new item at the end of unchecked items (before any checked items)
417
526
  const lines = section.content.split('\n');
418
527
  const lastUncheckedIdx = lines.reduce((last, line, i) => line.startsWith('- [ ] ') ? i : last, -1);
419
- const insertIdx = lastUncheckedIdx === -1 ? 0 : lastUncheckedIdx + 1;
528
+ const insertIdx = lastUncheckedIdx === -1 ? 0 : actionEnd(lines, lastUncheckedIdx);
420
529
  lines.splice(insertIdx, 0, `- [ ] ${action}`);
421
530
  const newContent = lines.join('\n');
422
531
  const before = body.slice(0, section.start).replace(/\n+$/, '');
@@ -429,45 +538,79 @@ function makeDoc(structuralSections) {
429
538
  return insertSectionAfter(body, 'Next Actions', `- [ ] ${action}`, 'Brief', ['Session Log']);
430
539
  }
431
540
  /**
432
- * Edit the matched action's line inside the Next Actions section only.
433
- * Matching was always section-scoped; editing must be too — an identical
434
- * checkbox line quoted elsewhere in the body (a Brief restating a blocker)
435
- * is authored content, not an action. Works on the section's raw slice and
436
- * on whole lines, so action text is never interpreted as a replacement
437
- * pattern ($&, $', $1 survive byte-identical).
541
+ * Edit the Next Actions section's raw lines, and nothing outside it. An
542
+ * identical checkbox line quoted elsewhere in the body (a Brief restating
543
+ * a blocker) is authored content, not an action. Works on whole lines, so
544
+ * action text is never interpreted as a replacement pattern ($&, $', $1
545
+ * survive byte-identical).
438
546
  */
439
- function editActionLine(body, matched, edit) {
547
+ function editActionLines(body, edit) {
440
548
  const section = findSection(body, 'Next Actions');
441
549
  if (!section)
442
550
  return body;
443
551
  const lines = body.slice(section.headingEnd, section.end).split('\n');
444
- const idx = lines.indexOf(`- [ ] ${matched}`);
445
- if (idx === -1)
446
- return body;
447
- edit(lines, idx);
552
+ edit(lines);
448
553
  return body.slice(0, section.headingEnd) + lines.join('\n') + body.slice(section.end);
449
554
  }
450
- function completeNextAction(body, query) {
451
- const matched = matchAction(body, query);
452
- // Replace the unchecked item with checked
453
- const updatedBody = editActionLine(body, matched, (lines, i) => { lines[i] = `- [x] ${matched}`; });
454
- // Check if any unchecked items remain
455
- const remaining = getNextActions(updatedBody);
456
- if (remaining.length === 0) {
457
- return { matched, body: clearNextActions(updatedBody) };
458
- }
459
- return { matched, body: updatedBody };
460
- }
461
- function removeNextAction(body, query) {
462
- const matched = matchAction(body, query);
463
- // Remove the line
464
- const updatedBody = editActionLine(body, matched, (lines, i) => { lines.splice(i, 1); });
465
- // Check if any unchecked items remain
466
- const remaining = getNextActions(updatedBody);
467
- if (remaining.length === 0) {
468
- return { matched, body: clearNextActions(updatedBody) };
469
- }
470
- return { matched, body: updatedBody };
555
+ // complete / remove edit one line and nothing else. They do not collapse
556
+ // an emptied section — that is pruneNextActions, run once by the caller
557
+ // after every edit of the op, so a tick that empties the open list and an
558
+ // add in the same op never remove-then-recreate the section (which lost
559
+ // the checked lines and re-homed the section below Current State).
560
+ // Each takes a query, or a match resolved earlier against this same body.
561
+ // `matched` is reported unstamped; the line edited keeps its stamp.
562
+ function completeNextAction(body, action) {
563
+ const match = typeof action === 'string' ? matchAction(body, action) : action;
564
+ return { matched: match.text, body: editActionLines(body, lines => { lines[match.line] = lines[match.line].replace('- [ ] ', '- [x] '); }) };
565
+ }
566
+ // `line` is the removed line as the file held it, stamp included, with the
567
+ // lines it carries — what the archive log keeps.
568
+ function removeNextAction(body, action) {
569
+ const match = typeof action === 'string' ? matchAction(body, action) : action;
570
+ const section = actionSectionLines(body);
571
+ const end = actionEnd(section, match.line);
572
+ return { matched: match.text, line: actionBlock(section, match.line), body: editActionLines(body, lines => { lines.splice(match.line, end - match.line); }) };
573
+ }
574
+ /** Every checkbox line of Next Actions, open and ticked, as the file holds
575
+ * them — each with the lines it carries. */
576
+ function actionLines(body) {
577
+ const section = actionSectionLines(body);
578
+ return section.flatMap((l, i) => ACTION_RE.test(l) ? [actionBlock(section, i)] : []);
579
+ }
580
+ /** Take the oldest ticked lines out of Next Actions until at most `keep`
581
+ * remain. Lines are added in order and ticked in place, so file order is
582
+ * age order. `lines` are the taken lines as the file held them, oldest
583
+ * first, each with the lines it carries. */
584
+ function extractOldestTickedActions(body, keep) {
585
+ const section = actionSectionLines(body);
586
+ const ticked = section.flatMap((l, i) => ACTION_RE.exec(l)?.[1].trim() ? [i] : []);
587
+ const oldest = ticked.slice(0, Math.max(0, ticked.length - keep));
588
+ if (oldest.length === 0)
589
+ return { lines: [], body };
590
+ const taken = new Set(oldest.flatMap(i => Array.from({ length: actionEnd(section, i) - i }, (_, k) => i + k)));
591
+ return {
592
+ lines: oldest.map(i => actionBlock(section, i)),
593
+ body: editActionLines(body, lines => { lines.splice(0, lines.length, ...lines.filter((_, i) => !taken.has(i))); }),
594
+ };
595
+ }
596
+ /** The body with the stamp stripped from every checkbox line (open and
597
+ * ticked) of Next Actions — for surfaces that carry action text but not the
598
+ * vault's file format (export). Nothing outside the section is touched. */
599
+ function stripNextActionStamps(body) {
600
+ const section = findSection(body, 'Next Actions');
601
+ if (!section)
602
+ return body;
603
+ const content = body.slice(section.headingEnd, section.end)
604
+ .split('\n')
605
+ .map(line => /^- \[[ xX]\] /.test(line) ? stripActionStamp(line) : line)
606
+ .join('\n');
607
+ return body.slice(0, section.headingEnd) + content + body.slice(section.end);
608
+ }
609
+ /** Remove the section once no open item remains (appear-on-write, removed when emptied). */
610
+ function pruneNextActions(body) {
611
+ if (!findSection(body, 'Next Actions'))
612
+ return body;
613
+ return getNextActions(body).length === 0 ? clearNextActions(body) : body;
471
614
  }
472
615
  function clearNextActions(body) {
473
616
  return removeSection(body, 'Next Actions');
@@ -531,10 +674,17 @@ function makeDoc(structuralSections) {
531
674
  setBrief,
532
675
  removeBrief,
533
676
  getNextActions,
677
+ getActionItems,
678
+ renderActionHandles,
534
679
  addNextAction,
680
+ matchNextAction: matchAction,
535
681
  completeNextAction,
536
682
  removeNextAction,
683
+ actionLines,
684
+ extractOldestTickedActions,
685
+ pruneNextActions,
537
686
  clearNextActions,
687
+ stripNextActionStamps,
538
688
  syncRelatedSection,
539
689
  };
540
690
  }
@@ -549,6 +699,12 @@ export function getBrief(body) {
549
699
  export function getNextActions(body) {
550
700
  return ENTITY_UNION_DOC.getNextActions(body);
551
701
  }
702
+ export function getActionItems(body) {
703
+ return ENTITY_UNION_DOC.getActionItems(body);
704
+ }
705
+ export function renderActionHandles(body, mark) {
706
+ return ENTITY_UNION_DOC.renderActionHandles(body, mark);
707
+ }
552
708
  export function parseSessionLog(body) {
553
709
  return ENTITY_UNION_DOC.parseSessionLog(body);
554
710
  }
@@ -9,4 +9,17 @@ export interface OkfExportReport {
9
9
  }>;
10
10
  unresolvedLinks: number;
11
11
  }
12
+ /** Frontmatter of an exported OKF document. Not vault frontmatter: `name` maps
13
+ * to `title`, `type` is normalized per kind, `timestamp` (and `description`,
14
+ * when the source has none) is derived, `resource` survives only on entities,
15
+ * and the remaining keys are carried through verbatim. */
16
+ export interface OkfFrontmatter {
17
+ type: string;
18
+ title: string;
19
+ description?: string;
20
+ resource?: string;
21
+ tags?: string[];
22
+ timestamp?: string;
23
+ [key: string]: unknown;
24
+ }
12
25
  export declare function exportOkf(vaultPath: string, outDir: string): OkfExportReport;
@@ -9,9 +9,10 @@ import { readdirSync, writeFileSync, existsSync, mkdirSync } from 'fs';
9
9
  import { join, resolve, sep, basename } from 'path';
10
10
  import { parseFilePath, serializeFile, scanVaultDir, kindDir } from './vault.js';
11
11
  import { DEFAULT_KNOWLEDGE_SUBTYPE } from './contracts.js';
12
- import { deriveSummary, WIKI_LINK_RE } from './document.js';
12
+ import { deriveSummary, documentFor, WIKI_LINK_RE } from './document.js';
13
13
  const SOURCE_KINDS = ['entity', 'knowledge', 'session'];
14
14
  const KIND_DIRS = SOURCE_KINDS.map(kind => ({ dir: kindDir(kind), kind }));
15
+ const ENTITY_DOC = documentFor({ kind: 'entity' });
15
16
  // The shared wiki-link syntax (document.ts) — group 1 slug, group 2 display.
16
17
  const OKF_LINK_RE = new RegExp(WIKI_LINK_RE.source, 'g');
17
18
  /** `index.md` is reserved in every bundle directory for the generated index, so
@@ -41,14 +42,14 @@ function assignBundleNames(docs) {
41
42
  }
42
43
  function mapFrontmatter(doc) {
43
44
  const { fm, kind, body } = doc;
44
- const mapped = {};
45
+ let type;
45
46
  if (kind === 'entity')
46
- mapped.type = fm.type ?? 'entity';
47
+ type = fm.type ?? 'entity';
47
48
  else if (kind === 'knowledge')
48
- mapped.type = `knowledge/${fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}`;
49
+ type = `knowledge/${fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}`;
49
50
  else
50
- mapped.type = 'session';
51
- mapped.title = fm.name;
51
+ type = 'session';
52
+ const mapped = { type, title: fm.name };
52
53
  const description = deriveSummary(kind, fm, body);
53
54
  if (description)
54
55
  mapped.description = description;
@@ -151,7 +152,10 @@ export function exportOkf(vaultPath, outDir) {
151
152
  const mapped = mapFrontmatter(doc);
152
153
  const outSubdir = join(resolvedOut, doc.dir);
153
154
  mkdirSync(outSubdir, { recursive: true });
154
- writeFileSync(join(outSubdir, `${doc.file}.md`), serializeFile(mapped, transformBody(doc.body)));
155
+ // The added-date stamp is vault file format, not action text: exported
156
+ // Next Actions carry the unstamped text.
157
+ const body = doc.kind === 'entity' ? ENTITY_DOC.stripNextActionStamps(doc.body) : doc.body;
158
+ writeFileSync(join(outSubdir, `${doc.file}.md`), serializeFile(mapped, transformBody(body)));
155
159
  if (doc.kind === 'entity')
156
160
  report.entities++;
157
161
  else if (doc.kind === 'knowledge')
@@ -0,0 +1,58 @@
1
+ /** Recorded when no Harness is detectable. */
2
+ export declare const NEUTRAL_AGENT = "agent";
3
+ export declare function detectAgent(env?: Record<string, string | undefined>): string;
4
+ /** Every Harness `onboard` can configure, and where its always-loaded
5
+ * user-level instructions file lives, relative to the home directory (harness
6
+ * research, docs/research/2026-09-29-harness-instruction-surfaces.md): the
7
+ * first candidate that exists, else the last. Cursor keeps its User Rules in a
8
+ * Settings UI, not a file: `null`, and the Block is printed for manual paste.
9
+ * `dir` is the config directory whose presence says the Harness is installed. */
10
+ export declare const HARNESSES: {
11
+ readonly 'claude-code': {
12
+ readonly label: "Claude Code";
13
+ readonly dir: ".claude";
14
+ readonly instructions: readonly [".claude/CLAUDE.md"];
15
+ };
16
+ readonly codex: {
17
+ readonly label: "Codex";
18
+ readonly dir: ".codex";
19
+ readonly instructions: readonly [".codex/AGENTS.override.md", ".codex/AGENTS.md"];
20
+ };
21
+ readonly cursor: {
22
+ readonly label: "Cursor";
23
+ readonly dir: ".cursor";
24
+ readonly instructions: null;
25
+ };
26
+ readonly gemini: {
27
+ readonly label: "Gemini CLI";
28
+ readonly dir: ".gemini";
29
+ readonly instructions: readonly [".gemini/GEMINI.md"];
30
+ };
31
+ readonly copilot: {
32
+ readonly label: "Copilot CLI";
33
+ readonly dir: ".copilot";
34
+ readonly instructions: readonly [".copilot/copilot-instructions.md"];
35
+ };
36
+ };
37
+ export type HarnessId = keyof typeof HARNESSES;
38
+ /** The Harnesses bare `onboard` sets up from inside — the ones detected from
39
+ * the environment — and, relative to the home directory, where each keeps
40
+ * what the agent applies: the SessionStart hook and the permission entries.
41
+ * `keystroke` invokes the `hafez-save` skill there. */
42
+ export declare const ONBOARD_HARNESSES: {
43
+ readonly 'claude-code': {
44
+ readonly keystroke: "/hafez-save";
45
+ readonly hooks: ".claude/settings.json";
46
+ readonly allowEntry: "Bash(hafez:*)";
47
+ };
48
+ readonly codex: {
49
+ readonly keystroke: "$hafez-save";
50
+ readonly hooks: ".codex/hooks.json";
51
+ readonly rules: ".codex/rules/default.rules";
52
+ readonly rule: "prefix_rule(pattern = [\"hafez\"], decision = \"allow\")";
53
+ readonly config: ".codex/config.toml";
54
+ };
55
+ };
56
+ export type OnboardHarness = keyof typeof ONBOARD_HARNESSES;
57
+ export declare const isOnboardHarness: (value: string) => value is OnboardHarness;
58
+ export declare const isHarness: (value: string) => value is HarnessId;