@karmaniverous/jeeves-meta 0.17.0 → 0.18.0-1

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 (93) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/jeeves-meta/index.js +1235 -1198
  3. package/dist/prompts/architect.md +31 -59
  4. package/dist/prompts/critic.md +18 -33
  5. package/package.json +18 -22
  6. package/dist/archive/index.d.ts +0 -10
  7. package/dist/archive/listArchive.d.ts +0 -12
  8. package/dist/archive/prune.d.ts +0 -14
  9. package/dist/archive/readArchive.d.ts +0 -30
  10. package/dist/archive/readLatest.d.ts +0 -13
  11. package/dist/archive/snapshot.d.ts +0 -17
  12. package/dist/bootstrap.d.ts +0 -26
  13. package/dist/cache.d.ts +0 -22
  14. package/dist/cli/jeeves-meta/architect.md +0 -173
  15. package/dist/cli/jeeves-meta/critic.md +0 -104
  16. package/dist/cli.d.ts +0 -10
  17. package/dist/configHotReload.d.ts +0 -30
  18. package/dist/configLoader.d.ts +0 -36
  19. package/dist/constants.d.ts +0 -13
  20. package/dist/customCliCommands.d.ts +0 -13
  21. package/dist/descriptor.d.ts +0 -19
  22. package/dist/discovery/buildMinimalNode.d.ts +0 -22
  23. package/dist/discovery/computeSummary.d.ts +0 -17
  24. package/dist/discovery/discoverMetas.d.ts +0 -19
  25. package/dist/discovery/getAncestorMeta.d.ts +0 -16
  26. package/dist/discovery/index.d.ts +0 -12
  27. package/dist/discovery/listMetas.d.ts +0 -63
  28. package/dist/discovery/ownershipTree.d.ts +0 -25
  29. package/dist/discovery/scope.d.ts +0 -47
  30. package/dist/discovery/types.d.ts +0 -25
  31. package/dist/ema.d.ts +0 -14
  32. package/dist/errors.d.ts +0 -15
  33. package/dist/escapeGlob.d.ts +0 -23
  34. package/dist/executor/GatewayExecutor.d.ts +0 -79
  35. package/dist/executor/SpawnAbortedError.d.ts +0 -9
  36. package/dist/executor/SpawnTimeoutError.d.ts +0 -13
  37. package/dist/executor/index.d.ts +0 -8
  38. package/dist/index.d.ts +0 -35
  39. package/dist/index.js +0 -5797
  40. package/dist/interfaces/MetaContext.d.ts +0 -38
  41. package/dist/interfaces/MetaExecutor.d.ts +0 -44
  42. package/dist/interfaces/WatcherClient.d.ts +0 -75
  43. package/dist/interfaces/index.d.ts +0 -8
  44. package/dist/lock.d.ts +0 -70
  45. package/dist/logger/index.d.ts +0 -27
  46. package/dist/mtimeFilter.d.ts +0 -26
  47. package/dist/normalizePath.d.ts +0 -6
  48. package/dist/orchestrator/buildTask.d.ts +0 -38
  49. package/dist/orchestrator/contextPackage.d.ts +0 -30
  50. package/dist/orchestrator/index.d.ts +0 -10
  51. package/dist/orchestrator/orchestratePhase.d.ts +0 -44
  52. package/dist/orchestrator/parseOutput.d.ts +0 -42
  53. package/dist/orchestrator/runPhase.d.ts +0 -40
  54. package/dist/phaseState/derivePhaseState.d.ts +0 -39
  55. package/dist/phaseState/index.d.ts +0 -9
  56. package/dist/phaseState/invalidate.d.ts +0 -39
  57. package/dist/phaseState/phaseScheduler.d.ts +0 -76
  58. package/dist/phaseState/phaseTransitions.d.ts +0 -83
  59. package/dist/progress/index.d.ts +0 -73
  60. package/dist/prompts/index.d.ts +0 -12
  61. package/dist/queue/index.d.ts +0 -90
  62. package/dist/readMetaJson.d.ts +0 -17
  63. package/dist/routes/__testUtils.d.ts +0 -37
  64. package/dist/routes/config.d.ts +0 -11
  65. package/dist/routes/configApply.d.ts +0 -13
  66. package/dist/routes/index.d.ts +0 -50
  67. package/dist/routes/metas.d.ts +0 -9
  68. package/dist/routes/metasUpdate.d.ts +0 -11
  69. package/dist/routes/preview.d.ts +0 -8
  70. package/dist/routes/queue.d.ts +0 -13
  71. package/dist/routes/seed.d.ts +0 -8
  72. package/dist/routes/status.d.ts +0 -13
  73. package/dist/routes/synthesize.d.ts +0 -12
  74. package/dist/routes/unlock.d.ts +0 -8
  75. package/dist/rules/healthCheck.d.ts +0 -36
  76. package/dist/rules/index.d.ts +0 -37
  77. package/dist/rules/verify.d.ts +0 -22
  78. package/dist/scheduler/index.d.ts +0 -71
  79. package/dist/scheduling/index.d.ts +0 -7
  80. package/dist/scheduling/staleness.d.ts +0 -68
  81. package/dist/scheduling/weightedFormula.d.ts +0 -38
  82. package/dist/schema/config.d.ts +0 -67
  83. package/dist/schema/error.d.ts +0 -6
  84. package/dist/schema/index.d.ts +0 -8
  85. package/dist/schema/meta.d.ts +0 -72
  86. package/dist/seed/autoSeed.d.ts +0 -30
  87. package/dist/seed/createMeta.d.ts +0 -38
  88. package/dist/seed/index.d.ts +0 -7
  89. package/dist/server.d.ts +0 -24
  90. package/dist/shutdown/index.d.ts +0 -33
  91. package/dist/structureHash.d.ts +0 -15
  92. package/dist/watcher-client/HttpWatcherClient.d.ts +0 -38
  93. package/dist/watcher-client/index.d.ts +0 -6
@@ -2,125 +2,18 @@
2
2
  import { sleepAsync, fetchWithTimeout, createConfigQueryHandler, atomicWrite, createStatusHandler, getBindAddress, fetchJson, postJson, getServiceUrl, jeevesComponentDescriptorSchema, createServiceCli } from '@karmaniverous/jeeves';
3
3
  import { normalizePath, metaConfigSchema, getEndpoint, META_COMPONENT } from '@karmaniverous/jeeves-meta-core';
4
4
  import fs, { unlinkSync, writeFileSync, existsSync, readFileSync, statSync, mkdirSync, readdirSync, watchFile } from 'node:fs';
5
- import path, { join, dirname, relative, posix, resolve } from 'node:path';
5
+ import path, { join, dirname, relative, posix, sep, resolve } from 'node:path';
6
6
  import { readFile, unlink, mkdir, writeFile, copyFile } from 'node:fs/promises';
7
7
  import { z } from 'zod';
8
8
  import { randomUUID, createHash } from 'node:crypto';
9
9
  import { tmpdir } from 'node:os';
10
10
  import pino from 'pino';
11
- import Handlebars from 'handlebars';
12
11
  import { fileURLToPath } from 'node:url';
13
12
  import process$1 from 'node:process';
13
+ import Handlebars from 'handlebars';
14
14
  import { Cron } from 'croner';
15
15
  import Fastify from 'fastify';
16
16
 
