@zuzuucodes/cli 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/zuzuu.mjs +8 -1
  2. package/package.json +1 -1
  3. package/web-app/dist/zuzuu-api.js +122 -27
  4. package/web-app/web-dist/assets/{DiffTab-BuWonUNJ.js → DiffTab-BpGp1akx.js} +1 -1
  5. package/web-app/web-dist/assets/{MonacoFile-CL3DhFKG.js → MonacoFile-CqbVacUZ.js} +1 -1
  6. package/web-app/web-dist/assets/{cssMode-B9jnrWOz.js → cssMode-Dx3ub8Pk.js} +1 -1
  7. package/web-app/web-dist/assets/{dist-ChcDQ_7s.js → dist-C6R6xoyX.js} +1 -1
  8. package/web-app/web-dist/assets/{htmlMode-Bi8vSvwb.js → htmlMode-DM6oHc7c.js} +1 -1
  9. package/web-app/web-dist/assets/{index--5yy8RbA.js → index-DHpC851f.js} +25 -24
  10. package/web-app/web-dist/assets/index-O-t1gyMG.css +2 -0
  11. package/web-app/web-dist/assets/{jsonMode-C6ELX5GM.js → jsonMode-DflaUwqW.js} +1 -1
  12. package/web-app/web-dist/assets/{monaco-setup-CsR6EfHe.js → monaco-setup-wbBeb0oN.js} +3 -3
  13. package/web-app/web-dist/assets/{tsMode-a8OvovQd.js → tsMode-DRwkDcoK.js} +1 -1
  14. package/web-app/web-dist/index.html +2 -2
  15. package/zuzuu/actions/adapter.mjs +12 -20
  16. package/zuzuu/actions/convert.mjs +10 -9
  17. package/zuzuu/actions/dispatch.mjs +12 -7
  18. package/zuzuu/actions/inbox.mjs +5 -5
  19. package/zuzuu/actions/manifest.mjs +48 -30
  20. package/zuzuu/actions/schema.mjs +9 -3
  21. package/zuzuu/commands/act-author.mjs +23 -13
  22. package/zuzuu/commands/act.mjs +3 -5
  23. package/zuzuu/commands/doctor.mjs +2 -15
  24. package/zuzuu/commands/explain.mjs +4 -4
  25. package/zuzuu/commands/faculty.mjs +75 -0
  26. package/zuzuu/commands/generation.mjs +2 -4
  27. package/zuzuu/commands/hook.mjs +7 -5
  28. package/zuzuu/commands/init.mjs +14 -1
  29. package/zuzuu/commands/migrate.mjs +348 -1
  30. package/zuzuu/digest.mjs +18 -13
  31. package/zuzuu/faculty/envelope.mjs +290 -0
  32. package/zuzuu/faculty/generation.mjs +53 -47
  33. package/zuzuu/faculty/items.mjs +75 -0
  34. package/zuzuu/guardrails/adapter.mjs +18 -49
  35. package/zuzuu/guardrails.mjs +72 -24
  36. package/zuzuu/instructions/adapter.mjs +30 -30
  37. package/zuzuu/knowledge/items.mjs +56 -91
  38. package/zuzuu/live/install.mjs +1 -1
  39. package/zuzuu/memory/adapter.mjs +27 -52
  40. package/zuzuu/miners/actions.mjs +14 -20
  41. package/zuzuu/miners/guardrails.mjs +8 -11
  42. package/zuzuu/miners/instructions.mjs +10 -10
  43. package/zuzuu/scaffold.mjs +99 -38
  44. package/web-app/web-dist/assets/index-BVG4hgk7.css +0 -2
@@ -3,17 +3,26 @@
3
3
  //
4
4
  // (default) proposal schema: legacy {candidate, er} → spine {payload, analysis, faculty} (WS2-T5)
5
5
  // --home faculty home: visible agent/ → hidden .zuzuu/ (W1, 2026-06-12)
6
+ // --items Faculty Standard (W24): legacy per-faculty shapes → one envelope —
7
+ // knowledge/memory frontmatter keys standardised, rules.json
8
+ // exploded into guardrails/items/, action.json+SKILL.md → ACTION.md,
9
+ // instructions/project.md → items/steering.md. Idempotent,
10
+ // fail-soft per item. Auto-runs from `zuzuu init` when old shapes
11
+ // are detected (like migrateHome).
6
12
  //
