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.
- package/README.md +26 -33
- package/dist/apply.d.ts +5 -2
- package/dist/apply.js +225 -114
- package/dist/batch-ops.d.ts +89 -26
- package/dist/batch-ops.js +176 -18
- package/dist/cli/args.d.ts +43 -5
- package/dist/cli/args.js +69 -8
- package/dist/cli/batch-errors.js +19 -11
- package/dist/cli/block-hook.d.ts +9 -0
- package/dist/cli/block-hook.js +90 -0
- package/dist/cli/commands-schema.js +2 -0
- package/dist/cli/commands.d.ts +4 -3
- package/dist/cli/commands.js +300 -99
- package/dist/cli/format.d.ts +7 -3
- package/dist/cli/format.js +27 -10
- package/dist/cli/index.d.ts +18 -4
- package/dist/cli/index.js +72 -30
- package/dist/cli/install.d.ts +53 -0
- package/dist/cli/install.js +297 -0
- package/dist/cli/onboard.d.ts +15 -2
- package/dist/cli/onboard.js +265 -90
- package/dist/cli/resolve-vault.d.ts +11 -4
- package/dist/cli/resolve-vault.js +13 -11
- package/dist/cli/schema-introspect.js +4 -3
- package/dist/cli/upgrade.d.ts +13 -4
- package/dist/cli/upgrade.js +90 -44
- package/dist/closest.d.ts +8 -0
- package/dist/closest.js +65 -0
- package/dist/contracts.d.ts +17 -1
- package/dist/contracts.js +44 -0
- package/dist/db.d.ts +15 -2
- package/dist/db.js +69 -48
- package/dist/digest.d.ts +6 -4
- package/dist/digest.js +28 -17
- package/dist/document.d.ts +41 -3
- package/dist/document.js +218 -62
- package/dist/export-okf.d.ts +13 -0
- package/dist/export-okf.js +11 -7
- package/dist/harness.d.ts +58 -0
- package/dist/harness.js +50 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +94 -104
- package/dist/merge.js +7 -5
- package/dist/near-match.d.ts +9 -0
- package/dist/near-match.js +34 -0
- package/dist/stale.d.ts +41 -0
- package/dist/stale.js +43 -0
- package/dist/stopwords.d.ts +6 -0
- package/dist/stopwords.js +25 -0
- package/dist/surfaces/items.d.ts +366 -0
- package/dist/surfaces/items.js +523 -0
- package/dist/surfaces/surfaces.d.ts +32 -0
- package/dist/surfaces/surfaces.js +230 -0
- package/dist/types.d.ts +45 -16
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +109 -0
- package/dist/vault.js +1 -1
- package/package.json +4 -3
- package/skill/hafez/SKILL.md +68 -0
- package/skill/hafez-save/SKILL.md +29 -0
- package/dist/cli/help-agent.d.ts +0 -1
- package/dist/cli/help-agent.js +0 -210
- 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.
|
|
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
|
-
.
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
for (const slug of
|
|
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
|
|
64
|
+
agent,
|
|
55
65
|
},
|
|
56
66
|
};
|
|
57
67
|
ops.push({ op: 'update', slug, fields });
|
|
58
68
|
}
|
|
59
|
-
// 2. Session note
|
|
60
|
-
|
|
61
|
-
const
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
102
|
+
if (touched.length > 0) {
|
|
92
103
|
lines.push('');
|
|
93
|
-
lines.push(`**Entities touched:** ${
|
|
104
|
+
lines.push(`**Entities touched:** ${touched.join(', ')}`);
|
|
94
105
|
}
|
|
95
106
|
return lines.join('\n');
|
|
96
107
|
}
|
package/dist/document.d.ts
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
356
|
-
*
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (
|
|
370
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
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
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* is
|
|
436
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
return { matched, body:
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
}
|
package/dist/export-okf.d.ts
CHANGED
|
@@ -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;
|
package/dist/export-okf.js
CHANGED
|
@@ -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
|
-
|
|
45
|
+
let type;
|
|
45
46
|
if (kind === 'entity')
|
|
46
|
-
|
|
47
|
+
type = fm.type ?? 'entity';
|
|
47
48
|
else if (kind === 'knowledge')
|
|
48
|
-
|
|
49
|
+
type = `knowledge/${fm.subtype ?? DEFAULT_KNOWLEDGE_SUBTYPE}`;
|
|
49
50
|
else
|
|
50
|
-
|
|
51
|
-
mapped
|
|
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
|
-
|
|
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;
|