17
- /**
18
- * Compute summary statistics from an array of MetaEntry objects.
19
- *
20
- * Shared between listMetas() (full list) and route handlers (filtered lists).
21
- *
22
- * @module discovery/computeSummary
23
- */
24
- /**
25
- * Compute summary statistics from a list of meta entries.
26
- *
27
- * @param entries - Enriched meta entries (full or filtered).
28
- * @param depthWeight - Config depth weight for effective staleness calculation.
29
- * @returns Aggregated summary statistics.
30
- */
31
- function computeSummary(entries, depthWeight) {
32
- let staleCount = 0;
33
- let errorCount = 0;
34
- let lockedCount = 0;
35
- let disabledCount = 0;
36
- let neverSynthesizedCount = 0;
37
- let totalArchitectTokens = 0;
38
- let totalBuilderTokens = 0;
39
- let totalCriticTokens = 0;
40
- let stalestPath = null;
41
- let stalestEffective = -1;
42
- let lastSynthesizedPath = null;
43
- let lastSynthesizedAt = null;
44
- for (const e of entries) {
45
- if (e.stalenessSeconds > 0)
46
- staleCount++;
47
- if (e.hasError)
48
- errorCount++;
49
- if (e.locked)
50
- lockedCount++;
51
- if (e.disabled)
52
- disabledCount++;
53
- if (e.lastSynthesized === null)
54
- neverSynthesizedCount++;
55
- totalArchitectTokens += e.architectTokens ?? 0;
56
- totalBuilderTokens += e.builderTokens ?? 0;
57
- totalCriticTokens += e.criticTokens ?? 0;
58
- // Track last synthesized
59
- if (e.lastSynthesized &&
60
- (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
61
- lastSynthesizedAt = e.lastSynthesized;
62
- lastSynthesizedPath = e.path;
63
- }
64
- // Track stalest (effective staleness for scheduling)
65
- const depthFactor = Math.pow(1 + depthWeight, e.depth);
66
- const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
67
- if (effectiveStaleness > stalestEffective) {
68
- stalestEffective = effectiveStaleness;
69
- stalestPath = e.path;
70
- }
71
- }
72
- return {
73
- total: entries.length,
74
- stale: staleCount,
75
- errors: errorCount,
76
- locked: lockedCount,
77
- disabled: disabledCount,
78
- neverSynthesized: neverSynthesizedCount,
79
- tokens: {
80
- architect: totalArchitectTokens,
81
- builder: totalBuilderTokens,
82
- critic: totalCriticTokens,
83
- },
84
- stalestPath,
85
- lastSynthesizedPath,
86
- lastSynthesizedAt,
87
- };
88
- }
89
-
90
- /**
91
- * Discover .meta/ directories via watcher `/walk` endpoint.
92
- *
93
- * Uses filesystem enumeration through the watcher (not Qdrant) to find
94
- * all `.meta/meta.json` files and returns deduplicated meta directory paths.
95
- *
96
- * @module discovery/discoverMetas
97
- */
98
- /**
99
- * Discover all .meta/ directories via watcher walk.
100
- *
101
- * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
102
- * and returns deduplicated meta directory paths.
103
- *
104
- * @param watcher - WatcherClient for walk queries.
105
- * @returns Array of normalized .meta/ directory paths.
106
- */
107
- async function discoverMetas(watcher) {
108
- const allPaths = await watcher.walk(['**/.meta/meta.json']);
109
- // Deduplicate by .meta/ directory path (handles multi-chunk files)
110
- const seen = new Set();
111
- const metaPaths = [];
112
- for (const filePath of allPaths) {
113
- const fp = normalizePath(filePath);
114
- // Derive .meta/ directory from file_path (strip /meta.json)
115
- const metaPath = fp.replace(/\/meta\.json$/, '');
116
- if (seen.has(metaPath))
117
- continue;
118
- seen.add(metaPath);
119
- metaPaths.push(metaPath);
120
- }
121
- return metaPaths;
122
- }
123
-
124
17
  /**
125
18
  * Retrieve the nearest ancestor meta node from the ownership tree.
126
19
  *
@@ -426,6 +319,113 @@ function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
426
319
  return Math.min(1, (stalenessSeconds * depthFactor * emphasis) / (30 * 86400));
427
320
  }
428
321
 
322
+ /**
323
+ * Compute summary statistics from an array of MetaEntry objects.
324
+ *
325
+ * Shared between listMetas() (full list) and route handlers (filtered lists).
326
+ *
327
+ * @module discovery/computeSummary
328
+ */
329
+ /**
330
+ * Compute summary statistics from a list of meta entries.
331
+ *
332
+ * @param entries - Enriched meta entries (full or filtered).
333
+ * @param depthWeight - Config depth weight for effective staleness calculation.
334
+ * @returns Aggregated summary statistics.
335
+ */
336
+ function computeSummary(entries, depthWeight) {
337
+ let staleCount = 0;
338
+ let errorCount = 0;
339
+ let lockedCount = 0;
340
+ let disabledCount = 0;
341
+ let neverSynthesizedCount = 0;
342
+ let totalArchitectTokens = 0;
343
+ let totalBuilderTokens = 0;
344
+ let totalCriticTokens = 0;
345
+ let stalestPath = null;
346
+ let stalestEffective = -1;
347
+ let lastSynthesizedPath = null;
348
+ let lastSynthesizedAt = null;
349
+ for (const e of entries) {
350
+ if (e.stalenessSeconds > 0)
351
+ staleCount++;
352
+ if (e.hasError)
353
+ errorCount++;
354
+ if (e.locked)
355
+ lockedCount++;
356
+ if (e.disabled)
357
+ disabledCount++;
358
+ if (e.lastSynthesized === null)
359
+ neverSynthesizedCount++;
360
+ totalArchitectTokens += e.architectTokens ?? 0;
361
+ totalBuilderTokens += e.builderTokens ?? 0;
362
+ totalCriticTokens += e.criticTokens ?? 0;
363
+ // Track last synthesized
364
+ if (e.lastSynthesized &&
365
+ (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
366
+ lastSynthesizedAt = e.lastSynthesized;
367
+ lastSynthesizedPath = e.path;
368
+ }
369
+ // Track stalest (effective staleness for scheduling)
370
+ const depthFactor = Math.pow(1 + depthWeight, e.depth);
371
+ const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
372
+ if (effectiveStaleness > stalestEffective) {
373
+ stalestEffective = effectiveStaleness;
374
+ stalestPath = e.path;
375
+ }
376
+ }
377
+ return {
378
+ total: entries.length,
379
+ stale: staleCount,
380
+ errors: errorCount,
381
+ locked: lockedCount,
382
+ disabled: disabledCount,
383
+ neverSynthesized: neverSynthesizedCount,
384
+ tokens: {
385
+ architect: totalArchitectTokens,
386
+ builder: totalBuilderTokens,
387
+ critic: totalCriticTokens,
388
+ },
389
+ stalestPath,
390
+ lastSynthesizedPath,
391
+ lastSynthesizedAt,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Discover .meta/ directories via watcher `/walk` endpoint.
397
+ *
398
+ * Uses filesystem enumeration through the watcher (not Qdrant) to find
399
+ * all `.meta/meta.json` files and returns deduplicated meta directory paths.
400
+ *
401
+ * @module discovery/discoverMetas
402
+ */
403
+ /**
404
+ * Discover all .meta/ directories via watcher walk.
405
+ *
406
+ * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
407
+ * and returns deduplicated meta directory paths.
408
+ *
409
+ * @param watcher - WatcherClient for walk queries.
410
+ * @returns Array of normalized .meta/ directory paths.
411
+ */
412
+ async function discoverMetas(watcher) {
413
+ const allPaths = await watcher.walk(['**/.meta/meta.json']);
414
+ // Deduplicate by .meta/ directory path (handles multi-chunk files)
415
+ const seen = new Set();
416
+ const metaPaths = [];
417
+ for (const filePath of allPaths) {
418
+ const fp = normalizePath(filePath);
419
+ // Derive .meta/ directory from file_path (strip /meta.json)
420
+ const metaPath = fp.replace(/\/meta\.json$/, '');
421
+ if (seen.has(metaPath))
422
+ continue;
423
+ seen.add(metaPath);
424
+ metaPaths.push(metaPath);
425
+ }
426
+ return metaPaths;
427
+ }
428
+
429
429
  /**
430
430
  * Build the ownership tree from discovered .meta/ paths.
431
431
  *
@@ -1251,1284 +1251,1233 @@ function createLogger(config) {
1251
1251
  return pino({ level });
1252
1252
  }
1253
1253
 
1254
- const toPath = urlOrPath => urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath;
1255
-
1256
- function findUpSync(name, {
1257
- cwd = process$1.cwd(),
1258
- type = 'file',
1259
- stopAt,
1260
- } = {}) {
1261
- let directory = path.resolve(toPath(cwd) ?? '');
1262
- const {root} = path.parse(directory);
1263
- stopAt = path.resolve(directory, toPath(stopAt) ?? root);
1264
- const isAbsoluteName = path.isAbsolute(name);
1265
-
1266
- while (directory) {
1267
- const filePath = isAbsoluteName ? name : path.join(directory, name);
1268
-
1269
- try {
1270
- const stats = fs.statSync(filePath, {throwIfNoEntry: false});
1271
- if ((type === 'file' && stats?.isFile()) || (type === 'directory' && stats?.isDirectory())) {
1272
- return filePath;
1273
- }
1274
- } catch {}
1275
-
1276
- if (directory === stopAt || directory === root) {
1277
- break;
1278
- }
1279
-
1280
- directory = path.dirname(directory);
1281
- }
1282
- }
1283
-
1284
- const isTypeOnlyPackageJsonData = packageData => {
1285
- if (!packageData || typeof packageData !== 'object' || Array.isArray(packageData)) {
1286
- return false;
1287
- }
1288
-
1289
- const keys = Object.keys(packageData);
1290
- return keys.length === 1 && keys[0] === 'type' && typeof packageData.type === 'string';
1291
- };
1292
-
1293
- const isTypeOnlyPackageJsonSync = filePath => {
1294
- let fileContents;
1295
-
1296
- try {
1297
- fileContents = fs.readFileSync(filePath, 'utf8');
1298
- } catch {
1299
- return false;
1300
- }
1301
-
1302
- try {
1303
- return isTypeOnlyPackageJsonData(JSON.parse(fileContents));
1304
- } catch {
1305
- return false;
1306
- }
1307
- };
1308
-
1309
- const getNextSearchDirectory = filePath => {
1310
- const packageDirectoryPath = path.dirname(filePath);
1311
- const parentDirectoryPath = path.dirname(packageDirectoryPath);
1312
- return parentDirectoryPath === packageDirectoryPath ? undefined : parentDirectoryPath;
1313
- };
1314
-
1315
- const findPackageDirectorySync = (directory, ignoreTypeOnlyPackageJson) => {
1316
- const filePath = findUpSync('package.json', {cwd: directory});
1317
- if (!filePath) {
1318
- return undefined;
1319
- }
1320
-
1321
- const packageDirectoryPath = path.dirname(filePath);
1322
- if (!ignoreTypeOnlyPackageJson) {
1323
- return packageDirectoryPath;
1324
- }
1325
-
1326
- if (!isTypeOnlyPackageJsonSync(filePath)) {
1327
- return packageDirectoryPath;
1328
- }
1329
-
1330
- const nextDirectory = getNextSearchDirectory(filePath);
1331
- if (!nextDirectory) {
1332
- return undefined;
1333
- }
1334
-
1335
- return findPackageDirectorySync(nextDirectory, ignoreTypeOnlyPackageJson);
1336
- };
1337
-
1338
- function packageDirectorySync({cwd, ignoreTypeOnlyPackageJson} = {}) {
1339
- return findPackageDirectorySync(cwd ?? process$1.cwd(), ignoreTypeOnlyPackageJson);
1340
- }
1341
-
1342
1254
  /**
1343
- * Built-in default prompts for the synthesis pipeline.
1255
+ * Build a minimal MetaNode from a known meta path using watcher walk.
1344
1256
  *
1345
- * Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
1346
- * Loaded at runtime relative to the compiled module location.
1257
+ * Used for targeted synthesis (when a specific path is requested) to avoid
1258
+ * the full discovery + ownership tree build. Discovers only immediate child
1259
+ * `.meta/` directories.
1347
1260
  *
1348
- * @module prompts
1261
+ * @module discovery/buildMinimalNode
1349
1262
  */
1350
- const packageRoot = packageDirectorySync({
1351
- cwd: fileURLToPath(import.meta.url),
1352
- });
1353
- const promptDir = join(packageRoot, 'dist', 'prompts');
1354
- /** Built-in default architect prompt. */
1355
- const DEFAULT_ARCHITECT_PROMPT = readFileSync(join(promptDir, 'architect.md'), 'utf8');
1356
- /** Built-in default critic prompt. */
1357
- const DEFAULT_CRITIC_PROMPT = readFileSync(join(promptDir, 'critic.md'), 'utf8');
1358
-
1359
1263
  /**
1360
- * List archive snapshot files in chronological order.
1264
+ * Build a minimal MetaNode for a known meta path.
1361
1265
  *
1362
- * @module archive/listArchive
1363
- */
1364
- /**
1365
- * List archive .json files sorted chronologically (oldest first).
1266
+ * Walks the owner directory for child `.meta/meta.json` files and constructs
1267
+ * a shallow ownership tree (self + direct children only).
1366
1268
  *
1367
- * @param metaPath - Absolute path to the .meta directory.
1368
- * @returns Array of absolute paths to archive files, or empty if none.
1269
+ * @param metaPath - Absolute path to the `.meta/` directory.
1270
+ * @param watcher - WatcherClient for filesystem enumeration.
1271
+ * @returns MetaNode with direct children wired.
1369
1272
  */
1370
- function listArchiveFiles(metaPath) {
1371
- const archiveDir = join(metaPath, 'archive');
1372
- try {
1373
- return readdirSync(archiveDir)
1374
- .filter((f) => f.endsWith('.json'))
1375
- .sort()
1376
- .map((f) => join(archiveDir, f));
1273
+ async function buildMinimalNode(metaPath, watcher) {
1274
+ const normalized = normalizePath(metaPath);
1275
+ const ownerPath = posix.dirname(normalized);
1276
+ // Find child metas using watcher walk.
1277
+ // We include only *direct* children (nearest descendants in the ownership tree)
1278
+ // to match the ownership semantics used elsewhere.
1279
+ const rawMetaJsonPaths = await watcher.walk([
1280
+ `${escapeGlob(ownerPath)}/**/.meta/meta.json`,
1281
+ ]);
1282
+ const candidateMetaPaths = [
1283
+ ...new Set(rawMetaJsonPaths.map((p) => posix.dirname(normalizePath(p)))),
1284
+ ].filter((p) => p !== normalized);
1285
+ const candidates = candidateMetaPaths
1286
+ .map((mp) => ({ metaPath: mp, ownerPath: posix.dirname(mp) }))
1287
+ .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
1288
+ const directChildren = [];
1289
+ for (const c of candidates) {
1290
+ const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
1291
+ c.ownerPath.startsWith(d.ownerPath + '/'));
1292
+ if (!nestedUnderExisting)
1293
+ directChildren.push(c);
1377
1294
  }
1378
- catch {
1379
- return [];
1295
+ const children = directChildren.map((c) => ({
1296
+ metaPath: c.metaPath,
1297
+ ownerPath: c.ownerPath,
1298
+ treeDepth: 1,
1299
+ children: [],
1300
+ parent: null,
1301
+ }));
1302
+ const node = {
1303
+ metaPath: normalized,
1304
+ ownerPath,
1305
+ treeDepth: 0,
1306
+ children,
1307
+ parent: null,
1308
+ };
1309
+ for (const child of children) {
1310
+ child.parent = node;
1380
1311
  }
1312
+ return node;
1381
1313
  }
1382
1314
 
1383
1315
  /**
1384
- * Prune old archive snapshots beyond maxArchive.
1316
+ * Pure phase-state transition functions.
1385
1317
  *
1386
- * @module archive/prune
1318
+ * Implements every row of the §8 "Transitions and invalidation cascade" table.
1319
+ * No I/O — pure functions over PhaseState and documented inputs.
1320
+ *
1321
+ * @module phaseState/phaseTransitions
1387
1322
  */
1388
1323
  /**
1389
- * Prune archive directory to keep at most maxArchive snapshots.
1390
- * Removes the oldest files.
1391
- *
1392
- * @param metaPath - Absolute path to the .meta directory.
1393
- * @param maxArchive - Maximum snapshots to retain.
1394
- * @returns Number of files pruned.
1324
+ * Create a fresh (fully-complete) phase state.
1395
1325
  */
1396
- async function pruneArchive(metaPath, maxArchive) {
1397
- const files = listArchiveFiles(metaPath);
1398
- const toRemove = files.length - maxArchive;
1399
- if (toRemove <= 0)
1400
- return 0;
1401
- await Promise.all(files.slice(0, toRemove).map(unlink));
1402
- return toRemove;
1326
+ function freshPhaseState() {
1327
+ return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
1403
1328
  }
1404
-
1405
1329
  /**
1406
- * Archive reading helpers — watcher scan with filesystem fallback.
1407
- *
1408
- * Used by the GET /metas/:path route to retrieve archive history.
1409
- * Prefers watcher scan for performance; falls back to disk reads
1410
- * when the watcher is unavailable.
1411
- *
1412
- * @module archive/readArchive
1330
+ * Create a phase state for a never-synthesized meta (all pending from architect).
1413
1331
  */
1414
- /** Build a Qdrant filter for archive points under a given meta path. */
1415
- function buildArchiveScanFilter(metaPath, metaArchiveProperty) {
1416
- const must = [
1417
- {
1418
- key: 'file_path',
1419
- match: { text: normalizePath(join(metaPath, 'archive')) },
1420
- },
1421
- ];
1422
- for (const [key, value] of Object.entries(metaArchiveProperty)) {
1423
- must.push({ key, match: { value } });
1424
- }
1425
- return { must };
1426
- }
1427
- /** Extract file_path from a scan point payload. */
1428
- function getArchiveFilePath(point) {
1429
- const value = point.payload?.file_path;
1430
- return typeof value === 'string' ? value : '';
1431
- }
1432
- /** Project archive metadata, stripping indexing-internal fields. */
1433
- function projectArchivePayload(point, projectMeta) {
1434
- const payload = point.payload ?? {};
1435
- if (Object.keys(payload).length === 0)
1436
- return null;
1437
- const archiveMeta = { ...payload };
1438
- delete archiveMeta.file_path;
1439
- delete archiveMeta.chunk_text;
1440
- delete archiveMeta.chunk_index;
1441
- delete archiveMeta.total_chunks;
1442
- delete archiveMeta.content_hash;
1443
- delete archiveMeta.matched_rules;
1444
- return projectMeta(archiveMeta);
1332
+ function initialPhaseState() {
1333
+ return { architect: 'pending', builder: 'stale', critic: 'stale' };
1445
1334
  }
1446
1335
  /**
1447
- * Read archive history via watcher scan.
1336
+ * Enforce the per-meta invariant: at most one phase is pending or running,
1337
+ * and it is the first non-fresh phase in pipeline order.
1448
1338
  *
1449
- * @param watcher - WatcherClient with scan support.
1450
- * @param metaPath - Absolute path to the .meta/ directory.
1451
- * @param metaArchiveProperty - Additional filter properties for archive points.
1452
- * @param limit - Max entries to return (newest first). Undefined = all.
1453
- * @param projectMeta - Field projection function.
1454
- * @returns Array of projected archive entries, newest first.
1339
+ * Stale phases that become the first non-fresh phase are promoted to pending.
1455
1340
  */
1456
- async function readArchiveFromWatcher(watcher, metaPath, metaArchiveProperty, limit, projectMeta) {
1457
- if (limit === 0)
1458
- return [];
1459
- if (!watcher.scan) {
1460
- throw new Error('Watcher scan not available');
1341
+ function enforceInvariant(state) {
1342
+ const result = { ...state };
1343
+ let foundNonFresh = false;
1344
+ for (const phase of ['architect', 'builder', 'critic']) {
1345
+ const s = result[phase];
1346
+ if (s === 'fresh')
1347
+ continue;
1348
+ if (!foundNonFresh) {
1349
+ foundNonFresh = true;
1350
+ // First non-fresh: if stale, promote to pending
1351
+ if (s === 'stale') {
1352
+ result[phase] = 'pending';
1353
+ }
1354
+ // pending, running, failed stay as-is
1355
+ }
1356
+ else {
1357
+ // Subsequent non-fresh: must not be pending or running
1358
+ if (s === 'pending') {
1359
+ result[phase] = 'stale';
1360
+ }
1361
+ // running in non-first position would be a bug, but don't mask it
1362
+ }
1461
1363
  }
1462
- const points = [];
1463
- let cursor;
1464
- do {
1465
- const result = await watcher.scan({
1466
- filter: buildArchiveScanFilter(metaPath, metaArchiveProperty),
1467
- limit: 100,
1468
- cursor,
1469
- });
1470
- points.push(...result.points);
1471
- cursor = result.cursor ?? undefined;
1472
- } while (cursor);
1473
- const sorted = points.sort((a, b) => getArchiveFilePath(a).localeCompare(getArchiveFilePath(b)));
1474
- return sorted
1475
- .slice(limit ? -limit : 0)
1476
- .reverse()
1477
- .map((point) => projectArchivePayload(point, projectMeta))
1478
- .filter((value) => value !== null);
1364
+ return result;
1479
1365
  }
1366
+ // ── Invalidation cascades ──────────────────────────────────────────────
1480
1367
  /**
1481
- * Read archive history from disk (fallback).
1482
- *
1483
- * @param metaPath - Absolute path to the .meta/ directory.
1484
- * @param limit - Max entries to return (newest first). Undefined = all.
1485
- * @param projectMeta - Field projection function.
1486
- * @returns Array of projected archive entries, newest first.
1368
+ * Architect invalidated: architect → pending; builder, critic → stale.
1369
+ * Triggers: first run, _structureHash change, _steer change,
1370
+ * _crossRefs declaration change, _synthesisCount \>= architectEvery.
1487
1371
  */
1488
- async function readArchiveFromDisk(metaPath, limit, projectMeta) {
1489
- if (limit === 0)
1490
- return [];
1491
- const archiveFiles = listArchiveFiles(metaPath);
1492
- const selected = (limit ? archiveFiles.slice(-limit) : archiveFiles).reverse();
1493
- return Promise.all(selected.map(async (archiveFile) => {
1494
- const raw = await readFile(archiveFile, 'utf8');
1495
- return projectMeta(JSON.parse(raw));
1496
- }));
1372
+ function invalidateArchitect(state) {
1373
+ return enforceInvariant({
1374
+ architect: state.architect === 'failed' ? 'failed' : 'pending',
1375
+ builder: state.builder === 'fresh' ? 'stale' : state.builder,
1376
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
1377
+ });
1497
1378
  }
1498
-
1499
1379
  /**
1500
- * Read the latest archive snapshot for steer change detection.
1501
- *
1502
- * @module archive/readLatest
1380
+ * Builder invalidated (scope mtime or cross-ref _content change):
1381
+ * builder → pending; critic → stale.
1382
+ * Only applies when architect is fresh; otherwise, builder stays stale.
1503
1383
  */
1384
+ function invalidateBuilder(state) {
1385
+ if (state.architect !== 'fresh') {
1386
+ // Architect is not fresh — builder stays stale (or whatever it is)
1387
+ return enforceInvariant({
1388
+ ...state,
1389
+ builder: state.builder === 'fresh' || state.builder === 'stale'
1390
+ ? 'stale'
1391
+ : state.builder,
1392
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
1393
+ });
1394
+ }
1395
+ return enforceInvariant({
1396
+ ...state,
1397
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
1398
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
1399
+ });
1400
+ }
1401
+ // ── Phase success transitions ──────────────────────────────────────────
1504
1402
  /**
1505
- * Read the most recent archive snapshot.
1506
- *
1507
- * @param metaPath - Absolute path to the .meta directory.
1508
- * @returns The latest archived meta, or null if no archives exist.
1403
+ * Architect completes successfully.
1404
+ * architect → fresh; builder → pending; critic → stale.
1509
1405
  */
1510
- async function readLatestArchive(metaPath) {
1511
- const files = listArchiveFiles(metaPath);
1512
- if (files.length === 0)
1513
- return null;
1514
- const raw = await readFile(files[files.length - 1], 'utf8');
1515
- return JSON.parse(raw);
1406
+ function architectSuccess(state) {
1407
+ return enforceInvariant({
1408
+ architect: 'fresh',
1409
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
1410
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
1411
+ });
1516
1412
  }
1517
-
1518
1413
  /**
1519
- * Create archive snapshots of meta.json.
1520
- *
1521
- * Copies current meta.json to archive/\{ISO-timestamp\}.json with
1522
- * _archived: true and _archivedAt added.
1523
- *
1524
- * @module archive/snapshot
1414
+ * Builder completes successfully.
1415
+ * builder → fresh; critic → pending.
1525
1416
  */
1417
+ function builderSuccess(state) {
1418
+ return enforceInvariant({
1419
+ ...state,
1420
+ builder: 'fresh',
1421
+ critic: state.critic === 'failed' ? 'failed' : 'pending',
1422
+ });
1423
+ }
1526
1424
  /**
1527
- * Create an archive snapshot of the current meta.json.
1528
- *
1529
- * @param metaPath - Absolute path to the .meta directory.
1530
- * @param meta - Current meta.json content.
1531
- * @returns The archive file path.
1425
+ * Critic completes successfully.
1426
+ * critic → fresh. Meta becomes fully fresh.
1532
1427
  */
1533
- async function createSnapshot(metaPath, meta) {
1534
- const archiveDir = join(metaPath, 'archive');
1535
- await mkdir(archiveDir, { recursive: true });
1536
- const now = new Date().toISOString().replace(/[:.]/g, '-');
1537
- const archiveFile = join(archiveDir, now + '.json');
1538
- const archived = {
1539
- ...meta,
1540
- _archived: true,
1541
- _archivedAt: new Date().toISOString(),
1542
- };
1543
- await writeFile(archiveFile, JSON.stringify(archived, null, 2) + '\n');
1544
- return archiveFile;
1428
+ function criticSuccess(state) {
1429
+ return enforceInvariant({
1430
+ ...state,
1431
+ critic: 'fresh',
1432
+ });
1545
1433
  }
1546
-
1434
+ // ── Failure transition ─────────────────────────────────────────────────
1547
1435
  /**
1548
- * Build the MetaContext for a synthesis cycle.
1549
- *
1550
- * Computes shared inputs once: scope files, delta files, child meta outputs,
1551
- * previous content/feedback, steer, and archive paths.
1552
- *
1553
- * @module orchestrator/contextPackage
1436
+ * A phase fails (error, timeout, or abort).
1437
+ * Target phase → failed; upstream and downstream unchanged.
1554
1438
  */
1439
+ function phaseFailed(state, phase) {
1440
+ return enforceInvariant({
1441
+ ...state,
1442
+ [phase]: 'failed',
1443
+ });
1444
+ }
1445
+ // ── Surgical retry ─────────────────────────────────────────────────────
1555
1446
  /**
1556
- * Condense a file list into glob-like summaries.
1557
- * Groups by directory + extension pattern.
1558
- *
1559
- * @param files - Array of file paths.
1560
- * @param maxIndividual - Show individual files up to this count.
1561
- * @returns Condensed summary string.
1447
+ * Retry a failed phase: failed → pending.
1448
+ * Only valid when the phase is currently failed.
1562
1449
  */
1563
- function condenseScopeFiles(files, maxIndividual = 30) {
1564
- if (files.length <= maxIndividual)
1565
- return files.join('\n');
1566
- // Group by dir + extension
1567
- const groups = new Map();
1568
- for (const f of files) {
1569
- const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
1570
- const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
1571
- const key = dir + '*' + ext;
1572
- groups.set(key, (groups.get(key) ?? 0) + 1);
1573
- }
1574
- // Sort by count descending
1575
- const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
1576
- return sorted
1577
- .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
1578
- .join('\n');
1450
+ function retryPhase(state, phase) {
1451
+ if (state[phase] !== 'failed')
1452
+ return state;
1453
+ return enforceInvariant({
1454
+ ...state,
1455
+ [phase]: 'pending',
1456
+ });
1579
1457
  }
1580
1458
  /**
1581
- * Read a meta.json file and extract `_content` and `_generatedAt`.
1582
- *
1583
- * @param metaJsonPath - Absolute path to a meta.json file.
1584
- * @returns Parsed fields, or defaults if missing/unreadable.
1459
+ * Retry all failed phases: each failed phase → pending.
1460
+ * Used by scheduler ticks and queue reads to auto-promote failed phases.
1585
1461
  */
1586
- async function readChildMeta(metaJsonPath) {
1587
- try {
1588
- const raw = await readFile(metaJsonPath, 'utf8');
1589
- const meta = JSON.parse(raw);
1590
- return {
1591
- content: meta._content ?? null,
1592
- generatedAt: meta._generatedAt,
1593
- };
1462
+ function retryAllFailed(state) {
1463
+ let result = state;
1464
+ for (const phase of ['architect', 'builder', 'critic']) {
1465
+ if (result[phase] === 'failed') {
1466
+ result = retryPhase(result, phase);
1467
+ }
1594
1468
  }
1595
- catch {
1596
- return { content: null, generatedAt: undefined };
1469
+ return result;
1470
+ }
1471
+ // ── Running transition ─────────────────────────────────────────────────
1472
+ /**
1473
+ * Mark a phase as running (scheduler picks it).
1474
+ */
1475
+ function phaseRunning(state, phase) {
1476
+ return {
1477
+ ...state,
1478
+ [phase]: 'running',
1479
+ };
1480
+ }
1481
+ // ── Query helpers ──────────────────────────────────────────────────────
1482
+ /**
1483
+ * Get the owed phase: first non-fresh phase in pipeline order, or null.
1484
+ */
1485
+ function getOwedPhase(state) {
1486
+ for (const phase of ['architect', 'builder', 'critic']) {
1487
+ if (state[phase] !== 'fresh')
1488
+ return phase;
1597
1489
  }
1490
+ return null;
1598
1491
  }
1599
1492
  /**
1600
- * Read a meta.json file and extract its `_content` field.
1601
- *
1602
- * @param metaJsonPath - Absolute path to a meta.json file.
1603
- * @returns The `_content` string, or null if missing/unreadable.
1493
+ * Check if a meta is fully fresh (all phases fresh).
1604
1494
  */
1605
- async function readMetaContent(metaJsonPath) {
1606
- const info = await readChildMeta(metaJsonPath);
1607
- return info.content;
1495
+ function isFullyFresh(state) {
1496
+ return (state.architect === 'fresh' &&
1497
+ state.builder === 'fresh' &&
1498
+ state.critic === 'fresh');
1608
1499
  }
1609
1500
  /**
1610
- * Build the context package for a synthesis cycle.
1501
+ * Get the scheduler priority band for a meta's owed phase.
1502
+ * 1 = critic (highest), 2 = builder, 3 = architect, null = fully fresh.
1503
+ */
1504
+ function getPriorityBand(state) {
1505
+ const owed = getOwedPhase(state);
1506
+ if (!owed)
1507
+ return null;
1508
+ if (owed === 'critic')
1509
+ return 1;
1510
+ if (owed === 'builder')
1511
+ return 2;
1512
+ return 3;
1513
+ }
1514
+
1515
+ /**
1516
+ * Backward-compatible derivation of _phaseState from existing meta fields.
1611
1517
  *
1612
- * @param node - The meta node being synthesized.
1613
- * @param meta - Current meta.json content.
1614
- * @param watcher - WatcherClient for scope enumeration.
1615
- * @returns The computed context package.
1518
+ * When a meta is loaded from disk without _phaseState, this reconstructs
1519
+ * the phase state from _content, _builder, _state, _error.step, and
1520
+ * the architect-invalidating inputs.
1521
+ *
1522
+ * @module phaseState/derivePhaseState
1616
1523
  */
1617
- async function buildContextPackage(node, meta, watcher, logger) {
1618
- // Scope and delta files via watcher walk
1619
- const scopeStart = Date.now();
1620
- const { scopeFiles } = await getScopeFiles(node, watcher, logger);
1621
- const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
1622
- logger?.debug({
1623
- scopeFiles: scopeFiles.length,
1624
- deltaFiles: deltaFiles.length,
1625
- durationMs: Date.now() - scopeStart,
1626
- }, 'scope and delta files computed');
1627
- // Child meta outputs (parallel reads, delta-aware).
1628
- // Only include full _content for children synthesized AFTER the parent's
1629
- // _generatedAt. Non-delta children are already folded into the parent's
1630
- // previous _content and are listed as path-only references (#169).
1631
- const parentGeneratedAt = meta._generatedAt;
1632
- const childMetas = {};
1633
- const childEntries = await Promise.all(node.children.map(async (child) => {
1634
- const info = await readChildMeta(join(child.metaPath, 'meta.json'));
1635
- return [child.ownerPath, info];
1636
- }));
1637
- let deltaChildCount = 0;
1638
- for (const [path, info] of childEntries) {
1639
- // Delta child: no parent timestamp (first run), or child synthesized
1640
- // after parent. Include full content.
1641
- if (!parentGeneratedAt ||
1642
- !info.generatedAt ||
1643
- info.generatedAt > parentGeneratedAt) {
1644
- childMetas[path] = info.content ?? undefined;
1645
- deltaChildCount++;
1646
- }
1647
- else {
1648
- // Non-delta: already incorporated in previous synthesis.
1649
- // Include path with null content so the builder knows it exists.
1650
- childMetas[path] = null;
1651
- }
1652
- }
1653
- logger?.debug({
1654
- totalChildren: childEntries.length,
1655
- deltaChildren: deltaChildCount,
1656
- }, 'child metas filtered by delta');
1657
- // Cross-referenced meta outputs (parallel reads)
1658
- const crossRefMetas = {};
1659
- const seen = new Set();
1660
- const crossRefPaths = [];
1661
- for (const refPath of meta._crossRefs ?? []) {
1662
- if (refPath === node.ownerPath || refPath === node.metaPath)
1663
- continue;
1664
- if (seen.has(refPath))
1665
- continue;
1666
- seen.add(refPath);
1667
- crossRefPaths.push(refPath);
1668
- }
1669
- const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
1670
- const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
1671
- return [refPath, content];
1672
- }));
1673
- for (const [path, content] of crossRefEntries) {
1674
- crossRefMetas[path] = content;
1675
- }
1676
- // Archive paths
1677
- const archives = listArchiveFiles(node.metaPath);
1678
- // Nearest ancestor _builder output
1679
- let ancestorBuilder;
1680
- const ancestor = getAncestorMeta(node);
1681
- if (ancestor) {
1682
- try {
1683
- const raw = await readFile(join(ancestor.metaPath, 'meta.json'), 'utf8');
1684
- const ancestorMeta = JSON.parse(raw);
1685
- if (ancestorMeta._builder) {
1686
- ancestorBuilder = ancestorMeta._builder;
1524
+ /**
1525
+ * Derive _phaseState from existing meta fields.
1526
+ *
1527
+ * If the meta already has _phaseState, returns it as-is.
1528
+ *
1529
+ * Otherwise, reconstructs from available fields:
1530
+ * - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
1531
+ * - Errored meta: the failed phase is mapped from _error.step.
1532
+ * - Mid-cycle meta with cached _builder but no _content: builder pending.
1533
+ * - Fully-fresh meta: all phases fresh.
1534
+ * - Meta with stale architect inputs: architect pending, downstream stale.
1535
+ *
1536
+ * @param meta - The meta.json content.
1537
+ * @param inputs - Optional derivation inputs. If not provided, a simpler
1538
+ * heuristic is used (no architect invalidation check).
1539
+ * @returns The derived PhaseState.
1540
+ */
1541
+ function derivePhaseState(meta, inputs) {
1542
+ // Already has _phaseState — use it
1543
+ if (meta._phaseState)
1544
+ return meta._phaseState;
1545
+ // Check for errors first — _error.step maps directly to failed phase
1546
+ if (meta._error) {
1547
+ const failedPhase = meta._error.step;
1548
+ const state = freshPhaseState();
1549
+ state[failedPhase] = 'failed';
1550
+ // If architect failed and no _builder, downstream is stale
1551
+ if (failedPhase === 'architect') {
1552
+ if (!meta._builder) {
1553
+ state.builder = 'stale';
1554
+ state.critic = 'stale';
1687
1555
  }
1688
1556
  }
1689
- catch {
1690
- // Ancestor meta.json unreadable — skip
1557
+ // If builder failed, critic is stale
1558
+ if (failedPhase === 'builder') {
1559
+ state.critic = 'stale';
1691
1560
  }
1561
+ return state;
1692
1562
  }
1693
- return {
1694
- path: node.metaPath,
1695
- scopeFiles,
1696
- deltaFiles,
1697
- childMetas,
1698
- crossRefMetas,
1699
- previousContent: meta._content ?? null,
1700
- previousFeedback: meta._feedback ?? null,
1701
- steer: meta._steer ?? null,
1702
- previousState: meta._state ?? null,
1703
- archives,
1704
- ancestorBuilder,
1705
- };
1563
+ // Never synthesized: no _content AND no _builder (and no error)
1564
+ if (!meta._content && !meta._builder) {
1565
+ return initialPhaseState();
1566
+ }
1567
+ // Has _builder but no _content: builder is pending
1568
+ if (meta._builder && !meta._content) {
1569
+ return {
1570
+ architect: 'fresh',
1571
+ builder: 'pending',
1572
+ critic: 'stale',
1573
+ };
1574
+ }
1575
+ // Has _content but no _feedback: critic is pending
1576
+ if (meta._content && !meta._feedback) {
1577
+ return {
1578
+ architect: 'fresh',
1579
+ builder: 'fresh',
1580
+ critic: 'pending',
1581
+ };
1582
+ }
1583
+ // Default: fully fresh
1584
+ return freshPhaseState();
1706
1585
  }
1707
1586
 
1708
1587
  /**
1709
- * Build task prompts for each synthesis step.
1710
- *
1711
- * Prompts are compiled as Handlebars templates with access to config,
1712
- * meta, and scope context. The architect can write template expressions
1713
- * into its _builder output; these resolve when the builder task is compiled.
1588
+ * List archive snapshot files in chronological order.
1714
1589
  *
1715
- * @module orchestrator/buildTask
1590
+ * @module archive/listArchive
1716
1591
  */
1717
- Handlebars.registerHelper('gt', (a, b) => a > b);
1718
- /** Build the template context from synthesis inputs. */
1719
- function buildTemplateContext(ctx, meta, config) {
1720
- return {
1721
- config,
1722
- meta,
1723
- scope: {
1724
- fileCount: ctx.scopeFiles.length,
1725
- deltaCount: ctx.deltaFiles.length,
1726
- childCount: Object.keys(ctx.childMetas).length,
1727
- crossRefCount: Object.keys(ctx.crossRefMetas).length,
1728
- },
1729
- };
1730
- }
1731
1592
  /**
1732
- * Compile a string as a Handlebars template with the given context.
1733
- * Returns the original string unchanged if compilation fails.
1593
+ * List archive .json files sorted chronologically (oldest first).
1594
+ *
1595
+ * @param metaPath - Absolute path to the .meta directory.
1596
+ * @returns Array of absolute paths to archive files, or empty if none.
1734
1597
  */
1735
- function compileTemplate(text, context) {
1598
+ function listArchiveFiles(metaPath) {
1599
+ const archiveDir = join(metaPath, 'archive');
1736
1600
  try {
1737
- return Handlebars.compile(text, { noEscape: true })(context);
1601
+ return readdirSync(archiveDir)
1602
+ .filter((f) => f.endsWith('.json'))
1603
+ .sort()
1604
+ .map((f) => join(archiveDir, f));
1738
1605
  }
1739
1606
  catch {
1740
- return text;
1607
+ return [];
1741
1608
  }
1742
1609
  }
1610
+
1743
1611
  /**
1744
- * Append a keyed record of meta outputs as subsections, if non-empty.
1612
+ * Prune old archive snapshots beyond maxArchive.
1745
1613
  *
1746
- * @param condenseMissing - When true, null entries are condensed into a
1747
- * path listing (delta-aware child metas). When false, null entries get
1748
- * individual subsections with "(not yet synthesized)" (cross-refs).
1614
+ * @module archive/prune
1749
1615
  */
1750
- function appendMetaSections(sections, heading, metas, condenseMissing = false) {
1751
- if (Object.keys(metas).length === 0)
1752
- return;
1753
- if (!condenseMissing) {
1754
- // Original behavior: every entry gets a subsection.
1755
- sections.push('', heading);
1756
- for (const [path, content] of Object.entries(metas)) {
1757
- sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
1758
- }
1759
- return;
1760
- }
1761
- // Delta-aware: separate entries into three categories (#169).
1762
- // - string content → full subsection (delta child with content)
1763
- // - null → non-delta, already folded into previous synthesis
1764
- // - undefined → delta child not yet synthesized
1765
- const withContent = [];
1766
- const unchanged = [];
1767
- const notYetSynthesized = [];
1768
- for (const [path, content] of Object.entries(metas)) {
1769
- if (typeof content === 'string') {
1770
- withContent.push([path, content]);
1771
- }
1772
- else if (content === null) {
1773
- unchanged.push(path);
1774
- }
1775
- else {
1776
- notYetSynthesized.push(path);
1777
- }
1778
- }
1779
- sections.push('', heading);
1780
- for (const [path, content] of withContent) {
1781
- sections.push(`### ${path}`, content);
1782
- }
1783
- if (notYetSynthesized.length > 0) {
1784
- sections.push('', `### Not yet synthesized (${notYetSynthesized.length.toString()})`, ...notYetSynthesized.map((p) => `- ${p}`));
1785
- }
1786
- if (unchanged.length > 0) {
1787
- sections.push('', `### Other children (${unchanged.length.toString()} \u2014 unchanged since last synthesis)`, ...unchanged.map((p) => `- ${p}`));
1788
- }
1789
- }
1790
- /** Inject nearest ancestor's organizational context, if available. */
1791
- function appendAncestorContext(sections, ctx) {
1792
- if (ctx.ancestorBuilder) {
1793
- sections.push('', '## PARENT ORGANIZATIONAL CONTEXT', ctx.ancestorBuilder);
1794
- }
1795
- }
1796
- /** Append optional context sections shared across all step prompts. */
1797
- function appendSharedSections(sections, ctx, options) {
1798
- const opts = {
1799
- includeSteer: true,
1800
- includePreviousContent: true,
1801
- includePreviousFeedback: true,
1802
- feedbackHeading: '## PREVIOUS FEEDBACK',
1803
- includeChildMetas: true,
1804
- includeCrossRefs: true,
1805
- ...options,
1806
- };
1807
- if (opts.includeSteer && ctx.steer) {
1808
- sections.push('', '## STEERING PROMPT', ctx.steer);
1809
- }
1810
- if (opts.includePreviousContent && ctx.previousContent) {
1811
- sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
1812
- }
1813
- if (opts.includePreviousFeedback && ctx.previousFeedback) {
1814
- sections.push('', opts.feedbackHeading, ctx.previousFeedback);
1815
- }
1816
- if (opts.includeChildMetas) {
1817
- appendMetaSections(sections, '## CHILD META OUTPUTS', ctx.childMetas, true);
1818
- }
1819
- if (opts.includeCrossRefs) {
1820
- appendMetaSections(sections, '## CROSS-REFERENCED METAS', ctx.crossRefMetas);
1821
- }
1616
+ /**
1617
+ * Prune archive directory to keep at most maxArchive snapshots.
1618
+ * Removes the oldest files.
1619
+ *
1620
+ * @param metaPath - Absolute path to the .meta directory.
1621
+ * @param maxArchive - Maximum snapshots to retain.
1622
+ * @returns Number of files pruned.
1623
+ */
1624
+ async function pruneArchive(metaPath, maxArchive) {
1625
+ const files = listArchiveFiles(metaPath);
1626
+ const toRemove = files.length - maxArchive;
1627
+ if (toRemove <= 0)
1628
+ return 0;
1629
+ await Promise.all(files.slice(0, toRemove).map(unlink));
1630
+ return toRemove;
1822
1631
  }
1632
+
1823
1633
  /**
1824
- * Build the architect task prompt.
1634
+ * Read the latest archive snapshot for steer change detection.
1825
1635
  *
1826
- * @param ctx - Synthesis context.
1827
- * @param meta - Current meta.json.
1828
- * @param config - Synthesis config.
1829
- * @returns The architect task prompt string.
1636
+ * @module archive/readLatest
1830
1637
  */
1831
- function buildArchitectTask(ctx, meta, config) {
1832
- const sections = [
1833
- `# jeeves-meta · ARCHITECT · ${ctx.path}`,
1834
- '',
1835
- DEFAULT_ARCHITECT_PROMPT,
1836
- '',
1837
- '## SCOPE',
1838
- `Path: ${ctx.path}`,
1839
- `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
1840
- `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
1841
- '',
1842
- '### File listing (scope)',
1843
- condenseScopeFiles(ctx.scopeFiles),
1844
- ];
1845
- appendAncestorContext(sections, ctx);
1846
- // Inject previous _builder so architect can see its own prior output
1847
- if (meta._builder) {
1848
- sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
1849
- }
1850
- appendSharedSections(sections, ctx);
1851
- if (ctx.archives.length > 0) {
1852
- sections.push('', '## ARCHIVE HISTORY', `${ctx.archives.length.toString()} previous synthesis snapshots available in .meta/archive/.`, 'Review these to understand how the synthesis has evolved over time.');
1853
- }
1854
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
1855
- }
1856
1638
  /**
1857
- * Build the builder task prompt.
1639
+ * Read the most recent archive snapshot.
1858
1640
  *
1859
- * @param ctx - Synthesis context.
1860
- * @param meta - Current meta.json.
1861
- * @param config - Synthesis config.
1862
- * @returns The builder task prompt string.
1641
+ * @param metaPath - Absolute path to the .meta directory.
1642
+ * @returns The latest archived meta, or null if no archives exist.
1863
1643
  */
1864
- function buildBuilderTask(ctx, meta, config) {
1865
- const sections = [
1866
- `# jeeves-meta · BUILDER · ${ctx.path}`,
1867
- '',
1868
- '## TASK BRIEF (from Architect)',
1869
- meta._builder ?? '(No architect brief available)',
1870
- '',
1871
- '## SCOPE',
1872
- `Path: ${ctx.path}`,
1873
- `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
1874
- ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
1875
- ];
1876
- appendAncestorContext(sections, ctx);
1877
- if (ctx.previousState != null) {
1878
- sections.push('', '## PREVIOUS STATE', 'The following opaque state was returned by the previous synthesis cycle.', 'Use it to continue progressive work. Update `_state` in your output to', 'reflect your progress.', '', '```json', JSON.stringify(ctx.previousState, null, 2), '```');
1879
- }
1880
- appendSharedSections(sections, ctx, {
1881
- includeSteer: false,
1882
- feedbackHeading: '## FEEDBACK FROM CRITIC',
1883
- });
1884
- sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" },', ' "_state": { "description": "Opaque state object for progressive work across cycles" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '_state is optional: set it to carry state across synthesis cycles for progressive work.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.', '', 'IMPORTANT: Do not call sessions_spawn or sessions_yield. Read all files directly using the tools', 'available in your session. Do not attempt to parallelize work by spawning sub-agents.');
1885
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
1644
+ async function readLatestArchive(metaPath) {
1645
+ const files = listArchiveFiles(metaPath);
1646
+ if (files.length === 0)
1647
+ return null;
1648
+ const raw = await readFile(files[files.length - 1], 'utf8');
1649
+ return JSON.parse(raw);
1886
1650
  }
1651
+
1887
1652
  /**
1888
- * Build the critic task prompt.
1653
+ * Create archive snapshots of meta.json.
1889
1654
  *
1890
- * @param ctx - Synthesis context.
1891
- * @param meta - Current meta.json (with _content already set by builder).
1892
- * @param config - Synthesis config.
1893
- * @returns The critic task prompt string.
1655
+ * Copies current meta.json to archive/\{ISO-timestamp\}.json with
1656
+ * _archived: true and _archivedAt added.
1657
+ *
1658
+ * @module archive/snapshot
1894
1659
  */
1895
- function buildCriticTask(ctx, meta, config) {
1896
- const sections = [
1897
- `# jeeves-meta · CRITIC · ${ctx.path}`,
1898
- '',
1899
- DEFAULT_CRITIC_PROMPT,
1900
- '',
1901
- '## SYNTHESIS TO EVALUATE',
1902
- meta._content ?? '(No content produced)',
1903
- '',
1904
- '## SCOPE',
1905
- `Path: ${ctx.path}`,
1906
- `Files in scope: ${ctx.scopeFiles.length.toString()}`,
1907
- ];
1908
- appendSharedSections(sections, ctx, {
1909
- includePreviousContent: false,
1910
- feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
1911
- includeChildMetas: false,
1912
- includeCrossRefs: false,
1913
- });
1914
- sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
1915
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
1660
+ /**
1661
+ * Create an archive snapshot of the current meta.json.
1662
+ *
1663
+ * @param metaPath - Absolute path to the .meta directory.
1664
+ * @param meta - Current meta.json content.
1665
+ * @returns The archive file path.
1666
+ */
1667
+ async function createSnapshot(metaPath, meta) {
1668
+ const archiveDir = join(metaPath, 'archive');
1669
+ await mkdir(archiveDir, { recursive: true });
1670
+ const now = new Date().toISOString().replace(/[:.]/g, '-');
1671
+ const archiveFile = join(archiveDir, now + '.json');
1672
+ const archived = {
1673
+ ...meta,
1674
+ _archived: true,
1675
+ _archivedAt: new Date().toISOString(),
1676
+ };
1677
+ await writeFile(archiveFile, JSON.stringify(archived, null, 2) + '\n');
1678
+ return archiveFile;
1679
+ }
1680
+
1681
+ const toPath = urlOrPath => urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath;
1682
+
1683
+ function findUpSync(name, {
1684
+ cwd = process$1.cwd(),
1685
+ type = 'file',
1686
+ stopAt,
1687
+ } = {}) {
1688
+ let directory = path.resolve(toPath(cwd) ?? '');
1689
+ const {root} = path.parse(directory);
1690
+ stopAt = path.resolve(directory, toPath(stopAt) ?? root);
1691
+ const isAbsoluteName = path.isAbsolute(name);
1692
+
1693
+ while (directory) {
1694
+ const filePath = isAbsoluteName ? name : path.join(directory, name);
1695
+
1696
+ try {
1697
+ const stats = fs.statSync(filePath, {throwIfNoEntry: false});
1698
+ if ((type === 'file' && stats?.isFile()) || (type === 'directory' && stats?.isDirectory())) {
1699
+ return filePath;
1700
+ }
1701
+ } catch {}
1702
+
1703
+ if (directory === stopAt || directory === root) {
1704
+ break;
1705
+ }
1706
+
1707
+ directory = path.dirname(directory);
1708
+ }
1709
+ }
1710
+
1711
+ const isTypeOnlyPackageJsonData = packageData => {
1712
+ if (!packageData || typeof packageData !== 'object' || Array.isArray(packageData)) {
1713
+ return false;
1714
+ }
1715
+
1716
+ const keys = Object.keys(packageData);
1717
+ return keys.length === 1 && keys[0] === 'type' && typeof packageData.type === 'string';
1718
+ };
1719
+
1720
+ const isTypeOnlyPackageJsonSync = filePath => {
1721
+ let fileContents;
1722
+
1723
+ try {
1724
+ fileContents = fs.readFileSync(filePath, 'utf8');
1725
+ } catch {
1726
+ return false;
1727
+ }
1728
+
1729
+ try {
1730
+ return isTypeOnlyPackageJsonData(JSON.parse(fileContents));
1731
+ } catch {
1732
+ return false;
1733
+ }
1734
+ };
1735
+
1736
+ const getNextSearchDirectory = filePath => {
1737
+ const packageDirectoryPath = path.dirname(filePath);
1738
+ const parentDirectoryPath = path.dirname(packageDirectoryPath);
1739
+ return parentDirectoryPath === packageDirectoryPath ? undefined : parentDirectoryPath;
1740
+ };
1741
+
1742
+ const findPackageDirectorySync = (directory, ignoreTypeOnlyPackageJson) => {
1743
+ const filePath = findUpSync('package.json', {cwd: directory});
1744
+ if (!filePath) {
1745
+ return undefined;
1746
+ }
1747
+
1748
+ const packageDirectoryPath = path.dirname(filePath);
1749
+ if (!ignoreTypeOnlyPackageJson) {
1750
+ return packageDirectoryPath;
1751
+ }
1752
+
1753
+ if (!isTypeOnlyPackageJsonSync(filePath)) {
1754
+ return packageDirectoryPath;
1755
+ }
1756
+
1757
+ const nextDirectory = getNextSearchDirectory(filePath);
1758
+ if (!nextDirectory) {
1759
+ return undefined;
1760
+ }
1761
+
1762
+ return findPackageDirectorySync(nextDirectory, ignoreTypeOnlyPackageJson);
1763
+ };
1764
+
1765
+ function packageDirectorySync({cwd, ignoreTypeOnlyPackageJson} = {}) {
1766
+ return findPackageDirectorySync(cwd ?? process$1.cwd(), ignoreTypeOnlyPackageJson);
1916
1767
  }
1917
1768
 
1918
1769
  /**
1919
- * Build a minimal MetaNode from a known meta path using watcher walk.
1770
+ * Built-in default prompts for the synthesis pipeline.
1920
1771
  *
1921
- * Used for targeted synthesis (when a specific path is requested) to avoid
1922
- * the full discovery + ownership tree build. Discovers only immediate child
1923
- * `.meta/` directories.
1772
+ * Prompts live as `.md` files next to this module in `src/prompts/`. The
1773
+ * CLI build copies them into `dist/prompts/` (rollup-plugin-copy), and a
1774
+ * build-time assertion verifies the copy.
1924
1775
  *
1925
- * @module discovery/buildMinimalNode
1776
+ * The prompt directory is resolved relative to the executing module, so
1777
+ * running from source (e.g. vitest) reads `src/prompts/` and the published
1778
+ * bundle reads `dist/prompts/`. Prompts are read lazily on first use, so a
1779
+ * missing file surfaces as one clear error instead of an import-time crash.
1780
+ *
1781
+ * @module prompts
1926
1782
  */
1927
1783
  /**
1928
- * Build a minimal MetaNode for a known meta path.
1929
- *
1930
- * Walks the owner directory for child `.meta/meta.json` files and constructs
1931
- * a shallow ownership tree (self + direct children only).
1784
+ * Resolve the directory holding the built-in prompt files for a module.
1932
1785
  *
1933
- * @param metaPath - Absolute path to the `.meta/` directory.
1934
- * @param watcher - WatcherClient for filesystem enumeration.
1935
- * @returns MetaNode with direct children wired.
1786
+ * @param moduleFile - Absolute path of the executing module.
1787
+ * @returns `<package root>/src/prompts` when the module lives under
1788
+ * `src/`, otherwise `<package root>/dist/prompts` (published bundle).
1936
1789
  */
1937
- async function buildMinimalNode(metaPath, watcher) {
1938
- const normalized = normalizePath(metaPath);
1939
- const ownerPath = posix.dirname(normalized);
1940
- // Find child metas using watcher walk.
1941
- // We include only *direct* children (nearest descendants in the ownership tree)
1942
- // to match the ownership semantics used elsewhere.
1943
- const rawMetaJsonPaths = await watcher.walk([
1944
- `${escapeGlob(ownerPath)}/**/.meta/meta.json`,
1945
- ]);
1946
- const candidateMetaPaths = [
1947
- ...new Set(rawMetaJsonPaths.map((p) => posix.dirname(normalizePath(p)))),
1948
- ].filter((p) => p !== normalized);
1949
- const candidates = candidateMetaPaths
1950
- .map((mp) => ({ metaPath: mp, ownerPath: posix.dirname(mp) }))
1951
- .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
1952
- const directChildren = [];
1953
- for (const c of candidates) {
1954
- const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
1955
- c.ownerPath.startsWith(d.ownerPath + '/'));
1956
- if (!nestedUnderExisting)
1957
- directChildren.push(c);
1790
+ function resolvePromptDir(moduleFile) {
1791
+ const packageRoot = packageDirectorySync({ cwd: dirname(moduleFile) });
1792
+ if (!packageRoot) {
1793
+ throw new Error(`jeeves-meta: cannot locate package root for prompt module ${moduleFile}`);
1958
1794
  }
1959
- const children = directChildren.map((c) => ({
1960
- metaPath: c.metaPath,
1961
- ownerPath: c.ownerPath,
1962
- treeDepth: 1,
1963
- children: [],
1964
- parent: null,
1965
- }));
1966
- const node = {
1967
- metaPath: normalized,
1968
- ownerPath,
1969
- treeDepth: 0,
1970
- children,
1971
- parent: null,
1972
- };
1973
- for (const child of children) {
1974
- child.parent = node;
1795
+ const [topLevel] = relative(packageRoot, moduleFile).split(sep);
1796
+ return join(packageRoot, topLevel === 'src' ? 'src' : 'dist', 'prompts');
1797
+ }
1798
+ const cache = new Map();
1799
+ function loadPrompt(file) {
1800
+ const cached = cache.get(file);
1801
+ if (cached !== undefined)
1802
+ return cached;
1803
+ const path = join(getPromptDir(), file);
1804
+ let content;
1805
+ try {
1806
+ content = readFileSync(path, 'utf8');
1975
1807
  }
1976
- return node;
1808
+ catch (err) {
1809
+ throw new Error(`jeeves-meta: built-in prompt not found at ${path}`, {
1810
+ cause: err,
1811
+ });
1812
+ }
1813
+ cache.set(file, content);
1814
+ return content;
1815
+ }
1816
+ /** Directory the built-in prompts are read from for this module. */
1817
+ function getPromptDir() {
1818
+ return resolvePromptDir(fileURLToPath(import.meta.url));
1819
+ }
1820
+ /** Built-in default architect prompt (read on first use, then cached). */
1821
+ function getDefaultArchitectPrompt() {
1822
+ return loadPrompt('architect.md');
1823
+ }
1824
+ /** Built-in default critic prompt (read on first use, then cached). */
1825
+ function getDefaultCriticPrompt() {
1826
+ return loadPrompt('critic.md');
1977
1827
  }
1978
1828
 
1979
1829
  /**
1980
- * Pure phase-state transition functions.
1830
+ * Compute a structure hash from a sorted file listing.
1981
1831
  *
1982
- * Implements every row of the §8 "Transitions and invalidation cascade" table.
1983
- * No I/O — pure functions over PhaseState and documented inputs.
1832
+ * Used to detect when directory structure changes, triggering
1833
+ * an architect re-run.
1984
1834
  *
1985
- * @module phaseState/phaseTransitions
1835
+ * @module structureHash
1986
1836
  */
1987
1837
  /**
1988
- * Create a fresh (fully-complete) phase state.
1838
+ * Compute a SHA-256 hash of a sorted file listing.
1839
+ *
1840
+ * @param filePaths - Array of file paths in scope.
1841
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
1989
1842
  */
1990
- function freshPhaseState() {
1991
- return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
1843
+ function computeStructureHash(filePaths) {
1844
+ const sorted = [...filePaths].sort();
1845
+ const content = sorted.join('\n');
1846
+ return createHash('sha256').update(content).digest('hex');
1992
1847
  }
1848
+
1993
1849
  /**
1994
- * Create a phase state for a never-synthesized meta (all pending from architect).
1850
+ * Per-tick invalidation pass.
1851
+ *
1852
+ * Computes architect-invalidating and builder-invalidating inputs for a meta,
1853
+ * then applies the cascade to update _phaseState.
1854
+ *
1855
+ * @module phaseState/invalidate
1995
1856
  */
1996
- function initialPhaseState() {
1997
- return { architect: 'pending', builder: 'stale', critic: 'stale' };
1857
+ /**
1858
+ * Check whether a persisted prompt snapshot mismatches the currently-resolved prompt.
1859
+ * Returns true when the snapshot exists and differs from the resolved prompt.
1860
+ * This is informational only — it does NOT trigger invalidation.
1861
+ */
1862
+ function isPromptStale(snapshot, resolved) {
1863
+ return snapshot !== undefined && snapshot !== resolved;
1998
1864
  }
1999
1865
  /**
2000
- * Enforce the per-meta invariant: at most one phase is pending or running,
2001
- * and it is the first non-fresh phase in pipeline order.
1866
+ * Compute invalidation inputs and apply cascade for a single meta.
2002
1867
  *
2003
- * Stale phases that become the first non-fresh phase are promoted to pending.
1868
+ * @param meta - Current meta.json content with existing _phaseState.
1869
+ * @param scopeFiles - Sorted file list from scope.
1870
+ * @param config - MetaConfig for architectEvery.
1871
+ * @param node - MetaNode for archive access.
1872
+ * @param crossRefMetas - Map of cross-ref owner paths to their current _content.
1873
+ * @param archiveCrossRefContent - Map of cross-ref owner paths to their archived _content.
1874
+ * @returns Updated phase state and invalidation details.
2004
1875
  */
2005
- function enforceInvariant(state) {
2006
- const result = { ...state };
2007
- let foundNonFresh = false;
2008
- for (const phase of ['architect', 'builder', 'critic']) {
2009
- const s = result[phase];
2010
- if (s === 'fresh')
2011
- continue;
2012
- if (!foundNonFresh) {
2013
- foundNonFresh = true;
2014
- // First non-fresh: if stale, promote to pending
2015
- if (s === 'stale') {
2016
- result[phase] = 'pending';
2017
- }
2018
- // pending, running, failed stay as-is
1876
+ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas, archiveCrossRefContent) {
1877
+ let phaseState = meta._phaseState ?? {
1878
+ architect: 'fresh',
1879
+ builder: 'fresh',
1880
+ critic: 'fresh',
1881
+ };
1882
+ // ── Architect-level inputs ──
1883
+ const structureHash = computeStructureHash(scopeFiles);
1884
+ const structureChanged = structureHash !== meta._structureHash;
1885
+ const latestArchive = await readLatestArchive(node.metaPath);
1886
+ const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
1887
+ // Prompt staleness detection: compare persisted prompt snapshots against
1888
+ // currently-resolved prompts. This is INFORMATIONAL ONLY — reported via
1889
+ // inputStatus so /preview can surface it, but it must NEVER feed into
1890
+ // the invalidation cascade. When a meta naturally reaches architectEvery
1891
+ // through real builder cycles, architect runs with the current prompt and
1892
+ // the snapshot updates. Coupling prompt changes to invalidation causes a
1893
+ // corpus-wide synthesis storm (see #163).
1894
+ const architectChanged = isPromptStale(meta._architect, getDefaultArchitectPrompt());
1895
+ const criticChanged = isPromptStale(meta._critic, getDefaultCriticPrompt());
1896
+ const effectiveSynthesisCount = meta._synthesisCount ?? 0;
1897
+ // _crossRefs declaration change
1898
+ const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
1899
+ const archiveRefs = (latestArchive?._crossRefs ?? [])
1900
+ .slice()
1901
+ .sort()
1902
+ .join(',');
1903
+ const crossRefsDeclChanged = latestArchive
1904
+ ? currentRefs !== archiveRefs
1905
+ : currentRefs.length > 0;
1906
+ const architectInvalidators = [];
1907
+ if (structureChanged) {
1908
+ if (meta._state !== undefined) {
1909
+ // Progressive entity: new files → builder only (cursor handles incremental)
1910
+ phaseState = invalidateBuilder(phaseState);
2019
1911
  }
2020
1912
  else {
2021
- // Subsequent non-fresh: must not be pending or running
2022
- if (s === 'pending') {
2023
- result[phase] = 'stale';
2024
- }
2025
- // running in non-first position would be a bug, but don't mask it
1913
+ architectInvalidators.push('structureHash');
2026
1914
  }
2027
1915
  }
2028
- return result;
1916
+ if (steerChanged)
1917
+ architectInvalidators.push('steer');
1918
+ if (crossRefsDeclChanged)
1919
+ architectInvalidators.push('_crossRefs');
1920
+ if (effectiveSynthesisCount >= config.architectEvery) {
1921
+ architectInvalidators.push('architectEvery');
1922
+ }
1923
+ if (!meta._builder)
1924
+ architectInvalidators.push('firstRun');
1925
+ if (architectInvalidators.length > 0) {
1926
+ phaseState = invalidateArchitect(phaseState);
1927
+ }
1928
+ // ── Builder-level inputs ──
1929
+ // Cross-ref _content change (builder-invalidating)
1930
+ let crossRefContentChanged = false;
1931
+ return {
1932
+ phaseState,
1933
+ architectInvalidators,
1934
+ inputStatus: {
1935
+ structureHash,
1936
+ steerChanged,
1937
+ architectChanged,
1938
+ criticChanged,
1939
+ crossRefsDeclChanged,
1940
+ crossRefContentChanged,
1941
+ },
1942
+ };
2029
1943
  }
2030
- // ── Invalidation cascades ──────────────────────────────────────────────
1944
+
2031
1945
  /**
2032
- * Architect invalidated: architect → pending; builder, critic → stale.
2033
- * Triggers: first run, _structureHash change, _steer change,
2034
- * _crossRefs declaration change, _synthesisCount \>= architectEvery.
1946
+ * Weighted staleness formula for candidate selection.
1947
+ *
1948
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1949
+ *
1950
+ * @module scheduling/weightedFormula
2035
1951
  */
2036
- function invalidateArchitect(state) {
2037
- return enforceInvariant({
2038
- architect: state.architect === 'failed' ? 'failed' : 'pending',
2039
- builder: state.builder === 'fresh' ? 'stale' : state.builder,
2040
- critic: state.critic === 'fresh' ? 'stale' : state.critic,
2041
- });
2042
- }
2043
1952
  /**
2044
- * Builder invalidated (scope mtime or cross-ref _content change):
2045
- * builder → pending; critic → stale.
2046
- * Only applies when architect is fresh; otherwise, builder stays stale.
1953
+ * Compute effective staleness for a set of candidates.
1954
+ *
1955
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
1956
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1957
+ *
1958
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
1959
+ * metas to tune how much their tree position affects scheduling.
1960
+ *
1961
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
1962
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
1963
+ * @returns Same array with effectiveStaleness computed.
2047
1964
  */
2048
- function invalidateBuilder(state) {
2049
- if (state.architect !== 'fresh') {
2050
- // Architect is not fresh — builder stays stale (or whatever it is)
2051
- return enforceInvariant({
2052
- ...state,
2053
- builder: state.builder === 'fresh' || state.builder === 'stale'
2054
- ? 'stale'
2055
- : state.builder,
2056
- critic: state.critic === 'fresh' ? 'stale' : state.critic,
2057
- });
2058
- }
2059
- return enforceInvariant({
2060
- ...state,
2061
- builder: state.builder === 'failed' ? 'failed' : 'pending',
2062
- critic: state.critic === 'fresh' ? 'stale' : state.critic,
1965
+ function computeEffectiveStaleness(candidates, depthWeight) {
1966
+ if (candidates.length === 0)
1967
+ return [];
1968
+ // Get depth for each candidate: use _depth override or tree depth
1969
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
1970
+ // Normalize: shift so minimum becomes 0
1971
+ const minDepth = Math.min(...depths);
1972
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
1973
+ return candidates.map((c, i) => {
1974
+ const emphasis = c.meta._emphasis ?? 1;
1975
+ return {
1976
+ ...c,
1977
+ effectiveStaleness: c.actualStaleness *
1978
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
1979
+ };
2063
1980
  });
2064
1981
  }
2065
- // ── Phase success transitions ──────────────────────────────────────────
1982
+
2066
1983
  /**
2067
- * Architect completes successfully.
2068
- * architect → fresh; builder → pending; critic → stale.
1984
+ * Corpus-wide phase scheduler.
1985
+ *
1986
+ * Selects the highest-priority ready phase across all metas.
1987
+ * Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
1988
+ * Tiebreak within band: weighted staleness (§3.9).
1989
+ *
1990
+ * @module phaseState/phaseScheduler
2069
1991
  */
2070
- function architectSuccess(state) {
2071
- return enforceInvariant({
2072
- architect: 'fresh',
2073
- builder: state.builder === 'failed' ? 'failed' : 'pending',
2074
- critic: state.critic === 'fresh' ? 'stale' : state.critic,
2075
- });
2076
- }
2077
1992
  /**
2078
- * Builder completes successfully.
2079
- * builder → fresh; critic → pending.
1993
+ * Build phase candidates from listMetas entries.
1994
+ *
1995
+ * Derives phase state, auto-retries failed phases, and applies Tier 1
1996
+ * cheap-invalidation (no I/O) for metas with persisted _phaseState.
1997
+ * Used by orchestratePhase, queue route, and status route.
2080
1998
  */
2081
- function builderSuccess(state) {
2082
- return enforceInvariant({
2083
- ...state,
2084
- builder: 'fresh',
2085
- critic: state.critic === 'failed' ? 'failed' : 'pending',
1999
+ function buildPhaseCandidates(entries, architectEvery) {
2000
+ return entries.map((entry) => {
2001
+ let ps = retryAllFailed(derivePhaseState(entry.meta));
2002
+ // Tier 1 cheap invalidation for metas with persisted _phaseState
2003
+ if (entry.meta._phaseState) {
2004
+ const needsArchitect = !entry.meta._builder ||
2005
+ (entry.meta._synthesisCount ?? 0) >= architectEvery;
2006
+ if (needsArchitect && isFullyFresh(ps)) {
2007
+ ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
2008
+ }
2009
+ }
2010
+ return {
2011
+ node: entry.node,
2012
+ meta: entry.meta,
2013
+ phaseState: ps,
2014
+ actualStaleness: entry.stalenessSeconds,
2015
+ locked: entry.locked,
2016
+ disabled: entry.disabled,
2017
+ };
2086
2018
  });
2087
2019
  }
2088
2020
  /**
2089
- * Critic completes successfully.
2090
- * critic → fresh. Meta becomes fully fresh.
2021
+ * Rank all eligible phase candidates by priority.
2022
+ *
2023
+ * Filters to pending phases, computes effective staleness, and sorts by
2024
+ * band (ascending: critic first) then effective staleness (descending).
2025
+ *
2026
+ * Used by selectPhaseCandidate (returns first) and the queue route (returns all).
2091
2027
  */
2092
- function criticSuccess(state) {
2093
- return enforceInvariant({
2094
- ...state,
2095
- critic: 'fresh',
2028
+ function rankPhaseCandidates(metas, depthWeight) {
2029
+ // Filter to metas with a pending (scheduler-eligible) phase
2030
+ const eligible = metas.filter((m) => {
2031
+ if (m.locked)
2032
+ return false;
2033
+ if (m.disabled && !m.isOverride)
2034
+ return false;
2035
+ const owed = getOwedPhase(m.phaseState);
2036
+ if (!owed)
2037
+ return false;
2038
+ return m.phaseState[owed] === 'pending';
2096
2039
  });
2097
- }
2098
- // ── Failure transition ─────────────────────────────────────────────────
2099
- /**
2100
- * A phase fails (error, timeout, or abort).
2101
- * Target phase → failed; upstream and downstream unchanged.
2102
- */
2103
- function phaseFailed(state, phase) {
2104
- return enforceInvariant({
2105
- ...state,
2106
- [phase]: 'failed',
2040
+ if (eligible.length === 0)
2041
+ return [];
2042
+ // Compute effective staleness for tiebreaking
2043
+ const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
2044
+ node: m.node,
2045
+ meta: m.meta,
2046
+ actualStaleness: m.actualStaleness,
2047
+ })), depthWeight);
2048
+ // Build candidates with band info
2049
+ const candidates = withStaleness.map((ws, i) => {
2050
+ const m = eligible[i];
2051
+ const owedPhase = getOwedPhase(m.phaseState);
2052
+ return {
2053
+ node: ws.node,
2054
+ meta: ws.meta,
2055
+ phaseState: m.phaseState,
2056
+ owedPhase,
2057
+ band: getPriorityBand(m.phaseState),
2058
+ actualStaleness: ws.actualStaleness,
2059
+ effectiveStaleness: ws.effectiveStaleness,
2060
+ };
2107
2061
  });
2108
- }
2109
- // ── Surgical retry ─────────────────────────────────────────────────────
2110
- /**
2111
- * Retry a failed phase: failed → pending.
2112
- * Only valid when the phase is currently failed.
2113
- */
2114
- function retryPhase(state, phase) {
2115
- if (state[phase] !== 'failed')
2116
- return state;
2117
- return enforceInvariant({
2118
- ...state,
2119
- [phase]: 'pending',
2062
+ // Sort by band (ascending = critic first) then effective staleness (descending)
2063
+ candidates.sort((a, b) => {
2064
+ if (a.band !== b.band)
2065
+ return a.band - b.band;
2066
+ return b.effectiveStaleness - a.effectiveStaleness;
2120
2067
  });
2068
+ return candidates;
2121
2069
  }
2122
2070
  /**
2123
- * Retry all failed phases: each failed phase → pending.
2124
- * Used by scheduler ticks and queue reads to auto-promote failed phases.
2125
- */
2126
- function retryAllFailed(state) {
2127
- let result = state;
2128
- for (const phase of ['architect', 'builder', 'critic']) {
2129
- if (result[phase] === 'failed') {
2130
- result = retryPhase(result, phase);
2131
- }
2132
- }
2133
- return result;
2134
- }
2135
- // ── Running transition ─────────────────────────────────────────────────
2136
- /**
2137
- * Mark a phase as running (scheduler picks it).
2071
+ * Select the best phase candidate across the corpus.
2072
+ *
2073
+ * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
2074
+ * @param depthWeight - Config depthWeight for staleness tiebreak.
2075
+ * @returns The winning candidate, or null if no phase is ready.
2138
2076
  */
2139
- function phaseRunning(state, phase) {
2140
- return {
2141
- ...state,
2142
- [phase]: 'running',
2143
- };
2077
+ function selectPhaseCandidate(metas, depthWeight) {
2078
+ return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
2144
2079
  }
2145
- // ── Query helpers ──────────────────────────────────────────────────────
2146
2080
  /**
2147
- * Get the owed phase: first non-fresh phase in pipeline order, or null.
2081
+ * Select all fully-fresh, non-disabled, non-locked metas sorted by staleness
2082
+ * (descending — stalest first) for Tier 2 invalidation scanning.
2148
2083
  */
2149
- function getOwedPhase(state) {
2150
- for (const phase of ['architect', 'builder', 'critic']) {
2151
- if (state[phase] !== 'fresh')
2152
- return phase;
2153
- }
2154
- return null;
2084
+ function selectAllTier2Candidates(metas) {
2085
+ return metas
2086
+ .filter((m) => !m.locked && !m.disabled && isFullyFresh(m.phaseState))
2087
+ .sort((a, b) => b.actualStaleness - a.actualStaleness)
2088
+ .map((m) => ({ node: m.node, meta: m.meta }));
2155
2089
  }
2090
+
2156
2091
  /**
2157
- * Check if a meta is fully fresh (all phases fresh).
2092
+ * Shared error utilities.
2093
+ *
2094
+ * @module errors
2158
2095
  */
2159
- function isFullyFresh(state) {
2160
- return (state.architect === 'fresh' &&
2161
- state.builder === 'fresh' &&
2162
- state.critic === 'fresh');
2163
- }
2164
2096
  /**
2165
- * Get the scheduler priority band for a meta's owed phase.
2166
- * 1 = critic (highest), 2 = builder, 3 = architect, null = fully fresh.
2097
+ * Wrap an unknown caught value into a MetaError.
2098
+ *
2099
+ * @param step - Which synthesis step failed.
2100
+ * @param err - The caught error value.
2101
+ * @param code - Error classification code.
2102
+ * @returns A structured MetaError.
2167
2103
  */
2168
- function getPriorityBand(state) {
2169
- const owed = getOwedPhase(state);
2170
- if (!owed)
2171
- return null;
2172
- if (owed === 'critic')
2173
- return 1;
2174
- if (owed === 'builder')
2175
- return 2;
2176
- return 3;
2104
+ function toMetaError(step, err, code = 'FAILED') {
2105
+ return {
2106
+ step,
2107
+ code,
2108
+ message: err instanceof Error ? err.message : String(err),
2109
+ };
2177
2110
  }
2178
2111
 
2179
2112
  /**
2180
- * Backward-compatible derivation of _phaseState from existing meta fields.
2113
+ * Build the MetaContext for a synthesis cycle.
2181
2114
  *
2182
- * When a meta is loaded from disk without _phaseState, this reconstructs
2183
- * the phase state from _content, _builder, _state, _error.step, and
2184
- * the architect-invalidating inputs.
2115
+ * Computes shared inputs once: scope files, delta files, child meta outputs,
2116
+ * previous content/feedback, steer, and archive paths.
2185
2117
  *
2186
- * @module phaseState/derivePhaseState
2118
+ * @module orchestrator/contextPackage
2187
2119
  */
2188
2120
  /**
2189
- * Derive _phaseState from existing meta fields.
2190
- *
2191
- * If the meta already has _phaseState, returns it as-is.
2192
- *
2193
- * Otherwise, reconstructs from available fields:
2194
- * - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
2195
- * - Errored meta: the failed phase is mapped from _error.step.
2196
- * - Mid-cycle meta with cached _builder but no _content: builder pending.
2197
- * - Fully-fresh meta: all phases fresh.
2198
- * - Meta with stale architect inputs: architect pending, downstream stale.
2121
+ * Condense a file list into glob-like summaries.
2122
+ * Groups by directory + extension pattern.
2199
2123
  *
2200
- * @param meta - The meta.json content.
2201
- * @param inputs - Optional derivation inputs. If not provided, a simpler
2202
- * heuristic is used (no architect invalidation check).
2203
- * @returns The derived PhaseState.
2124
+ * @param files - Array of file paths.
2125
+ * @param maxIndividual - Show individual files up to this count.
2126
+ * @returns Condensed summary string.
2204
2127
  */
2205
- function derivePhaseState(meta, inputs) {
2206
- // Already has _phaseState — use it
2207
- if (meta._phaseState)
2208
- return meta._phaseState;
2209
- // Check for errors first — _error.step maps directly to failed phase
2210
- if (meta._error) {
2211
- const failedPhase = meta._error.step;
2212
- const state = freshPhaseState();
2213
- state[failedPhase] = 'failed';
2214
- // If architect failed and no _builder, downstream is stale
2215
- if (failedPhase === 'architect') {
2216
- if (!meta._builder) {
2217
- state.builder = 'stale';
2218
- state.critic = 'stale';
2219
- }
2220
- }
2221
- // If builder failed, critic is stale
2222
- if (failedPhase === 'builder') {
2223
- state.critic = 'stale';
2224
- }
2225
- return state;
2226
- }
2227
- // Never synthesized: no _content AND no _builder (and no error)
2228
- if (!meta._content && !meta._builder) {
2229
- return initialPhaseState();
2128
+ function condenseScopeFiles(files, maxIndividual = 30) {
2129
+ if (files.length <= maxIndividual)
2130
+ return files.join('\n');
2131
+ // Group by dir + extension
2132
+ const groups = new Map();
2133
+ for (const f of files) {
2134
+ const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
2135
+ const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
2136
+ const key = dir + '*' + ext;
2137
+ groups.set(key, (groups.get(key) ?? 0) + 1);
2230
2138
  }
2231
- // Has _builder but no _content: builder is pending
2232
- if (meta._builder && !meta._content) {
2139
+ // Sort by count descending
2140
+ const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
2141
+ return sorted
2142
+ .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
2143
+ .join('\n');
2144
+ }
2145
+ /**
2146
+ * Read a meta.json file and extract `_content` and `_generatedAt`.
2147
+ *
2148
+ * @param metaJsonPath - Absolute path to a meta.json file.
2149
+ * @returns Parsed fields, or defaults if missing/unreadable.
2150
+ */
2151
+ async function readChildMeta(metaJsonPath) {
2152
+ try {
2153
+ const raw = await readFile(metaJsonPath, 'utf8');
2154
+ const meta = JSON.parse(raw);
2233
2155
  return {
2234
- architect: 'fresh',
2235
- builder: 'pending',
2236
- critic: 'stale',
2156
+ content: meta._content ?? null,
2157
+ generatedAt: meta._generatedAt,
2237
2158
  };
2238
2159
  }
2239
- // Has _content but no _feedback: critic is pending
2240
- if (meta._content && !meta._feedback) {
2241
- return {
2242
- architect: 'fresh',
2243
- builder: 'fresh',
2244
- critic: 'pending',
2245
- };
2160
+ catch {
2161
+ return { content: null, generatedAt: undefined };
2246
2162
  }
2247
- // Default: fully fresh
2248
- return freshPhaseState();
2249
2163
  }
2250
-
2251
- /**
2252
- * Compute a structure hash from a sorted file listing.
2253
- *
2254
- * Used to detect when directory structure changes, triggering
2255
- * an architect re-run.
2256
- *
2257
- * @module structureHash
2258
- */
2259
2164
  /**
2260
- * Compute a SHA-256 hash of a sorted file listing.
2165
+ * Read a meta.json file and extract its `_content` field.
2261
2166
  *
2262
- * @param filePaths - Array of file paths in scope.
2263
- * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
2167
+ * @param metaJsonPath - Absolute path to a meta.json file.
2168
+ * @returns The `_content` string, or null if missing/unreadable.
2264
2169
  */
2265
- function computeStructureHash(filePaths) {
2266
- const sorted = [...filePaths].sort();
2267
- const content = sorted.join('\n');
2268
- return createHash('sha256').update(content).digest('hex');
2170
+ async function readMetaContent(metaJsonPath) {
2171
+ const info = await readChildMeta(metaJsonPath);
2172
+ return info.content;
2269
2173
  }
2270
-
2271
2174
  /**
2272
- * Per-tick invalidation pass.
2273
- *
2274
- * Computes architect-invalidating and builder-invalidating inputs for a meta,
2275
- * then applies the cascade to update _phaseState.
2175
+ * Build the context package for a synthesis cycle.
2276
2176
  *
2277
- * @module phaseState/invalidate
2278
- */
2279
- /**
2280
- * Check whether a persisted prompt snapshot mismatches the currently-resolved prompt.
2281
- * Returns true when the snapshot exists and differs from the resolved prompt.
2282
- * This is informational only — it does NOT trigger invalidation.
2177
+ * @param node - The meta node being synthesized.
2178
+ * @param meta - Current meta.json content.
2179
+ * @param watcher - WatcherClient for scope enumeration.
2180
+ * @returns The computed context package.
2283
2181
  */
2284
- function isPromptStale(snapshot, resolved) {
2285
- return snapshot !== undefined && snapshot !== resolved;
2286
- }
2287
- /**
2288
- * Compute invalidation inputs and apply cascade for a single meta.
2289
- *
2290
- * @param meta - Current meta.json content with existing _phaseState.
2291
- * @param scopeFiles - Sorted file list from scope.
2292
- * @param config - MetaConfig for architectEvery.
2293
- * @param node - MetaNode for archive access.
2294
- * @param crossRefMetas - Map of cross-ref owner paths to their current _content.
2295
- * @param archiveCrossRefContent - Map of cross-ref owner paths to their archived _content.
2296
- * @returns Updated phase state and invalidation details.
2297
- */
2298
- async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas, archiveCrossRefContent) {
2299
- let phaseState = meta._phaseState ?? {
2300
- architect: 'fresh',
2301
- builder: 'fresh',
2302
- critic: 'fresh',
2303
- };
2304
- // ── Architect-level inputs ──
2305
- const structureHash = computeStructureHash(scopeFiles);
2306
- const structureChanged = structureHash !== meta._structureHash;
2307
- const latestArchive = await readLatestArchive(node.metaPath);
2308
- const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2309
- // Prompt staleness detection: compare persisted prompt snapshots against
2310
- // currently-resolved prompts. This is INFORMATIONAL ONLY — reported via
2311
- // inputStatus so /preview can surface it, but it must NEVER feed into
2312
- // the invalidation cascade. When a meta naturally reaches architectEvery
2313
- // through real builder cycles, architect runs with the current prompt and
2314
- // the snapshot updates. Coupling prompt changes to invalidation causes a
2315
- // corpus-wide synthesis storm (see #163).
2316
- const architectChanged = isPromptStale(meta._architect, DEFAULT_ARCHITECT_PROMPT);
2317
- const criticChanged = isPromptStale(meta._critic, DEFAULT_CRITIC_PROMPT);
2318
- const effectiveSynthesisCount = meta._synthesisCount ?? 0;
2319
- // _crossRefs declaration change
2320
- const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
2321
- const archiveRefs = (latestArchive?._crossRefs ?? [])
2322
- .slice()
2323
- .sort()
2324
- .join(',');
2325
- const crossRefsDeclChanged = latestArchive
2326
- ? currentRefs !== archiveRefs
2327
- : currentRefs.length > 0;
2328
- const architectInvalidators = [];
2329
- if (structureChanged) {
2330
- if (meta._state !== undefined) {
2331
- // Progressive entity: new files → builder only (cursor handles incremental)
2332
- phaseState = invalidateBuilder(phaseState);
2182
+ async function buildContextPackage(node, meta, watcher, logger) {
2183
+ // Scope and delta files via watcher walk
2184
+ const scopeStart = Date.now();
2185
+ const { scopeFiles } = await getScopeFiles(node, watcher, logger);
2186
+ const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
2187
+ logger?.debug({
2188
+ scopeFiles: scopeFiles.length,
2189
+ deltaFiles: deltaFiles.length,
2190
+ durationMs: Date.now() - scopeStart,
2191
+ }, 'scope and delta files computed');
2192
+ // Child meta outputs (parallel reads, delta-aware).
2193
+ // Only include full _content for children synthesized AFTER the parent's
2194
+ // _generatedAt. Non-delta children are already folded into the parent's
2195
+ // previous _content and are listed as path-only references (#169).
2196
+ const parentGeneratedAt = meta._generatedAt;
2197
+ const childMetas = {};
2198
+ const childEntries = await Promise.all(node.children.map(async (child) => {
2199
+ const info = await readChildMeta(join(child.metaPath, 'meta.json'));
2200
+ return [child.ownerPath, info];
2201
+ }));
2202
+ let deltaChildCount = 0;
2203
+ for (const [path, info] of childEntries) {
2204
+ // Delta child: no parent timestamp (first run), or child synthesized
2205
+ // after parent. Include full content.
2206
+ if (!parentGeneratedAt ||
2207
+ !info.generatedAt ||
2208
+ info.generatedAt > parentGeneratedAt) {
2209
+ childMetas[path] = info.content ?? undefined;
2210
+ deltaChildCount++;
2333
2211
  }
2334
2212
  else {
2335
- architectInvalidators.push('structureHash');
2213
+ // Non-delta: already incorporated in previous synthesis.
2214
+ // Include path with null content so the builder knows it exists.
2215
+ childMetas[path] = null;
2336
2216
  }
2337
2217
  }
2338
- if (steerChanged)
2339
- architectInvalidators.push('steer');
2340
- if (crossRefsDeclChanged)
2341
- architectInvalidators.push('_crossRefs');
2342
- if (effectiveSynthesisCount >= config.architectEvery) {
2343
- architectInvalidators.push('architectEvery');
2218
+ logger?.debug({
2219
+ totalChildren: childEntries.length,
2220
+ deltaChildren: deltaChildCount,
2221
+ }, 'child metas filtered by delta');
2222
+ // Cross-referenced meta outputs (parallel reads)
2223
+ const crossRefMetas = {};
2224
+ const seen = new Set();
2225
+ const crossRefPaths = [];
2226
+ for (const refPath of meta._crossRefs ?? []) {
2227
+ if (refPath === node.ownerPath || refPath === node.metaPath)
2228
+ continue;
2229
+ if (seen.has(refPath))
2230
+ continue;
2231
+ seen.add(refPath);
2232
+ crossRefPaths.push(refPath);
2344
2233
  }
2345
- if (!meta._builder)
2346
- architectInvalidators.push('firstRun');
2347
- if (architectInvalidators.length > 0) {
2348
- phaseState = invalidateArchitect(phaseState);
2234
+ const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
2235
+ const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
2236
+ return [refPath, content];
2237
+ }));
2238
+ for (const [path, content] of crossRefEntries) {
2239
+ crossRefMetas[path] = content;
2240
+ }
2241
+ // Archive paths
2242
+ const archives = listArchiveFiles(node.metaPath);
2243
+ // Nearest ancestor _builder output
2244
+ let ancestorBuilder;
2245
+ const ancestor = getAncestorMeta(node);
2246
+ if (ancestor) {
2247
+ try {
2248
+ const raw = await readFile(join(ancestor.metaPath, 'meta.json'), 'utf8');
2249
+ const ancestorMeta = JSON.parse(raw);
2250
+ if (ancestorMeta._builder) {
2251
+ ancestorBuilder = ancestorMeta._builder;
2252
+ }
2253
+ }
2254
+ catch {
2255
+ // Ancestor meta.json unreadable — skip
2256
+ }
2349
2257
  }
2350
- // ── Builder-level inputs ──
2351
- // Cross-ref _content change (builder-invalidating)
2352
- let crossRefContentChanged = false;
2353
2258
  return {
2354
- phaseState,
2355
- architectInvalidators,
2356
- inputStatus: {
2357
- structureHash,
2358
- steerChanged,
2359
- architectChanged,
2360
- criticChanged,
2361
- crossRefsDeclChanged,
2362
- crossRefContentChanged,
2363
- },
2259
+ path: node.metaPath,
2260
+ scopeFiles,
2261
+ deltaFiles,
2262
+ childMetas,
2263
+ crossRefMetas,
2264
+ previousContent: meta._content ?? null,
2265
+ previousFeedback: meta._feedback ?? null,
2266
+ steer: meta._steer ?? null,
2267
+ previousState: meta._state ?? null,
2268
+ archives,
2269
+ ancestorBuilder,
2364
2270
  };
2365
2271
  }
2366
2272
 
2367
2273
  /**
2368
- * Weighted staleness formula for candidate selection.
2369
- *
2370
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
2371
- *
2372
- * @module scheduling/weightedFormula
2373
- */
2374
- /**
2375
- * Compute effective staleness for a set of candidates.
2376
- *
2377
- * Normalizes depths so the minimum becomes 0, then applies the formula:
2378
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
2274
+ * Build task prompts for each synthesis step.
2379
2275
  *
2380
- * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
2381
- * metas to tune how much their tree position affects scheduling.
2276
+ * Prompts are compiled as Handlebars templates with access to config,
2277
+ * meta, and scope context. The architect can write template expressions
2278
+ * into its _builder output; these resolve when the builder task is compiled.
2382
2279
  *
2383
- * @param candidates - Array of \{ node, meta, actualStaleness \}.
2384
- * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
2385
- * @returns Same array with effectiveStaleness computed.
2280
+ * @module orchestrator/buildTask
2386
2281
  */
2387
- function computeEffectiveStaleness(candidates, depthWeight) {
2388
- if (candidates.length === 0)
2389
- return [];
2390
- // Get depth for each candidate: use _depth override or tree depth
2391
- const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
2392
- // Normalize: shift so minimum becomes 0
2393
- const minDepth = Math.min(...depths);
2394
- const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
2395
- return candidates.map((c, i) => {
2396
- const emphasis = c.meta._emphasis ?? 1;
2397
- return {
2398
- ...c,
2399
- effectiveStaleness: c.actualStaleness *
2400
- Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
2401
- };
2402
- });
2282
+ Handlebars.registerHelper('gt', (a, b) => a > b);
2283
+ /** Build the template context from synthesis inputs. */
2284
+ function buildTemplateContext(ctx, meta, config) {
2285
+ return {
2286
+ config,
2287
+ meta,
2288
+ scope: {
2289
+ fileCount: ctx.scopeFiles.length,
2290
+ deltaCount: ctx.deltaFiles.length,
2291
+ childCount: Object.keys(ctx.childMetas).length,
2292
+ crossRefCount: Object.keys(ctx.crossRefMetas).length,
2293
+ },
2294
+ };
2403
2295
  }
2404
-
2405
2296
  /**
2406
- * Corpus-wide phase scheduler.
2407
- *
2408
- * Selects the highest-priority ready phase across all metas.
2409
- * Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
2410
- * Tiebreak within band: weighted staleness (§3.9).
2411
- *
2412
- * @module phaseState/phaseScheduler
2297
+ * Compile a string as a Handlebars template with the given context.
2298
+ * Returns the original string unchanged if compilation fails.
2413
2299
  */
2300
+ function compileTemplate(text, context) {
2301
+ try {
2302
+ return Handlebars.compile(text, { noEscape: true })(context);
2303
+ }
2304
+ catch {
2305
+ return text;
2306
+ }
2307
+ }
2414
2308
  /**
2415
- * Build phase candidates from listMetas entries.
2309
+ * Append a keyed record of meta outputs as subsections, if non-empty.
2416
2310
  *
2417
- * Derives phase state, auto-retries failed phases, and applies Tier 1
2418
- * cheap-invalidation (no I/O) for metas with persisted _phaseState.
2419
- * Used by orchestratePhase, queue route, and status route.
2311
+ * @param condenseMissing - When true, null entries are condensed into a
2312
+ * path listing (delta-aware child metas). When false, null entries get
2313
+ * individual subsections with "(not yet synthesized)" (cross-refs).
2420
2314
  */
2421
- function buildPhaseCandidates(entries, architectEvery) {
2422
- return entries.map((entry) => {
2423
- let ps = retryAllFailed(derivePhaseState(entry.meta));
2424
- // Tier 1 cheap invalidation for metas with persisted _phaseState
2425
- if (entry.meta._phaseState) {
2426
- const needsArchitect = !entry.meta._builder ||
2427
- (entry.meta._synthesisCount ?? 0) >= architectEvery;
2428
- if (needsArchitect && isFullyFresh(ps)) {
2429
- ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
2430
- }
2315
+ function appendMetaSections(sections, heading, metas, condenseMissing = false) {
2316
+ if (Object.keys(metas).length === 0)
2317
+ return;
2318
+ if (!condenseMissing) {
2319
+ // Original behavior: every entry gets a subsection.
2320
+ sections.push('', heading);
2321
+ for (const [path, content] of Object.entries(metas)) {
2322
+ sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
2431
2323
  }
2432
- return {
2433
- node: entry.node,
2434
- meta: entry.meta,
2435
- phaseState: ps,
2436
- actualStaleness: entry.stalenessSeconds,
2437
- locked: entry.locked,
2438
- disabled: entry.disabled,
2439
- };
2440
- });
2324
+ return;
2325
+ }
2326
+ // Delta-aware: separate entries into three categories (#169).
2327
+ // - string content → full subsection (delta child with content)
2328
+ // - null → non-delta, already folded into previous synthesis
2329
+ // - undefined → delta child not yet synthesized
2330
+ const withContent = [];
2331
+ const unchanged = [];
2332
+ const notYetSynthesized = [];
2333
+ for (const [path, content] of Object.entries(metas)) {
2334
+ if (typeof content === 'string') {
2335
+ withContent.push([path, content]);
2336
+ }
2337
+ else if (content === null) {
2338
+ unchanged.push(path);
2339
+ }
2340
+ else {
2341
+ notYetSynthesized.push(path);
2342
+ }
2343
+ }
2344
+ sections.push('', heading);
2345
+ for (const [path, content] of withContent) {
2346
+ sections.push(`### ${path}`, content);
2347
+ }
2348
+ if (notYetSynthesized.length > 0) {
2349
+ sections.push('', `### Not yet synthesized (${notYetSynthesized.length.toString()})`, ...notYetSynthesized.map((p) => `- ${p}`));
2350
+ }
2351
+ if (unchanged.length > 0) {
2352
+ sections.push('', `### Other children (${unchanged.length.toString()} \u2014 unchanged since last synthesis)`, ...unchanged.map((p) => `- ${p}`));
2353
+ }
2354
+ }
2355
+ /** Inject nearest ancestor's organizational context, if available. */
2356
+ function appendAncestorContext(sections, ctx) {
2357
+ if (ctx.ancestorBuilder) {
2358
+ sections.push('', '## PARENT ORGANIZATIONAL CONTEXT', ctx.ancestorBuilder);
2359
+ }
2360
+ }
2361
+ /** Append optional context sections shared across all step prompts. */
2362
+ function appendSharedSections(sections, ctx, options) {
2363
+ const opts = {
2364
+ includeSteer: true,
2365
+ includePreviousContent: true,
2366
+ includePreviousFeedback: true,
2367
+ feedbackHeading: '## PREVIOUS FEEDBACK',
2368
+ includeChildMetas: true,
2369
+ includeCrossRefs: true,
2370
+ ...options,
2371
+ };
2372
+ if (opts.includeSteer && ctx.steer) {
2373
+ sections.push('', '## STEERING PROMPT', ctx.steer);
2374
+ }
2375
+ if (opts.includePreviousContent && ctx.previousContent) {
2376
+ sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
2377
+ }
2378
+ if (opts.includePreviousFeedback && ctx.previousFeedback) {
2379
+ sections.push('', opts.feedbackHeading, ctx.previousFeedback);
2380
+ }
2381
+ if (opts.includeChildMetas) {
2382
+ appendMetaSections(sections, '## CHILD META OUTPUTS', ctx.childMetas, true);
2383
+ }
2384
+ if (opts.includeCrossRefs) {
2385
+ appendMetaSections(sections, '## CROSS-REFERENCED METAS', ctx.crossRefMetas);
2386
+ }
2441
2387
  }
2442
2388
  /**
2443
- * Rank all eligible phase candidates by priority.
2444
- *
2445
- * Filters to pending phases, computes effective staleness, and sorts by
2446
- * band (ascending: critic first) then effective staleness (descending).
2389
+ * Build the architect task prompt.
2447
2390
  *
2448
- * Used by selectPhaseCandidate (returns first) and the queue route (returns all).
2391
+ * @param ctx - Synthesis context.
2392
+ * @param meta - Current meta.json.
2393
+ * @param config - Synthesis config.
2394
+ * @returns The architect task prompt string.
2449
2395
  */
2450
- function rankPhaseCandidates(metas, depthWeight) {
2451
- // Filter to metas with a pending (scheduler-eligible) phase
2452
- const eligible = metas.filter((m) => {
2453
- if (m.locked)
2454
- return false;
2455
- if (m.disabled && !m.isOverride)
2456
- return false;
2457
- const owed = getOwedPhase(m.phaseState);
2458
- if (!owed)
2459
- return false;
2460
- return m.phaseState[owed] === 'pending';
2461
- });
2462
- if (eligible.length === 0)
2463
- return [];
2464
- // Compute effective staleness for tiebreaking
2465
- const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
2466
- node: m.node,
2467
- meta: m.meta,
2468
- actualStaleness: m.actualStaleness,
2469
- })), depthWeight);
2470
- // Build candidates with band info
2471
- const candidates = withStaleness.map((ws, i) => {
2472
- const m = eligible[i];
2473
- const owedPhase = getOwedPhase(m.phaseState);
2474
- return {
2475
- node: ws.node,
2476
- meta: ws.meta,
2477
- phaseState: m.phaseState,
2478
- owedPhase,
2479
- band: getPriorityBand(m.phaseState),
2480
- actualStaleness: ws.actualStaleness,
2481
- effectiveStaleness: ws.effectiveStaleness,
2482
- };
2483
- });
2484
- // Sort by band (ascending = critic first) then effective staleness (descending)
2485
- candidates.sort((a, b) => {
2486
- if (a.band !== b.band)
2487
- return a.band - b.band;
2488
- return b.effectiveStaleness - a.effectiveStaleness;
2489
- });
2490
- return candidates;
2396
+ function buildArchitectTask(ctx, meta, config) {
2397
+ const sections = [
2398
+ `# jeeves-meta · ARCHITECT · ${ctx.path}`,
2399
+ '',
2400
+ getDefaultArchitectPrompt(),
2401
+ '',
2402
+ '## SCOPE',
2403
+ `Path: ${ctx.path}`,
2404
+ `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
2405
+ `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
2406
+ '',
2407
+ '### File listing (scope)',
2408
+ condenseScopeFiles(ctx.scopeFiles),
2409
+ ];
2410
+ appendAncestorContext(sections, ctx);
2411
+ // Inject previous _builder so architect can see its own prior output
2412
+ if (meta._builder) {
2413
+ sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
2414
+ }
2415
+ appendSharedSections(sections, ctx);
2416
+ if (ctx.archives.length > 0) {
2417
+ sections.push('', '## ARCHIVE HISTORY', `${ctx.archives.length.toString()} previous synthesis snapshots available in .meta/archive/.`, 'Review these to understand how the synthesis has evolved over time.');
2418
+ }
2419
+ return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
2491
2420
  }
2492
2421
  /**
2493
- * Select the best phase candidate across the corpus.
2422
+ * Build the builder task prompt.
2494
2423
  *
2495
- * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
2496
- * @param depthWeight - Config depthWeight for staleness tiebreak.
2497
- * @returns The winning candidate, or null if no phase is ready.
2498
- */
2499
- function selectPhaseCandidate(metas, depthWeight) {
2500
- return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
2501
- }
2502
- /**
2503
- * Select all fully-fresh, non-disabled, non-locked metas sorted by staleness
2504
- * (descending — stalest first) for Tier 2 invalidation scanning.
2424
+ * @param ctx - Synthesis context.
2425
+ * @param meta - Current meta.json.
2426
+ * @param config - Synthesis config.
2427
+ * @returns The builder task prompt string.
2505
2428
  */
2506
- function selectAllTier2Candidates(metas) {
2507
- return metas
2508
- .filter((m) => !m.locked && !m.disabled && isFullyFresh(m.phaseState))
2509
- .sort((a, b) => b.actualStaleness - a.actualStaleness)
2510
- .map((m) => ({ node: m.node, meta: m.meta }));
2429
+ function buildBuilderTask(ctx, meta, config) {
2430
+ const sections = [
2431
+ `# jeeves-meta · BUILDER · ${ctx.path}`,
2432
+ '',
2433
+ '## TASK BRIEF (from Architect)',
2434
+ meta._builder ?? '(No architect brief available)',
2435
+ '',
2436
+ '## SCOPE',
2437
+ `Path: ${ctx.path}`,
2438
+ `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
2439
+ ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
2440
+ ];
2441
+ appendAncestorContext(sections, ctx);
2442
+ if (ctx.previousState != null) {
2443
+ sections.push('', '## PREVIOUS STATE', 'The following opaque state was returned by the previous synthesis cycle.', 'Use it to continue progressive work. Update `_state` in your output to', 'reflect your progress.', '', '```json', JSON.stringify(ctx.previousState, null, 2), '```');
2444
+ }
2445
+ appendSharedSections(sections, ctx, {
2446
+ includeSteer: false,
2447
+ feedbackHeading: '## FEEDBACK FROM CRITIC',
2448
+ });
2449
+ sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" },', ' "_state": { "description": "Opaque state object for progressive work across cycles" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '_state is optional: set it to carry state across synthesis cycles for progressive work.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.', '', 'IMPORTANT: Do not call sessions_spawn or sessions_yield. Read all files directly using the tools', 'available in your session. Do not attempt to parallelize work by spawning sub-agents.');
2450
+ return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
2511
2451
  }
2512
-
2513
- /**
2514
- * Shared error utilities.
2515
- *
2516
- * @module errors
2517
- */
2518
2452
  /**
2519
- * Wrap an unknown caught value into a MetaError.
2453
+ * Build the critic task prompt.
2520
2454
  *
2521
- * @param step - Which synthesis step failed.
2522
- * @param err - The caught error value.
2523
- * @param code - Error classification code.
2524
- * @returns A structured MetaError.
2455
+ * @param ctx - Synthesis context.
2456
+ * @param meta - Current meta.json (with _content already set by builder).
2457
+ * @param config - Synthesis config.
2458
+ * @returns The critic task prompt string.
2525
2459
  */
2526
- function toMetaError(step, err, code = 'FAILED') {
2527
- return {
2528
- step,
2529
- code,
2530
- message: err instanceof Error ? err.message : String(err),
2531
- };
2460
+ function buildCriticTask(ctx, meta, config) {
2461
+ const sections = [
2462
+ `# jeeves-meta · CRITIC · ${ctx.path}`,
2463
+ '',
2464
+ getDefaultCriticPrompt(),
2465
+ '',
2466
+ '## SYNTHESIS TO EVALUATE',
2467
+ meta._content ?? '(No content produced)',
2468
+ '',
2469
+ '## SCOPE',
2470
+ `Path: ${ctx.path}`,
2471
+ `Files in scope: ${ctx.scopeFiles.length.toString()}`,
2472
+ ];
2473
+ appendSharedSections(sections, ctx, {
2474
+ includePreviousContent: false,
2475
+ feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
2476
+ includeChildMetas: false,
2477
+ includeCrossRefs: false,
2478
+ });
2479
+ sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
2480
+ return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
2532
2481
  }
2533
2482
 
2534
2483
  /**
@@ -2723,7 +2672,7 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2723
2672
  ps = architectSuccess(ps);
2724
2673
  const architectUpdates = {
2725
2674
  _builder: builderBrief,
2726
- _architect: DEFAULT_ARCHITECT_PROMPT,
2675
+ _architect: getDefaultArchitectPrompt(),
2727
2676
  _synthesisCount: 0,
2728
2677
  _architectTokens: architectTokens,
2729
2678
  _generatedAt: new Date().toISOString(),
@@ -2876,7 +2825,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
2876
2825
  const cycleComplete = isFullyFresh(ps);
2877
2826
  const updates = {
2878
2827
  _feedback: feedback,
2879
- _critic: DEFAULT_CRITIC_PROMPT,
2828
+ _critic: getDefaultCriticPrompt(),
2880
2829
  _criticTokens: criticTokens,
2881
2830
  _error: undefined,
2882
2831
  };
@@ -4170,6 +4119,100 @@ function registerConfigApplyRoute(app, configPath) {
4170
4119
  });
4171
4120
  }
4172
4121
 
4122
+ /**
4123
+ * Archive reading helpers — watcher scan with filesystem fallback.
4124
+ *
4125
+ * Used by the GET /metas/:path route to retrieve archive history.
4126
+ * Prefers watcher scan for performance; falls back to disk reads
4127
+ * when the watcher is unavailable.
4128
+ *
4129
+ * @module archive/readArchive
4130
+ */
4131
+ /** Build a Qdrant filter for archive points under a given meta path. */
4132
+ function buildArchiveScanFilter(metaPath, metaArchiveProperty) {
4133
+ const must = [
4134
+ {
4135
+ key: 'file_path',
4136
+ match: { text: normalizePath(join(metaPath, 'archive')) },
4137
+ },
4138
+ ];
4139
+ for (const [key, value] of Object.entries(metaArchiveProperty)) {
4140
+ must.push({ key, match: { value } });
4141
+ }
4142
+ return { must };
4143
+ }
4144
+ /** Extract file_path from a scan point payload. */
4145
+ function getArchiveFilePath(point) {
4146
+ const value = point.payload?.file_path;
4147
+ return typeof value === 'string' ? value : '';
4148
+ }
4149
+ /** Project archive metadata, stripping indexing-internal fields. */
4150
+ function projectArchivePayload(point, projectMeta) {
4151
+ const payload = point.payload ?? {};
4152
+ if (Object.keys(payload).length === 0)
4153
+ return null;
4154
+ const archiveMeta = { ...payload };
4155
+ delete archiveMeta.file_path;
4156
+ delete archiveMeta.chunk_text;
4157
+ delete archiveMeta.chunk_index;
4158
+ delete archiveMeta.total_chunks;
4159
+ delete archiveMeta.content_hash;
4160
+ delete archiveMeta.matched_rules;
4161
+ return projectMeta(archiveMeta);
4162
+ }
4163
+ /**
4164
+ * Read archive history via watcher scan.
4165
+ *
4166
+ * @param watcher - WatcherClient with scan support.
4167
+ * @param metaPath - Absolute path to the .meta/ directory.
4168
+ * @param metaArchiveProperty - Additional filter properties for archive points.
4169
+ * @param limit - Max entries to return (newest first). Undefined = all.
4170
+ * @param projectMeta - Field projection function.
4171
+ * @returns Array of projected archive entries, newest first.
4172
+ */
4173
+ async function readArchiveFromWatcher(watcher, metaPath, metaArchiveProperty, limit, projectMeta) {
4174
+ if (limit === 0)
4175
+ return [];
4176
+ if (!watcher.scan) {
4177
+ throw new Error('Watcher scan not available');
4178
+ }
4179
+ const points = [];
4180
+ let cursor;
4181
+ do {
4182
+ const result = await watcher.scan({
4183
+ filter: buildArchiveScanFilter(metaPath, metaArchiveProperty),
4184
+ limit: 100,
4185
+ cursor,
4186
+ });
4187
+ points.push(...result.points);
4188
+ cursor = result.cursor ?? undefined;
4189
+ } while (cursor);
4190
+ const sorted = points.sort((a, b) => getArchiveFilePath(a).localeCompare(getArchiveFilePath(b)));
4191
+ return sorted
4192
+ .slice(limit ? -limit : 0)
4193
+ .reverse()
4194
+ .map((point) => projectArchivePayload(point, projectMeta))
4195
+ .filter((value) => value !== null);
4196
+ }
4197
+ /**
4198
+ * Read archive history from disk (fallback).
4199
+ *
4200
+ * @param metaPath - Absolute path to the .meta/ directory.
4201
+ * @param limit - Max entries to return (newest first). Undefined = all.
4202
+ * @param projectMeta - Field projection function.
4203
+ * @returns Array of projected archive entries, newest first.
4204
+ */
4205
+ async function readArchiveFromDisk(metaPath, limit, projectMeta) {
4206
+ if (limit === 0)
4207
+ return [];
4208
+ const archiveFiles = listArchiveFiles(metaPath);
4209
+ const selected = (limit ? archiveFiles.slice(-limit) : archiveFiles).reverse();
4210
+ return Promise.all(selected.map(async (archiveFile) => {
4211
+ const raw = await readFile(archiveFile, 'utf8');
4212
+ return projectMeta(JSON.parse(raw));
4213
+ }));
4214
+ }
4215
+
4173
4216
  /**
4174
4217
  * GET /metas — list metas with optional filters.
4175
4218
  * GET /metas/:path — single meta detail.
@@ -5551,8 +5594,8 @@ function registerCustomCliCommands(program) {
5551
5594
  /**
5552
5595
  * Jeeves component descriptor for jeeves-meta.
5553
5596
  *
5554
- * Single source of truth consumed by the service CLI, plugin writer, and
5555
- * config-apply pipeline.
5597
+ * Single source of truth consumed by the service CLI and the config-apply
5598
+ * pipeline.
5556
5599
  *
5557
5600
  * @module descriptor
5558
5601
  */
@@ -5565,8 +5608,6 @@ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
5565
5608
  servicePackage: META_COMPONENT.servicePackage,
5566
5609
  pluginPackage: META_COMPONENT.pluginPackage,
5567
5610
  defaultPort: META_COMPONENT.defaultPort,
5568
- // The runtime Zod custom validator only checks for a .parse() method.
5569
- // Use unknown cast to bridge the Zod v4 (service) → v3 (core SDK) type gap.
5570
5611
  configSchema: serviceConfigSchema,
5571
5612
  configFileName: 'config.json',
5572
5613
  initTemplate: () => serviceConfigSchema.parse({
@@ -5588,10 +5629,6 @@ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
5588
5629
  '-c',
5589
5630
  configPath,
5590
5631
  ],
5591
- sectionId: 'Meta',
5592
- refreshIntervalSeconds: 73,
5593
- generateToolsContent: () => '',
5594
- dependencies: { hard: ['watcher'], soft: [] },
5595
5632
  customCliCommands: registerCustomCliCommands,
5596
5633
  });
5597
5634