7
13
  // Pure cores: migrateProposals(agentDir) → { scanned, migrated, skipped }
8
14
  // migrateHome(root) → { migrated }
15
+ // migrateItems(agentDir) → { knowledge, memory, guardrails, actions, instructions, skipped, errors }
9
16
  // CLI surface: migrate(args) — resolves paths, runs the core, prints summary.
10
17
 
11
- import { existsSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
18
+ import { existsSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync, statSync, mkdirSync } from 'node:fs';
12
19
  import { join } from 'node:path';
13
20
  import { paths, repoRoot } from '../store.mjs';
14
21
  import { proposalsDir, archiveDir } from '../faculty/contract.mjs';
15
22
  import { ensureGitignore } from '../scaffold.mjs';
16
23
  import { injectBlock, BLOCK_VERSION } from '../inject.mjs';
24
+ import { serializeEnvelope, deriveTitle } from '../faculty/envelope.mjs';
25
+ import { serializeItem } from '../knowledge/items.mjs';
17
26
 
18
27
  // ---------------------------------------------------------------------------
19
28
  // pure core — testable without process.*
@@ -200,11 +209,349 @@ function reinjectHostBlocks(root) {
200
209
  }
201
210
  }
202
211
 
212
+ // ---------------------------------------------------------------------------
213
+ // items migration — the Faculty Standard (W24)
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /** Does this file's frontmatter already carry the envelope (a `faculty:` key)? */
217
+ function isEnvelopeText(text) {
218
+ const m = String(text).match(/^---\r?\n([\s\S]*?)\r?\n---/);
219
+ return !!m && /^faculty:/m.test(m[1]);
220
+ }
221
+
222
+ const unquoteLegacy = (s) => {
223
+ const t = String(s).trim();
224
+ return (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) ? t.slice(1, -1) : t;
225
+ };
226
+
227
+ /**
228
+ * Parse the PRE-standard knowledge/memory frontmatter grammar (top-level
229
+ * scalars; ONE nested map `attributes`/`provenance`; arrays of flat maps).
230
+ * Kept here (and only here) — the live parsers are envelope-only (clean break).
231
+ * Throws on violations; the caller fail-softs per item.
232
+ */
233
+ function parseLegacyFrontmatter(text) {
234
+ const m = String(text).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
235
+ if (!m) throw new Error('no frontmatter block');
236
+ const [, fm, body] = m;
237
+ const item = { scalars: {}, maps: {}, lists: {}, body: body.trim() };
238
+ let section = null; // current nested key
239
+ let mode = null; // 'map' | 'list'
240
+ let current = null;
241
+ for (const raw of fm.split('\n')) {
242
+ if (!raw.trim()) continue;
243
+ const indent = raw.match(/^ */)[0].length;
244
+ const line = raw.trim();
245
+ const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
246
+ if (indent === 0) {
247
+ current = null;
248
+ if (!kv) throw new Error(`bad line: ${line}`);
249
+ const [, key, val] = kv;
250
+ if (val === '') { section = key; mode = null; }
251
+ else { section = null; item.scalars[key] = unquoteLegacy(val); }
252
+ } else if (section) {
253
+ if (line.startsWith('- ')) {
254
+ mode = mode ?? 'list';
255
+ if (!item.lists[section]) item.lists[section] = [];
256
+ const ekv = line.slice(2).match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
257
+ if (ekv) { current = { [ekv[1]]: unquoteLegacy(ekv[2]) }; item.lists[section].push(current); }
258
+ else { current = null; item.lists[section].push(unquoteLegacy(line.slice(2))); }
259
+ } else if (current && kv) {
260
+ current[kv[1]] = unquoteLegacy(kv[2]);
261
+ } else if (kv) {
262
+ mode = mode ?? 'map';
263
+ if (!item.maps[section]) item.maps[section] = {};
264
+ item.maps[section][kv[1]] = unquoteLegacy(kv[2]);
265
+ } else {
266
+ throw new Error(`bad nested line: ${line}`);
267
+ }
268
+ } else {
269
+ throw new Error(`unexpected indented line: ${line}`);
270
+ }
271
+ }
272
+ return item;
273
+ }
274
+
275
+ /** Parse a legacy inline list: `[a, b]` or an already-array value. */
276
+ function legacyList(v) {
277
+ if (Array.isArray(v)) return v.map(String);
278
+ const t = String(v ?? '').trim();
279
+ if (t.startsWith('[') && t.endsWith(']')) {
280
+ return t.slice(1, -1).split(',').map((s) => unquoteLegacy(s)).filter(Boolean);
281
+ }
282
+ return t ? [t] : [];
283
+ }
284
+
285
+ /** knowledge/items/*.md: legacy keys → envelope (ids unchanged). */
286
+ function migrateKnowledgeItems(agentDir, out) {
287
+ const dir = join(agentDir, 'knowledge', 'items');
288
+ if (!existsSync(dir)) return;
289
+ for (const f of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
290
+ const path = join(dir, f);
291
+ try {
292
+ const text = readFileSync(path, 'utf8');
293
+ if (isEnvelopeText(text)) { out.skipped++; continue; }
294
+ const legacy = parseLegacyFrontmatter(text);
295
+ const item = {
296
+ id: legacy.scalars.id || f.replace(/\.md$/, ''),
297
+ type: legacy.scalars.type,
298
+ created_at: legacy.scalars.created_at,
299
+ updated_at: legacy.scalars.updated_at,
300
+ status: legacy.scalars.status ?? 'active',
301
+ attributes: legacy.maps.attributes ?? {},
302
+ relations: (legacy.lists.relations ?? []).filter((r) => typeof r === 'object'),
303
+ provenance: (legacy.lists.provenance ?? []).filter((r) => typeof r === 'object'),
304
+ body: legacy.body,
305
+ };
306
+ if (!item.type) throw new Error('item missing type');
307
+ writeFileSync(path, serializeItem(item)); // envelope via the knowledge wrapper
308
+ out.knowledge++;
309
+ } catch (e) {
310
+ out.errors.push({ file: `knowledge/items/${f}`, error: e.message });
311
+ }
312
+ }
313
+ }
314
+
315
+ /** memory/entries/*.md: legacy episode keys → envelope (kind: episode). */
316
+ function migrateMemoryEntries(agentDir, out) {
317
+ const dir = join(agentDir, 'memory', 'entries');
318
+ if (!existsSync(dir)) return;
319
+ for (const f of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
320
+ const path = join(dir, f);
321
+ try {
322
+ const text = readFileSync(path, 'utf8');
323
+ if (isEnvelopeText(text)) { out.skipped++; continue; }
324
+ const legacy = parseLegacyFrontmatter(text);
325
+ const id = legacy.scalars.id || f.replace(/\.md$/, '');
326
+ const payload = {};
327
+ const prov = legacy.maps.provenance ?? {};
328
+ const sessions = legacyList(prov.sessions ?? legacy.scalars.sessions ?? '');
329
+ const hosts = legacyList(prov.hosts ?? legacy.scalars.hosts ?? '');
330
+ const tags = legacyList(legacy.scalars.tags ?? '');
331
+ if (sessions.length) payload.sessions = sessions;
332
+ if (hosts.length) payload.hosts = hosts;
333
+ if (tags.length) payload.tags = tags;
334
+ writeFileSync(path, serializeEnvelope({
335
+ id,
336
+ faculty: 'memory',
337
+ kind: 'episode',
338
+ title: legacy.scalars.title ?? deriveTitle(legacy.body, id),
339
+ status: 'active', // curated/proposed lifecycles fold into active
340
+ created_at: legacy.scalars.date ?? legacy.scalars.created_at,
341
+ payload,
342
+ body: legacy.body,
343
+ }));
344
+ out.memory++;
345
+ } catch (e) {
346
+ out.errors.push({ file: `memory/entries/${f}`, error: e.message });
347
+ }
348
+ }
349
+ }
350
+
351
+ /** guardrails/rules.json: EXPLODE into items/<id>.md, then delete rules.json. */
352
+ function migrateGuardrails(agentDir, out) {
353
+ const rulesPath = join(agentDir, 'guardrails', 'rules.json');
354
+ if (!existsSync(rulesPath)) return;
355
+ let data;
356
+ try {
357
+ data = JSON.parse(readFileSync(rulesPath, 'utf8'));
358
+ } catch (e) {
359
+ out.errors.push({ file: 'guardrails/rules.json', error: e.message });
360
+ return; // unreadable → leave the file for the human, never destroy it
361
+ }
362
+ const rules = Array.isArray(data?.rules) ? data.rules : [];
363
+ const itemsDir = join(agentDir, 'guardrails', 'items');
364
+ let failed = 0;
365
+ for (let i = 0; i < rules.length; i++) {
366
+ const r = rules[i] ?? {};
367
+ try {
368
+ const id = String(r.id ?? `rule-${i}`);
369
+ mkdirSync(itemsDir, { recursive: true });
370
+ writeFileSync(join(itemsDir, `${id}.md`), serializeEnvelope({
371
+ id,
372
+ faculty: 'guardrails',
373
+ kind: 'rule',
374
+ title: deriveTitle(r.reason, id),
375
+ status: 'active',
376
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
377
+ payload: { action: r.action, tool: r.tool || '*', pattern: String(r.pattern ?? ''), reason: String(r.reason ?? '') },
378
+ body: '',
379
+ }));
380
+ out.guardrails++;
381
+ } catch (e) {
382
+ failed++;
383
+ out.errors.push({ file: `guardrails/rules.json#${r.id ?? i}`, error: e.message });
384
+ }
385
+ }
386
+ // rules.json goes away only when every rule landed as an item (fail-soft)
387
+ if (failed === 0) {
388
+ try { rmSync(rulesPath, { force: true }); } catch { /* fail-soft */ }
389
+ }
390
+ }
391
+
392
+ /** One action dir: action.json (+SKILL.md) → ACTION.md; legacy files removed on success. */
393
+ function migrateActionDir(dir, slug, out) {
394
+ const actionMd = join(dir, 'ACTION.md');
395
+ if (existsSync(actionMd)) { out.skipped++; return; }
396
+ const manPath = join(dir, 'action.json');
397
+ const skillPath = join(dir, 'SKILL.md');
398
+ if (!existsSync(manPath) && !existsSync(skillPath)) return; // not an action dir
399
+ try {
400
+ let man = {};
401
+ if (existsSync(manPath)) man = JSON.parse(readFileSync(manPath, 'utf8'));
402
+ let skillFm = {};
403
+ let skillBody = '';
404
+ if (existsSync(skillPath)) {
405
+ const text = readFileSync(skillPath, 'utf8');
406
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
407
+ if (m) {
408
+ for (const line of m[1].split('\n')) {
409
+ const kv = line.match(/^(\w+):\s*(.*)$/);
410
+ if (kv) skillFm[kv[1]] = kv[2].trim();
411
+ }
412
+ skillBody = m[2].trim();
413
+ } else {
414
+ skillBody = text.trim();
415
+ }
416
+ }
417
+ const isScript = existsSync(join(dir, 'run.mjs'));
418
+ const payload = {};
419
+ if (isScript) payload.exec = 'run.mjs';
420
+ // default_args survive as payload.args (flat scalars only — the envelope grammar)
421
+ const args = {};
422
+ for (const [k, v] of Object.entries(man.default_args ?? {})) {
423
+ if (v == null || typeof v === 'object') continue;
424
+ args[k] = String(v);
425
+ }
426
+ if (Object.keys(args).length) payload.args = args;
427
+ const snippet = man.promptSnippet ?? man.description ?? skillFm.description ?? slug;
428
+ const bodyParts = [snippet];
429
+ if (man.description && man.description !== snippet) bodyParts.push('', man.description);
430
+ if (skillBody) bodyParts.push('', skillBody);
431
+ writeFileSync(actionMd, serializeEnvelope({
432
+ id: slug,
433
+ faculty: 'actions',
434
+ kind: isScript ? 'script' : 'runbook',
435
+ title: man.title ?? skillFm.name ?? slug,
436
+ status: 'active',
437
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
438
+ payload,
439
+ body: bodyParts.join('\n'),
440
+ }));
441
+ // legacy files leave only after ACTION.md landed
442
+ rmSync(manPath, { force: true });
443
+ rmSync(skillPath, { force: true });
444
+ out.actions++;
445
+ } catch (e) {
446
+ out.errors.push({ file: `actions/${slug}`, error: e.message });
447
+ }
448
+ }
449
+
450
+ /** All action dirs: active + inbox (proposed) — same conversion. */
451
+ function migrateActions(agentDir, out) {
452
+ for (const base of [join(agentDir, 'actions'), join(agentDir, 'actions', 'inbox')]) {
453
+ if (!existsSync(base)) continue;
454
+ for (const name of readdirSync(base)) {
455
+ if (name === 'inbox' || name === 'proposals' || name === '_rolledback') continue;
456
+ const dir = join(base, name);
457
+ let isDir = false;
458
+ try { isDir = statSync(dir).isDirectory(); } catch { continue; }
459
+ if (isDir) migrateActionDir(dir, name, out);
460
+ }
461
+ }
462
+ }
463
+
464
+ /** instructions/project.md → items/steering.md. A customized steering item is
465
+ * never clobbered — project.md then stays put for the human to reconcile. */
466
+ function migrateInstructions(agentDir, out) {
467
+ const projPath = join(agentDir, 'instructions', 'project.md');
468
+ if (!existsSync(projPath)) return;
469
+ const steeringPath = join(agentDir, 'instructions', 'items', 'steering.md');
470
+ try {
471
+ const existing = existsSync(steeringPath) ? readFileSync(steeringPath, 'utf8') : null;
472
+ const placeholder = existing != null && existing.includes('<!-- Fill in:');
473
+ if (existing != null && !placeholder) {
474
+ out.errors.push({ file: 'instructions/project.md', error: 'a customized steering item already exists — merge by hand, then delete project.md' });
475
+ return;
476
+ }
477
+ const body = readFileSync(projPath, 'utf8').trim();
478
+ mkdirSync(join(agentDir, 'instructions', 'items'), { recursive: true });
479
+ writeFileSync(steeringPath, serializeEnvelope({
480
+ id: 'steering',
481
+ faculty: 'instructions',
482
+ kind: 'steering',
483
+ title: 'Project steering',
484
+ status: 'active',
485
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
486
+ payload: { scope: 'project' },
487
+ body,
488
+ }));
489
+ out.instructions++;
490
+ rmSync(projPath, { force: true });
491
+ } catch (e) {
492
+ out.errors.push({ file: 'instructions/project.md', error: e.message });
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Are pre-standard shapes present? Cheap checks gate the init auto-run.
498
+ */
499
+ export function needsItemsMigration(agentDir) {
500
+ if (existsSync(join(agentDir, 'guardrails', 'rules.json'))) return true;
501
+ if (existsSync(join(agentDir, 'instructions', 'project.md'))) return true;
502
+ for (const base of [join(agentDir, 'actions'), join(agentDir, 'actions', 'inbox')]) {
503
+ if (!existsSync(base)) continue;
504
+ for (const name of readdirSync(base)) {
505
+ if (name === 'inbox' || name === 'proposals' || name === '_rolledback') continue;
506
+ const dir = join(base, name);
507
+ try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
508
+ if (existsSync(join(dir, 'ACTION.md'))) continue;
509
+ if (existsSync(join(dir, 'action.json')) || existsSync(join(dir, 'SKILL.md'))) return true;
510
+ }
511
+ }
512
+ for (const seg of [['knowledge', 'items'], ['memory', 'entries']]) {
513
+ const dir = join(agentDir, ...seg);
514
+ if (!existsSync(dir)) continue;
515
+ for (const f of readdirSync(dir).filter((f) => f.endsWith('.md'))) {
516
+ try {
517
+ if (!isEnvelopeText(readFileSync(join(dir, f), 'utf8'))) return true;
518
+ } catch { /* unreadable file never forces a migration */ }
519
+ }
520
+ }
521
+ return false;
522
+ }
523
+
524
+ /**
525
+ * One-shot Faculty Standard migration for a home. Idempotent (already-envelope
526
+ * files are skipped) and fail-soft per item (an unconvertible item is reported,
527
+ * never fatal; its legacy source is left in place).
528
+ * @returns {{knowledge:number, memory:number, guardrails:number, actions:number,
529
+ * instructions:number, skipped:number, errors:Array<{file,error}>}}
530
+ */
531
+ export function migrateItems(agentDir) {
532
+ const out = { knowledge: 0, memory: 0, guardrails: 0, actions: 0, instructions: 0, skipped: 0, errors: [] };
533
+ migrateKnowledgeItems(agentDir, out);
534
+ migrateMemoryEntries(agentDir, out);
535
+ migrateGuardrails(agentDir, out);
536
+ migrateActions(agentDir, out);
537
+ migrateInstructions(agentDir, out);
538
+ return out;
539
+ }
540
+
203
541
  // ---------------------------------------------------------------------------
204
542
  // CLI surface
205
543
  // ---------------------------------------------------------------------------
206
544
 
207
545
  export function migrate(args = {}) {
546
+ if (args.items) {
547
+ const agentDir = paths().dir;
548
+ const r = migrateItems(agentDir);
549
+ const total = r.knowledge + r.memory + r.guardrails + r.actions + r.instructions;
550
+ console.log(`migrate --items: ${total} item(s) → the Faculty Standard envelope — knowledge ${r.knowledge} · memory ${r.memory} · guardrails ${r.guardrails} · actions ${r.actions} · instructions ${r.instructions} (${r.skipped} already standard)`);
551
+ for (const e of r.errors) console.log(` ✗ ${e.file}: ${e.error}`);
552
+ if (!total && !r.errors.length) console.log(' nothing to migrate (the home already speaks the envelope)');
553
+ return;
554
+ }
208
555
  if (args.home) {
209
556
  const root = repoRoot(process.cwd());
210
557
  const { migrated } = migrateHome(root);
package/zuzuu/digest.mjs CHANGED
@@ -4,31 +4,36 @@
4
4
  // I/O-free: callers (the CLI + the SessionStart hook) handle output. Every
5
5
  // reader is wrapped so a single broken faculty never sinks the whole digest.
6
6
 
7
- import { readFileSync } from 'node:fs';
8
7
  import { join } from 'node:path';
9
8
  import { allItems } from './knowledge/items.mjs';
10
9
  import { listProposals } from './knowledge/proposals.mjs';
11
10
  import { loadRules } from './guardrails.mjs';
12
11
  import { allActions } from './actions/manifest.mjs';
12
+ import { listFacultyItems } from './faculty/items.mjs';
13
13
 
14
14
  const PLACEHOLDER_MARK = '<!-- Fill in:';
15
15
 
16
- /** Read instructions/project.md; classify empty vs steering text. */
16
+ /** Read the instructions items (steering first, then amendments); classify
17
+ * empty vs steering text. Items are Faculty Standard envelopes (W24). */
17
18
  function readInstructions(agentDir) {
18
- const path = join(agentDir, 'instructions', 'project.md');
19
- let raw = '';
19
+ let items = [];
20
20
  try {
21
- raw = readFileSync(path, 'utf8');
21
+ items = listFacultyItems(agentDir, 'instructions').items;
22
22
  } catch { /* missing or unreadable → treat as empty */ }
23
- const stripped = raw.replace(/^#.*$/gm, '').trim();
24
- const empty = !stripped || raw.includes(PLACEHOLDER_MARK);
25
- return { empty, text: empty ? '' : raw.trim() };
23
+ // steering pins the top; amendments follow in id order (already sorted)
24
+ items.sort((a, b) => (a.kind === 'steering' ? -1 : 1) - (b.kind === 'steering' ? -1 : 1));
25
+ const bodies = items
26
+ .map((i) => String(i.body ?? ''))
27
+ .map((raw) => (raw.includes(PLACEHOLDER_MARK) ? '' : raw.replace(/^#.*$/gm, '').trim() && raw.trim()))
28
+ .filter(Boolean);
29
+ const text = bodies.join('\n\n');
30
+ return { empty: !text, text };
26
31
  }
27
32
 
28
33
  const INTERVIEW = [
29
34
  'Project steering is empty. Before substantive work, interview your human',
30
- '(what is this project, its conventions, its priorities), draft',
31
- '.zuzuu/instructions/project.md from their answers, and get their approval.',
35
+ '(what is this project, its conventions, its priorities), draft the steering item',
36
+ '.zuzuu/instructions/items/steering.md from their answers, and get their approval.',
32
37
  ].join(' ');
33
38
 
34
39
  function knowledgeSection(agentDir, limit) {
@@ -64,10 +69,10 @@ function actionsSection(agentDir, limit) {
64
69
 
65
70
  function guardrailsSection(agentDir) {
66
71
  try {
67
- const loaded = loadRules(join(agentDir, 'guardrails', 'rules.json'));
68
- return { ok: loaded.ok, count: loaded.ok ? loaded.rules.length : 0 };
72
+ const loaded = loadRules(join(agentDir, 'guardrails'));
73
+ return { ok: loaded.ok, count: loaded.ok ? loaded.rules.length : 0, skipped: loaded.skipped?.length ?? 0 };
69
74
  } catch {
70
- return { ok: false, count: 0 };
75
+ return { ok: false, count: 0, skipped: 0 };
71
76
  }
72
77
  }
73
78