hafez 2.0.0 → 2.1.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/dist/apply.js +45 -21
- package/dist/batch-ops.d.ts +7 -1
- package/dist/batch-ops.js +6 -4
- package/dist/cli/commands.js +2 -2
- package/dist/cli/index.js +3 -3
- package/dist/cli/install.d.ts +13 -1
- package/dist/cli/install.js +44 -8
- package/dist/cli/onboard.js +9 -1
- package/dist/cli/upgrade.d.ts +4 -2
- package/dist/cli/upgrade.js +18 -5
- package/dist/contracts.js +2 -0
- package/dist/surfaces/items.js +10 -6
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
package/dist/apply.js
CHANGED
|
@@ -50,7 +50,7 @@ function entityPath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, '
|
|
|
50
50
|
function knowledgePath(vaultPath, slug) { return resolveFilePath(vaultPath, slug, 'knowledge'); }
|
|
51
51
|
function today() { return localDay(); }
|
|
52
52
|
// --- Field policy ---
|
|
53
|
-
const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'brief', 'current_state', 'session_log', 'agent', 'resource', 'parent'];
|
|
53
|
+
const ENTITY_ONLY_FIELDS = ['status', 'add_action', 'add_actions', 'complete_action', 'complete_actions', 'remove_action', 'remove_actions', 'clear_actions', 'brief', 'current_state', 'session_log', 'agent', 'resource', 'parent'];
|
|
54
54
|
const KNOWLEDGE_ONLY_FIELDS = ['confidence', 'synthesis', 'add_evidence', 'add_source'];
|
|
55
55
|
// An entity-only field is still reachable on a knowledge note when THAT
|
|
56
56
|
// note's contract declares the capability the field needs. The gate asks the
|
|
@@ -185,8 +185,8 @@ export function buildKnowledgeFrontmatter(name, kf, existsFn) {
|
|
|
185
185
|
}
|
|
186
186
|
// The Next Actions fields are the one group in UpdateFields that all touch
|
|
187
187
|
// the same section, so their order within an op IS their meaning. The
|
|
188
|
-
// contract: prune, tick, add — clear_actions → remove_action →
|
|
189
|
-
// complete_action → add_action → add_actions. Every query is resolved
|
|
188
|
+
// contract: prune, tick, add — clear_actions → remove_action(s) →
|
|
189
|
+
// complete_action(s) → add_action → add_actions. Every query is resolved
|
|
190
190
|
// against the list as it stands after the clear and before any edit, so a
|
|
191
191
|
// query never sees a line another field of the same op removed or added;
|
|
192
192
|
// two queries naming one line is a collision, not a silent overwrite. The
|
|
@@ -207,27 +207,51 @@ const ARCHIVE_LIMIT = 10;
|
|
|
207
207
|
function applyNextActionFields(doc, body, fields) {
|
|
208
208
|
const result = {};
|
|
209
209
|
const cleared = fields.clear_actions ? doc.actionLines(body) : [];
|
|
210
|
-
let removedLine;
|
|
211
210
|
if (fields.clear_actions)
|
|
212
211
|
body = doc.clearNextActions(body);
|
|
213
|
-
const
|
|
214
|
-
const
|
|
212
|
+
const match = (field, query) => ({ field, match: doc.matchNextAction(body, query) });
|
|
213
|
+
const removes = [
|
|
214
|
+
...(fields.remove_action !== undefined ? [match('remove_action', fields.remove_action)] : []),
|
|
215
|
+
...(fields.remove_actions ?? []).map(q => match('remove_actions', q)),
|
|
216
|
+
];
|
|
217
|
+
const completes = [
|
|
218
|
+
...(fields.complete_action !== undefined ? [match('complete_action', fields.complete_action)] : []),
|
|
219
|
+
...(fields.complete_actions ?? []).map(q => match('complete_actions', q)),
|
|
220
|
+
];
|
|
215
221
|
// Caller misuse, not a Spine bug: like no-match and ambiguous (matchNextAction),
|
|
216
222
|
// it depends on the list as the earlier ops of a batch leave it, so it is
|
|
217
223
|
// resolved here rather than in the validate phase.
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
const seen = new Map();
|
|
225
|
+
for (const { field, match: m } of [...removes, ...completes]) {
|
|
226
|
+
const first = seen.get(m.line);
|
|
227
|
+
if (first !== undefined) {
|
|
228
|
+
throw new HafezError('VALIDATION_FAILED', first === field
|
|
229
|
+
? `${field} matches "${m.text}" twice — one line, one query`
|
|
230
|
+
: `${first} and ${field} both match "${m.text}" — one line, one field`);
|
|
231
|
+
}
|
|
232
|
+
seen.set(m.line, field);
|
|
233
|
+
}
|
|
234
|
+
// The singular query, when sent, is first in its list; the array form is the rest.
|
|
235
|
+
const texts = (list) => list.map(q => q.match.text);
|
|
236
|
+
const removeOffset = fields.remove_action !== undefined ? 1 : 0;
|
|
237
|
+
const completeOffset = fields.complete_action !== undefined ? 1 : 0;
|
|
238
|
+
if (removeOffset)
|
|
239
|
+
result.removed_action = removes[0].match.text;
|
|
240
|
+
if (fields.remove_actions !== undefined)
|
|
241
|
+
result.removed_actions = texts(removes.slice(removeOffset));
|
|
242
|
+
if (completeOffset)
|
|
243
|
+
result.completed_action = completes[0].match.text;
|
|
244
|
+
if (fields.complete_actions !== undefined)
|
|
245
|
+
result.completed_actions = texts(completes.slice(completeOffset));
|
|
246
|
+
// Every match is a line position in the pre-edit list. A tick leaves every
|
|
247
|
+
// position where it was; a removal shifts the lines below it — so tick
|
|
248
|
+
// first, then remove bottom-up. The archive keeps query order.
|
|
249
|
+
for (const { match: m } of completes)
|
|
250
|
+
body = doc.completeNextAction(body, m).body;
|
|
251
|
+
const removed = [];
|
|
252
|
+
for (const i of removes.map((_, i) => i).sort((x, y) => removes[y].match.line - removes[x].match.line)) {
|
|
253
|
+
({ body, line: removed[i] } = doc.removeNextAction(body, removes[i].match));
|
|
220
254
|
}
|
|
221
|
-
if (removed)
|
|
222
|
-
result.removed_action = removed.text;
|
|
223
|
-
if (completed)
|
|
224
|
-
result.completed_action = completed.text;
|
|
225
|
-
// Both matches are line positions in the pre-edit list. A tick leaves every
|
|
226
|
-
// position where it was; a removal shifts the lines below it — so tick first.
|
|
227
|
-
if (completed)
|
|
228
|
-
body = doc.completeNextAction(body, completed).body;
|
|
229
|
-
if (removed)
|
|
230
|
-
({ body, line: removedLine } = doc.removeNextAction(body, removed));
|
|
231
255
|
if (fields.add_action !== undefined)
|
|
232
256
|
body = addStampedAction(doc, body, fields.add_action);
|
|
233
257
|
if (fields.add_actions !== undefined) {
|
|
@@ -241,7 +265,7 @@ function applyNextActionFields(doc, body, fields) {
|
|
|
241
265
|
return {
|
|
242
266
|
body: doc.pruneNextActions(overflow.body),
|
|
243
267
|
result,
|
|
244
|
-
archived: { cleared, removed
|
|
268
|
+
archived: { cleared, removed, ticked: [...overflow.lines, ...pruned] },
|
|
245
269
|
};
|
|
246
270
|
}
|
|
247
271
|
// Pure field application, like every arm above it: the Spine has already run
|
|
@@ -317,8 +341,8 @@ function archivedActionEntries(archived) {
|
|
|
317
341
|
const n = archived.cleared.length;
|
|
318
342
|
entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: `Cleared ${n} action${plural(n)}`, body: archived.cleared.join('\n') }));
|
|
319
343
|
}
|
|
320
|
-
|
|
321
|
-
entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body:
|
|
344
|
+
for (const line of archived.removed) {
|
|
345
|
+
entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body: line }));
|
|
322
346
|
}
|
|
323
347
|
const n = archived.ticked.length;
|
|
324
348
|
if (n > 0) {
|
package/dist/batch-ops.d.ts
CHANGED
|
@@ -27,7 +27,9 @@ export declare const UpdateFieldsSchema: z.ZodObject<{
|
|
|
27
27
|
add_action: z.ZodOptional<z.ZodString>;
|
|
28
28
|
add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
29
29
|
complete_action: z.ZodOptional<z.ZodString>;
|
|
30
|
+
complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
30
31
|
remove_action: z.ZodOptional<z.ZodString>;
|
|
32
|
+
remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
31
33
|
clear_actions: z.ZodOptional<z.ZodBoolean>;
|
|
32
34
|
parent: z.ZodOptional<z.ZodString>;
|
|
33
35
|
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
@@ -110,7 +112,9 @@ export declare const BatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
|
|
110
112
|
add_action: z.ZodOptional<z.ZodString>;
|
|
111
113
|
add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
112
114
|
complete_action: z.ZodOptional<z.ZodString>;
|
|
115
|
+
complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
113
116
|
remove_action: z.ZodOptional<z.ZodString>;
|
|
117
|
+
remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
114
118
|
clear_actions: z.ZodOptional<z.ZodBoolean>;
|
|
115
119
|
parent: z.ZodOptional<z.ZodString>;
|
|
116
120
|
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
@@ -279,7 +283,9 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
279
283
|
add_action: z.ZodOptional<z.ZodString>;
|
|
280
284
|
add_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
281
285
|
complete_action: z.ZodOptional<z.ZodString>;
|
|
286
|
+
complete_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
282
287
|
remove_action: z.ZodOptional<z.ZodString>;
|
|
288
|
+
remove_actions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
283
289
|
clear_actions: z.ZodOptional<z.ZodBoolean>;
|
|
284
290
|
parent: z.ZodOptional<z.ZodString>;
|
|
285
291
|
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
@@ -296,7 +302,7 @@ declare const OP_SPEC_ROWS: readonly [{
|
|
|
296
302
|
add_source: z.ZodOptional<z.ZodString>;
|
|
297
303
|
}, z.core.$strict>;
|
|
298
304
|
}, z.core.$strict>;
|
|
299
|
-
readonly description: "Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action` → `complete_action` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity's archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.";
|
|
305
|
+
readonly description: "Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action(s)` → `complete_action(s)` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity's archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.";
|
|
300
306
|
readonly exampleInclude: readonly ["status"];
|
|
301
307
|
readonly check: (op: Extract<BatchOperation, {
|
|
302
308
|
op: "update";
|
package/dist/batch-ops.js
CHANGED
|
@@ -52,7 +52,9 @@ export const UpdateFieldsSchema = z.object({
|
|
|
52
52
|
add_action: nonEmptyText.optional(),
|
|
53
53
|
add_actions: z.array(nonEmptyText).optional(),
|
|
54
54
|
complete_action: nonEmptyText.optional(),
|
|
55
|
+
complete_actions: z.array(nonEmptyText).min(1).optional(),
|
|
55
56
|
remove_action: nonEmptyText.optional(),
|
|
57
|
+
remove_actions: z.array(nonEmptyText).min(1).optional(),
|
|
56
58
|
clear_actions: z.boolean().optional(),
|
|
57
59
|
parent: z.string().optional(), // entity-only; the slug must exist, no cycle
|
|
58
60
|
// Knowledge metadata
|
|
@@ -120,7 +122,7 @@ const CreateSessionBranch = z.object({
|
|
|
120
122
|
}).strict();
|
|
121
123
|
/** Update fields the mechanised session log neither counts nor names:
|
|
122
124
|
* ticking a box is not history worth an entry, and `agent` only names it. */
|
|
123
|
-
const UNLOGGED_FIELDS = new Set(['add_action', 'add_actions', 'complete_action', 'remove_action', 'clear_actions', 'agent']);
|
|
125
|
+
const UNLOGGED_FIELDS = new Set(['add_action', 'add_actions', 'complete_action', 'complete_actions', 'remove_action', 'remove_actions', 'clear_actions', 'agent']);
|
|
124
126
|
/** The fields an update's mechanised session log entry names — empty when
|
|
125
127
|
* the update earns none. Shared by the update check (which rejects an
|
|
126
128
|
* `agent` with no entry to name) and the Apply update arm (which writes it). */
|
|
@@ -316,7 +318,7 @@ const OP_SPEC_ROWS = [
|
|
|
316
318
|
name: 'update',
|
|
317
319
|
op: 'update',
|
|
318
320
|
schema: UpdateBranch,
|
|
319
|
-
description: 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action` → `complete_action` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity\'s archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.',
|
|
321
|
+
description: 'Update an entity or knowledge note (status, brief, next actions, session log, synthesis, …). Next Actions fields compose within one op as prune, tick, add: `clear_actions` → `remove_action(s)` → `complete_action(s)` → `add_action(s)` — queries match the list as it was before the op (two queries naming one line is an error), added lines land last, so `clear_actions` + `add_actions` rebuilds the list; `clear_actions` with a query field is rejected. Lines leaving the list (removed, cleared, pruned with an emptied section, ticked past nine, oldest first) move to the entity\'s archive log. A Handle of an open action (as `read` shows it, or any longer or shorter prefix of four or more hex that names only that line) names that line; anything else is a case-insensitive substring of the text. `parent` sets the parent link (entity-only; the slug must exist, and the parent chain must not lead back to the entity). `insight` is an alias for `synthesis` (knowledge-only; ignored when `synthesis` is also given). An entity update with no `session_log` gets a `progress` entry naming the fields written, unless its only fields are action fields; `agent` names the agent on it (entity-only, detected from the Harness when absent, rejected alongside `session_log`). On knowledge notes `status` is plan-subtype only (active|done); insights reject it.',
|
|
320
322
|
exampleInclude: ['status'],
|
|
321
323
|
check(op, ctx) {
|
|
322
324
|
const errors = [];
|
|
@@ -341,9 +343,9 @@ const OP_SPEC_ROWS = [
|
|
|
341
343
|
// only — on a knowledge note validateKindFields has already rejected
|
|
342
344
|
// the fields themselves, and a second error about their order is noise.
|
|
343
345
|
if (target?.kind === 'entity' && op.fields.clear_actions) {
|
|
344
|
-
const queries = ['remove_action', 'complete_action'].filter(f => op.fields[f] !== undefined);
|
|
346
|
+
const queries = ['remove_action', 'remove_actions', 'complete_action', 'complete_actions'].filter(f => op.fields[f] !== undefined);
|
|
345
347
|
if (queries.length > 0) {
|
|
346
|
-
errors.push(`clear_actions empties the list before ${queries.join(' and ')} would run — drop the clear or use a separate op`);
|
|
348
|
+
errors.push(`clear_actions empties the list before ${queries.slice(0, -1).join(', ')}${queries.length > 1 ? ' and ' : ''}${queries.at(-1)} would run — drop the clear or use a separate op`);
|
|
347
349
|
}
|
|
348
350
|
}
|
|
349
351
|
// `agent` names the entry the Spine adds; with nothing added it would be
|
package/dist/cli/commands.js
CHANGED
|
@@ -356,11 +356,11 @@ export async function cmdCreate(os, args, opts = {}) {
|
|
|
356
356
|
}
|
|
357
357
|
throw new UsageError(`Unknown kind: ${kind}. Use entity or knowledge.`);
|
|
358
358
|
}
|
|
359
|
-
const UPDATE_USAGE = 'Usage: hafez update <slug> [--brief text] [--description text] [--resource uri] [--add-action text (repeats)] [--complete-action handle-or-text] [--remove-action handle-or-text] [--clear-actions] [--status <active|paused|done>] [--parent slug] [--log "<progress|decision|blocker|research>: summary" [--body text]]';
|
|
359
|
+
const UPDATE_USAGE = 'Usage: hafez update <slug> [--brief text] [--description text] [--resource uri] [--add-action text (repeats)] [--complete-action handle-or-text (repeats)] [--remove-action handle-or-text (repeats)] [--clear-actions] [--status <active|paused|done>] [--parent slug] [--log "<progress|decision|blocker|research>: summary" [--body text]]';
|
|
360
360
|
/** Exported so tests can assert the table covers the update schema. */
|
|
361
361
|
export const UPDATE_FLAGS = {
|
|
362
362
|
status: 'string', 'current-state': 'string', brief: 'string',
|
|
363
|
-
'add-action': 'repeat', 'complete-action': '
|
|
363
|
+
'add-action': 'repeat', 'complete-action': 'repeat', 'remove-action': 'repeat',
|
|
364
364
|
'clear-actions': 'boolean', parent: 'string',
|
|
365
365
|
description: 'string', resource: 'string', domain: 'string', confidence: 'string',
|
|
366
366
|
tags: 'string', related: 'string', insight: 'string', synthesis: 'string',
|
package/dist/cli/index.js
CHANGED
|
@@ -88,9 +88,9 @@ Commands:
|
|
|
88
88
|
--brief "text" Set/replace Brief (use --brief "" to clear)
|
|
89
89
|
--description "text" Set one-line description (use --description "" to clear)
|
|
90
90
|
--resource "uri" Set canonical resource URI, entities only (use --resource "" to clear)
|
|
91
|
-
--add-action "text" Add a next action
|
|
92
|
-
--complete-action handle-or-text Complete the action with that Handle (as read shows it), else the matching one
|
|
93
|
-
--remove-action handle-or-text Remove the action with that Handle, else the matching one
|
|
91
|
+
--add-action "text" Add a next action (repeats)
|
|
92
|
+
--complete-action handle-or-text Complete the action with that Handle (as read shows it), else the matching one (repeats)
|
|
93
|
+
--remove-action handle-or-text Remove the action with that Handle, else the matching one (repeats)
|
|
94
94
|
--clear-actions Remove all next actions
|
|
95
95
|
--synthesis "text" Set/replace Synthesis section (--insight is an alias)
|
|
96
96
|
--add-evidence "text" Append to Evidence section
|
package/dist/cli/install.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export declare const SKILL_DIRS: string[];
|
|
|
6
6
|
export type HashState = 'absent' | 'current' | 'stale' | 'hand-edited';
|
|
7
7
|
/** What to do about broken markers: a hand edit, then `rerun`. */
|
|
8
8
|
export declare const fixBrokenMarkers: (path: string, rerun: string) => string;
|
|
9
|
+
/** What to do about a 1.x section: Hafez cannot tell where it ends. */
|
|
10
|
+
export declare const removeLegacy: (path: string, line: number) => string;
|
|
11
|
+
/** The status line for a 1.x section. */
|
|
12
|
+
export declare const legacyLine: (path: string, line: number) => string;
|
|
9
13
|
/** Codex reads an `AGENTS.override.md` in place of `AGENTS.md` when one
|
|
10
14
|
* exists: the first candidate present, else the last. */
|
|
11
15
|
export declare const instructionsFile: (home: string, candidates: readonly string[]) => string;
|
|
@@ -18,6 +22,8 @@ export interface InstallState {
|
|
|
18
22
|
skills: HashState;
|
|
19
23
|
/** Some skill file carries a hash: `onboard --install` has run here. */
|
|
20
24
|
onboarded: boolean;
|
|
25
|
+
/** The line of a 1.x section in the instructions file, else null. */
|
|
26
|
+
legacy: number | null;
|
|
21
27
|
}
|
|
22
28
|
export declare function installState(home: string, harness: HarnessId): InstallState;
|
|
23
29
|
export declare function install(opts: {
|
|
@@ -38,12 +44,18 @@ export interface BlockCheck {
|
|
|
38
44
|
/** The Block Hafez writes has changed since this one was written. */
|
|
39
45
|
renderChanged: boolean;
|
|
40
46
|
}
|
|
47
|
+
export interface LegacyCheck {
|
|
48
|
+
harness: HarnessId;
|
|
49
|
+
path: string;
|
|
50
|
+
line: number;
|
|
51
|
+
}
|
|
41
52
|
/** Both skills in both directories, and the Block in the instructions file
|
|
42
53
|
* each Harness reads — the one `--install` would write. Only Blocks that are
|
|
43
|
-
* there are listed: none means invoke-only. */
|
|
54
|
+
* there are listed: none, and no 1.x section, means invoke-only. */
|
|
44
55
|
export declare function checkInstall(home: string): {
|
|
45
56
|
skills: SkillCheck[];
|
|
46
57
|
blocks: BlockCheck[];
|
|
58
|
+
legacy: LegacyCheck[];
|
|
47
59
|
};
|
|
48
60
|
/** Remove everything Hafez wrote: both skills from both directories, and the
|
|
49
61
|
* Block region from every instructions file a Harness reads. Nothing else. */
|
package/dist/cli/install.js
CHANGED
|
@@ -75,6 +75,26 @@ function findRegion(file, displayPath) {
|
|
|
75
75
|
text: file.slice(begin.start, end.start + END.length),
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
+
// --- The 1.x section ---
|
|
79
|
+
/** The heading 1.2–1.4 `onboard` had the agent paste, with no markers (#157). */
|
|
80
|
+
const LEGACY_HEADING = '## Hafez — Session Memory';
|
|
81
|
+
/** The 1-based line of a 1.x section outside the Block region, or null. With
|
|
82
|
+
* broken markers (`region` undefined) the region is unknown: every line is read. */
|
|
83
|
+
function findLegacy(file, region) {
|
|
84
|
+
let offset = 0;
|
|
85
|
+
const lines = file.split('\n');
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const inside = region != null && offset >= region.start && offset < region.next;
|
|
88
|
+
if (!inside && lines[i].trimEnd() === LEGACY_HEADING)
|
|
89
|
+
return i + 1;
|
|
90
|
+
offset += lines[i].length + 1;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/** What to do about a 1.x section: Hafez cannot tell where it ends. */
|
|
95
|
+
export const removeLegacy = (path, line) => `Remove the Hafez 1.x section in ${path}, from its heading \`${LEGACY_HEADING}\` (line ${line}) to the end of that section, after the user's yes. It has no markers, so Hafez does not delete it.`;
|
|
96
|
+
/** The status line for a 1.x section. */
|
|
97
|
+
export const legacyLine = (path, line) => `Legacy section in ${path}, line ${line}: \`${LEGACY_HEADING}\``;
|
|
78
98
|
/** A line diff of the user's Block against the render: ` ` both, `-` theirs
|
|
79
99
|
* only, `+` the render only. */
|
|
80
100
|
function lineDiff(theirs, ours) {
|
|
@@ -207,14 +227,19 @@ function run({ header, steps, tail = [], unchanged, listAll }) {
|
|
|
207
227
|
export function installState(home, harness) {
|
|
208
228
|
const { instructions } = HARNESSES[harness];
|
|
209
229
|
let block = null;
|
|
230
|
+
let legacy = null;
|
|
210
231
|
if (instructions !== null) {
|
|
211
232
|
const rel = instructionsFile(home, instructions);
|
|
233
|
+
const text = readInstalled(join(home, rel)) ?? '';
|
|
234
|
+
let region;
|
|
212
235
|
try {
|
|
213
|
-
|
|
236
|
+
region = findRegion(text, `~/${rel}`);
|
|
237
|
+
block = hashState(region, RENDERED.text);
|
|
214
238
|
}
|
|
215
239
|
catch {
|
|
216
240
|
block = 'broken';
|
|
217
241
|
}
|
|
242
|
+
legacy = findLegacy(text, region);
|
|
218
243
|
}
|
|
219
244
|
const skills = SKILL_DIRS.flatMap(dir => SKILLS.map(({ name, render }) => {
|
|
220
245
|
const text = readInstalled(join(home, dir, name, 'SKILL.md'));
|
|
@@ -224,7 +249,7 @@ export function installState(home, harness) {
|
|
|
224
249
|
const states = new Set(skills.map(s => s.state));
|
|
225
250
|
const [only] = states;
|
|
226
251
|
const merged = states.size === 1 ? only : states.has('hand-edited') ? 'hand-edited' : 'stale';
|
|
227
|
-
return { block, skills: merged, onboarded: skills.some(s => s.hashed) };
|
|
252
|
+
return { block, skills: merged, onboarded: skills.some(s => s.hashed), legacy };
|
|
228
253
|
}
|
|
229
254
|
// --- The two commands ---
|
|
230
255
|
export function install(opts) {
|
|
@@ -232,7 +257,8 @@ export function install(opts) {
|
|
|
232
257
|
const { label, instructions } = HARNESSES[harness];
|
|
233
258
|
const steps = skillSteps(home, 'write');
|
|
234
259
|
const tail = [];
|
|
235
|
-
|
|
260
|
+
const rel = instructions === null ? null : instructionsFile(home, instructions);
|
|
261
|
+
if (rel === null) {
|
|
236
262
|
if (mode === 'always-on') {
|
|
237
263
|
steps.push({ line: `Block: printed below, for ${label}'s User Rules` });
|
|
238
264
|
tail.push(`${label} keeps its User Rules in Settings, not a file, so the Block was not written. Show the user the Block below and ask them to paste it into ${label} Settings → Rules → User Rules, marker lines included.`, `\`\`\`\`\n${renderBlock()}\`\`\`\``);
|
|
@@ -244,24 +270,32 @@ export function install(opts) {
|
|
|
244
270
|
else {
|
|
245
271
|
steps.push(blockStep({
|
|
246
272
|
home,
|
|
247
|
-
rel
|
|
273
|
+
rel,
|
|
248
274
|
want: mode === 'always-on' ? 'write' : 'remove',
|
|
249
275
|
confirm,
|
|
250
276
|
rerun: `hafez onboard --install --harness ${harness} --mode ${mode} --confirm`,
|
|
251
277
|
suffix: mode === 'invoke-only' ? ' (invoke-only)' : '',
|
|
252
278
|
}));
|
|
253
279
|
}
|
|
254
|
-
|
|
280
|
+
const out = run({ header: `${label}, ${mode}:`, steps, tail, unchanged: 'Nothing changed: the install is current.', listAll: true });
|
|
281
|
+
if (rel === null)
|
|
282
|
+
return out;
|
|
283
|
+
// A 1.x section loads beside the Block, or in place of invoke-only: named,
|
|
284
|
+
// never touched (#162). Read after the writes, so its line is where it is now.
|
|
285
|
+
const text = readInstalled(join(home, rel)) ?? '';
|
|
286
|
+
const line = findLegacy(text, findRegion(text, `~/${rel}`));
|
|
287
|
+
return line === null ? out : `${out}\n\n${removeLegacy(`~/${rel}`, line)}`;
|
|
255
288
|
}
|
|
256
289
|
/** Both skills in both directories, and the Block in the instructions file
|
|
257
290
|
* each Harness reads — the one `--install` would write. Only Blocks that are
|
|
258
|
-
* there are listed: none means invoke-only. */
|
|
291
|
+
* there are listed: none, and no 1.x section, means invoke-only. */
|
|
259
292
|
export function checkInstall(home) {
|
|
260
293
|
const skills = skillFiles(home).map(({ rel, render, text }) => ({
|
|
261
294
|
path: `~/${rel}`,
|
|
262
295
|
state: hashState(text === null ? null : parseSkill(text), render()),
|
|
263
296
|
}));
|
|
264
297
|
const blocks = [];
|
|
298
|
+
const legacy = [];
|
|
265
299
|
for (const [harness, { instructions }] of Object.entries(HARNESSES)) {
|
|
266
300
|
if (instructions === null)
|
|
267
301
|
continue;
|
|
@@ -276,14 +310,16 @@ export function checkInstall(home) {
|
|
|
276
310
|
}
|
|
277
311
|
catch {
|
|
278
312
|
blocks.push({ harness, path, state: 'broken', renderChanged: false });
|
|
279
|
-
continue;
|
|
280
313
|
}
|
|
314
|
+
const line = findLegacy(text, region);
|
|
315
|
+
if (line !== null)
|
|
316
|
+
legacy.push({ harness, path, line });
|
|
281
317
|
if (!region)
|
|
282
318
|
continue;
|
|
283
319
|
const state = hashState(region, RENDERED.text);
|
|
284
320
|
blocks.push({ harness, path, state, renderChanged: region.hash !== RENDERED.hash });
|
|
285
321
|
}
|
|
286
|
-
return { skills, blocks };
|
|
322
|
+
return { skills, blocks, legacy };
|
|
287
323
|
}
|
|
288
324
|
/** Remove everything Hafez wrote: both skills from both directories, and the
|
|
289
325
|
* Block region from every instructions file a Harness reads. Nothing else. */
|
package/dist/cli/onboard.js
CHANGED
|
@@ -15,7 +15,7 @@ import { homedir } from 'os';
|
|
|
15
15
|
import { join } from 'path';
|
|
16
16
|
import { UsageError, parseArgs, str } from './args.js';
|
|
17
17
|
import { HARNESSES, ONBOARD_HARNESSES, detectAgent, isHarness, isOnboardHarness } from '../harness.js';
|
|
18
|
-
import { MODES, SKILL_DIRS, fixBrokenMarkers, install, installState, instructionsFile, isMode, remove } from './install.js';
|
|
18
|
+
import { MODES, SKILL_DIRS, fixBrokenMarkers, install, installState, instructionsFile, isMode, legacyLine, remove, removeLegacy } from './install.js';
|
|
19
19
|
import { noVaultMessage, resolveVaultPath } from './resolve-vault.js';
|
|
20
20
|
import { HafezError } from '../types.js';
|
|
21
21
|
import { kindDir, scanVaultDir } from '../vault.js';
|
|
@@ -159,6 +159,9 @@ export function renderOnboard({ home, harness, vault }) {
|
|
|
159
159
|
...[{ ...HARNESSES[harness], state }, ...others]
|
|
160
160
|
.filter(h => h.state.block === 'broken')
|
|
161
161
|
.map(h => fixBrokenMarkers(`~/${instructionsFile(home, h.instructions)}`, 'hafez onboard')),
|
|
162
|
+
...[{ ...HARNESSES[harness], state }, ...others]
|
|
163
|
+
.filter(h => h.state.legacy !== null)
|
|
164
|
+
.map(h => `${legacyLine(`~/${instructionsFile(home, h.instructions)}`, h.state.legacy)}, from Hafez 1.x, with no markers.`),
|
|
162
165
|
];
|
|
163
166
|
// With the install current there is no install step: switching the Mode is
|
|
164
167
|
// walked here, and re-running `onboard` afterwards walks what it changed.
|
|
@@ -187,6 +190,8 @@ export function renderOnboard({ home, harness, vault }) {
|
|
|
187
190
|
steps.push({
|
|
188
191
|
title: 'Install',
|
|
189
192
|
body: [
|
|
193
|
+
// Either install leaves the 1.x section in place, beside the Block or alone.
|
|
194
|
+
...(state.legacy === null ? [] : [`${removeLegacy(instructionsRel, state.legacy)} Do this before either install below.`, '']),
|
|
190
195
|
`Always-on: ${showBlock}`,
|
|
191
196
|
'',
|
|
192
197
|
'hafez onboard --install --mode always-on',
|
|
@@ -201,6 +206,9 @@ export function renderOnboard({ home, harness, vault }) {
|
|
|
201
206
|
].join('\n'),
|
|
202
207
|
});
|
|
203
208
|
}
|
|
209
|
+
if (installCurrent && state.legacy !== null) {
|
|
210
|
+
steps.push({ title: 'Remove the Hafez 1.x section', body: removeLegacy(instructionsRel, state.legacy) });
|
|
211
|
+
}
|
|
204
212
|
if (!config.allowlist) {
|
|
205
213
|
steps.push({ title: 'Allow the command', body: ITEMS.harnessPermissions.render({ harness, vault }) });
|
|
206
214
|
}
|
package/dist/cli/upgrade.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BlockCheck, type SkillCheck } from './install.js';
|
|
1
|
+
import { type BlockCheck, type LegacyCheck, type SkillCheck } from './install.js';
|
|
2
2
|
export type Comparison = 'behind' | 'equal' | 'ahead' | 'unknown';
|
|
3
3
|
/** `command` entries are directly executable (after user approval); `action`
|
|
4
4
|
* entries are prose, so an agent that shells out every entry does not try to
|
|
@@ -21,11 +21,13 @@ export interface UpgradeState {
|
|
|
21
21
|
alternativeInstall: string | null;
|
|
22
22
|
steps: UpgradeStep[];
|
|
23
23
|
}
|
|
24
|
-
/** Mode is not stored: a Block present means always-on.
|
|
24
|
+
/** Mode is not stored: a Block present means always-on, and so does a 1.x
|
|
25
|
+
* section with no markers (`legacy`). */
|
|
25
26
|
export interface Integration {
|
|
26
27
|
mode: 'always-on' | 'invoke-only';
|
|
27
28
|
blocks: BlockCheck[];
|
|
28
29
|
skills: SkillCheck[];
|
|
30
|
+
legacy: LegacyCheck[];
|
|
29
31
|
}
|
|
30
32
|
export interface UpgradeDeps {
|
|
31
33
|
fetch: typeof globalThis.fetch;
|
package/dist/cli/upgrade.js
CHANGED
|
@@ -29,7 +29,7 @@ import { dirname, join } from 'path';
|
|
|
29
29
|
import { fileURLToPath } from 'url';
|
|
30
30
|
import { UsageError, helpIfAsked } from './args.js';
|
|
31
31
|
import { HARNESSES, detectAgent, isHarness } from '../harness.js';
|
|
32
|
-
import { checkInstall, fixBrokenMarkers } from './install.js';
|
|
32
|
+
import { checkInstall, fixBrokenMarkers, legacyLine, removeLegacy } from './install.js';
|
|
33
33
|
const PLAIN_SEMVER = /^\d+\.\d+\.\d+$/;
|
|
34
34
|
const REGISTRY_TIMEOUT_MS = 3000;
|
|
35
35
|
const NPM_PROBE_TIMEOUT_MS = 3000;
|
|
@@ -131,8 +131,8 @@ function devCheckoutStep(packageRoot) {
|
|
|
131
131
|
}
|
|
132
132
|
function readIntegration(home) {
|
|
133
133
|
try {
|
|
134
|
-
const { skills, blocks } = checkInstall(home);
|
|
135
|
-
return { mode: blocks.length > 0 ? 'always-on' : 'invoke-only', blocks, skills };
|
|
134
|
+
const { skills, blocks, legacy } = checkInstall(home);
|
|
135
|
+
return { mode: blocks.length > 0 || legacy.length > 0 ? 'always-on' : 'invoke-only', blocks, skills, legacy };
|
|
136
136
|
}
|
|
137
137
|
catch {
|
|
138
138
|
return null;
|
|
@@ -155,7 +155,18 @@ function integrationSteps(integration, upgrading, env) {
|
|
|
155
155
|
text: fixBrokenMarkers(b.path, 'hafez upgrade'),
|
|
156
156
|
}));
|
|
157
157
|
}
|
|
158
|
-
const { blocks, skills } = integration;
|
|
158
|
+
const { blocks, skills, legacy } = integration;
|
|
159
|
+
// The 1.x section goes first: the always-on install writes a Block beside
|
|
160
|
+
// it, and both would be loaded.
|
|
161
|
+
const removals = legacy.map(l => ({ kind: 'action', text: removeLegacy(l.path, l.line) }));
|
|
162
|
+
const unblocked = legacy
|
|
163
|
+
.filter(l => !blocks.some(b => b.harness === l.harness))
|
|
164
|
+
.map(l => ({ kind: 'command', text: `hafez onboard --install --mode always-on --harness ${l.harness}` }));
|
|
165
|
+
// With no Block, the always-on install refreshes the skills too.
|
|
166
|
+
const rest = blocks.length === 0 && legacy.length > 0 ? [] : blockSteps(integration, upgrading, env);
|
|
167
|
+
return [...removals, ...unblocked, ...rest];
|
|
168
|
+
}
|
|
169
|
+
function blockSteps({ blocks, skills }, upgrading, env) {
|
|
159
170
|
const skillDrift = skills.some(s => s.state === 'stale' || s.state === 'hand-edited');
|
|
160
171
|
const blockDrift = (b) => b.state === 'stale' || (b.state === 'hand-edited' && b.renderChanged);
|
|
161
172
|
const installed = blocks.length > 0 || skills.some(s => s.state !== 'absent');
|
|
@@ -263,13 +274,15 @@ function integrationLines(integration) {
|
|
|
263
274
|
if (integration === null)
|
|
264
275
|
return ['Skills and Block: could not be read.'];
|
|
265
276
|
const lines = integration.mode === 'always-on'
|
|
266
|
-
? ['Mode: always-on (a Block is installed).']
|
|
277
|
+
? [integration.blocks.length > 0 ? 'Mode: always-on (a Block is installed).' : 'Mode: always-on (legacy: a Hafez 1.x section with no markers).']
|
|
267
278
|
: [
|
|
268
279
|
'Mode: invoke-only (no Block in any instructions file Hafez writes); installing a Block is not offered.',
|
|
269
280
|
"A Block pasted into Cursor's User Rules lives in its Settings and is not checked here.",
|
|
270
281
|
];
|
|
271
282
|
for (const b of integration.blocks)
|
|
272
283
|
lines.push(`- Block in ${b.path}: ${BLOCK_STATE[b.state](b)}`);
|
|
284
|
+
for (const l of integration.legacy)
|
|
285
|
+
lines.push(`- ${legacyLine(l.path, l.line)}, from Hafez 1.x, with no markers`);
|
|
273
286
|
for (const s of integration.skills) {
|
|
274
287
|
const lost = s.state === 'hand-edited' ? ' — `onboard --install` replaces it, and the edit is lost' : '';
|
|
275
288
|
lines.push(`- ${s.path}: ${s.state}${lost}`);
|
package/dist/contracts.js
CHANGED
|
@@ -195,6 +195,8 @@ export const WRITE_LINE_OMITS = {
|
|
|
195
195
|
status: 'a lifecycle change, not a write-back: the `hafez` skill\'s rare ops carry the status line',
|
|
196
196
|
agent: 'detected from the Harness; the override is never shown',
|
|
197
197
|
add_action: 'the one-line form of add_actions, written by the same flag',
|
|
198
|
+
complete_actions: 'the repeated form of complete_action, written by the same flag',
|
|
199
|
+
remove_actions: 'the repeated form of remove_action, written by the same flag',
|
|
198
200
|
clear_actions: 'drops lines unseen; the Footer drops one line by its Handle',
|
|
199
201
|
description: SET_AT_CREATE,
|
|
200
202
|
resource: SET_AT_CREATE,
|
package/dist/surfaces/items.js
CHANGED
|
@@ -116,6 +116,11 @@ const setOrCleared = (field, name = field.replace(/_/g, ' ')) => f => {
|
|
|
116
116
|
return undefined;
|
|
117
117
|
return value === null || value === '' || (Array.isArray(value) && value.length === 0) ? `${name} cleared` : `${name} set`;
|
|
118
118
|
};
|
|
119
|
+
/** `<n> action(s) <verb>` over a Next Actions field's one-line and array forms. */
|
|
120
|
+
function countPhrase(one, many, verb) {
|
|
121
|
+
const n = (one !== undefined ? 1 : 0) + (many?.length ?? 0);
|
|
122
|
+
return n > 0 ? `${n} action${n === 1 ? '' : 's'} ${verb}` : undefined;
|
|
123
|
+
}
|
|
119
124
|
/** What each update field did, for the success line; `null` for a field that
|
|
120
125
|
* changes nothing of its own. Keyed by every update field, so a new field
|
|
121
126
|
* without a phrase fails the build. Key order is the line's order: the
|
|
@@ -123,12 +128,11 @@ const setOrCleared = (field, name = field.replace(/_/g, ' ')) => f => {
|
|
|
123
128
|
const CHANGE_PHRASES = {
|
|
124
129
|
session_log: (_f, r) => r.logged ? 'log added' : undefined,
|
|
125
130
|
clear_actions: f => f.clear_actions ? 'actions cleared' : undefined,
|
|
126
|
-
complete_action: f => f.complete_action
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
},
|
|
131
|
+
complete_action: f => countPhrase(f.complete_action, f.complete_actions, 'ticked'),
|
|
132
|
+
complete_actions: null, // counted with complete_action
|
|
133
|
+
remove_action: f => countPhrase(f.remove_action, f.remove_actions, 'removed'),
|
|
134
|
+
remove_actions: null, // counted with remove_action
|
|
135
|
+
add_actions: f => countPhrase(f.add_action, f.add_actions, 'added'),
|
|
132
136
|
add_action: null, // counted with add_actions
|
|
133
137
|
brief: setOrCleared('brief'),
|
|
134
138
|
current_state: f => f.current_state !== undefined ? 'current state set' : undefined,
|
package/dist/types.d.ts
CHANGED
|
@@ -141,7 +141,9 @@ export interface UpdateFields {
|
|
|
141
141
|
add_action?: string;
|
|
142
142
|
add_actions?: string[];
|
|
143
143
|
complete_action?: string;
|
|
144
|
+
complete_actions?: string[];
|
|
144
145
|
remove_action?: string;
|
|
146
|
+
remove_actions?: string[];
|
|
145
147
|
clear_actions?: boolean;
|
|
146
148
|
parent?: string;
|
|
147
149
|
description?: string | null;
|
|
@@ -158,6 +160,8 @@ export interface UpdateFields {
|
|
|
158
160
|
export interface UpdateResult {
|
|
159
161
|
completed_action?: string;
|
|
160
162
|
removed_action?: string;
|
|
163
|
+
completed_actions?: string[];
|
|
164
|
+
removed_actions?: string[];
|
|
161
165
|
logged?: true;
|
|
162
166
|
}
|
|
163
167
|
export interface CreateEntityFields {
|