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 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 removed = fields.remove_action !== undefined ? doc.matchNextAction(body, fields.remove_action) : undefined;
214
- const completed = fields.complete_action !== undefined ? doc.matchNextAction(body, fields.complete_action) : undefined;
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
- if (removed && completed && removed.line === completed.line) {
219
- throw new HafezError('VALIDATION_FAILED', `remove_action and complete_action both match "${removed.text}" — one line, one field`);
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: removedLine, ticked: [...overflow.lines, ...pruned] },
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
- if (archived.removed !== undefined) {
321
- entries.push(formatSessionLogEntry({ type: 'progress', agent: 'system', summary: 'Removed action', body: archived.removed }));
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) {
@@ -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
@@ -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': 'string', 'remove-action': 'string',
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
@@ -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. */
@@ -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
- block = hashState(findRegion(readInstalled(join(home, rel)) ?? '', `~/${rel}`), RENDERED.text);
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
- if (instructions === null) {
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: instructionsFile(home, instructions),
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
- return run({ header: `${label}, ${mode}:`, steps, tail, unchanged: 'Nothing changed: the install is current.', listAll: true });
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. */
@@ -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
  }
@@ -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;
@@ -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,
@@ -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 !== undefined ? '1 action ticked' : undefined,
127
- remove_action: f => f.remove_action !== undefined ? '1 action removed' : undefined,
128
- add_actions: f => {
129
- const n = (f.add_action !== undefined ? 1 : 0) + (f.add_actions?.length ?? 0);
130
- return n > 0 ? `${n} action${n === 1 ? '' : 's'} added` : undefined;
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hafez",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",