@karmaniverous/jeeves-meta 0.16.3 → 0.18.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/jeeves-meta/index.js +1386 -1350
  3. package/dist/prompts/architect.md +31 -59
  4. package/dist/prompts/critic.md +18 -33
  5. package/package.json +14 -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 -5798
  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 -44
  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 -57
  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
@@ -1,126 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { sleepAsync, createConfigQueryHandler, atomicWrite, createStatusHandler, getBindAddress, fetchJson, postJson, getServiceUrl, jeevesComponentDescriptorSchema, createServiceCli } from '@karmaniverous/jeeves';
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
  *
@@ -763,6 +763,12 @@ function applyHotReloadedConfig(newConfig) {
763
763
  *
764
764
  * @module schema/config
765
765
  */
766
+ /** Default Handlebars template strings for progress reporting. Single source of truth. */
767
+ const DEFAULT_TEMPLATE_STRINGS = {
768
+ phaseStart: ':gear: Started meta synthesis {{phase}} phase of <{{dirLink}}>',
769
+ phaseEnd: ':white_check_mark: Completed meta synthesis {{phase}} phase ({{tokens}} tokens / {{seconds}}s) at <{{metaLink}}>',
770
+ phaseError: ':x: Meta synthesis {{phase}} phase failed at <{{dirLink}}>\n Error: {{error}}',
771
+ };
766
772
  /** Zod schema for logging configuration. */
767
773
  const loggingSchema = z.object({
768
774
  /** Log level. */
@@ -791,14 +797,24 @@ const serviceConfigSchema = metaConfigSchema.extend({
791
797
  reportChannel: z.string().optional(),
792
798
  /** Channel/user ID to send progress messages to. */
793
799
  reportTarget: z.string().optional(),
794
- /** Optional base URL for the service, used to construct entity links in progress reports. */
795
- serverBaseUrl: z.string().optional(),
796
800
  /**
797
- * Optional mapping of server drive labels to absolute filesystem paths.
798
- * Used on Linux to resolve absolute paths to jeeves-server browse paths.
799
- * Example: `{ "content": "/opt/jeeves/content" }`
801
+ * URL of the local jeeves-server instance, used by the ProgressReporter
802
+ * to resolve filesystem paths to browse links via the resolve-path API.
803
+ * Default: http://127.0.0.1:1934
804
+ */
805
+ serverUrl: z.string().default('http://127.0.0.1:1934'),
806
+ /**
807
+ * Handlebars templates for progress reporting messages.
808
+ * Each template receives a standard set of data keys (dirLink, metaLink,
809
+ * phase, tokens, seconds, error).
800
810
  */
801
- serverDriveRoots: z.record(z.string(), z.string()).optional(),
811
+ templates: z
812
+ .object({
813
+ phaseStart: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseStart),
814
+ phaseEnd: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseEnd),
815
+ phaseError: z.string().default(DEFAULT_TEMPLATE_STRINGS.phaseError),
816
+ })
817
+ .default(() => ({ ...DEFAULT_TEMPLATE_STRINGS })),
802
818
  /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
803
819
  watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
804
820
  /** Logging configuration. */
@@ -1235,1284 +1251,1233 @@ function createLogger(config) {
1235
1251
  return pino({ level });
1236
1252
  }
1237
1253
 
1238
- const toPath = urlOrPath => urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath;
1239
-
1240
- function findUpSync(name, {
1241
- cwd = process$1.cwd(),
1242
- type = 'file',
1243
- stopAt,
1244
- } = {}) {
1245
- let directory = path.resolve(toPath(cwd) ?? '');
1246
- const {root} = path.parse(directory);
1247
- stopAt = path.resolve(directory, toPath(stopAt) ?? root);
1248
- const isAbsoluteName = path.isAbsolute(name);
1249
-
1250
- while (directory) {
1251
- const filePath = isAbsoluteName ? name : path.join(directory, name);
1252
-
1253
- try {
1254
- const stats = fs.statSync(filePath, {throwIfNoEntry: false});
1255
- if ((type === 'file' && stats?.isFile()) || (type === 'directory' && stats?.isDirectory())) {
1256
- return filePath;
1257
- }
1258
- } catch {}
1259
-
1260
- if (directory === stopAt || directory === root) {
1261
- break;
1262
- }
1263
-
1264
- directory = path.dirname(directory);
1265
- }
1266
- }
1267
-
1268
- const isTypeOnlyPackageJsonData = packageData => {
1269
- if (!packageData || typeof packageData !== 'object' || Array.isArray(packageData)) {
1270
- return false;
1271
- }
1272
-
1273
- const keys = Object.keys(packageData);
1274
- return keys.length === 1 && keys[0] === 'type' && typeof packageData.type === 'string';
1275
- };
1276
-
1277
- const isTypeOnlyPackageJsonSync = filePath => {
1278
- let fileContents;
1279
-
1280
- try {
1281
- fileContents = fs.readFileSync(filePath, 'utf8');
1282
- } catch {
1283
- return false;
1284
- }
1285
-
1286
- try {
1287
- return isTypeOnlyPackageJsonData(JSON.parse(fileContents));
1288
- } catch {
1289
- return false;
1290
- }
1291
- };
1292
-
1293
- const getNextSearchDirectory = filePath => {
1294
- const packageDirectoryPath = path.dirname(filePath);
1295
- const parentDirectoryPath = path.dirname(packageDirectoryPath);
1296
- return parentDirectoryPath === packageDirectoryPath ? undefined : parentDirectoryPath;
1297
- };
1298
-
1299
- const findPackageDirectorySync = (directory, ignoreTypeOnlyPackageJson) => {
1300
- const filePath = findUpSync('package.json', {cwd: directory});
1301
- if (!filePath) {
1302
- return undefined;
1303
- }
1304
-
1305
- const packageDirectoryPath = path.dirname(filePath);
1306
- if (!ignoreTypeOnlyPackageJson) {
1307
- return packageDirectoryPath;
1308
- }
1309
-
1310
- if (!isTypeOnlyPackageJsonSync(filePath)) {
1311
- return packageDirectoryPath;
1312
- }
1313
-
1314
- const nextDirectory = getNextSearchDirectory(filePath);
1315
- if (!nextDirectory) {
1316
- return undefined;
1317
- }
1318
-
1319
- return findPackageDirectorySync(nextDirectory, ignoreTypeOnlyPackageJson);
1320
- };
1321
-
1322
- function packageDirectorySync({cwd, ignoreTypeOnlyPackageJson} = {}) {
1323
- return findPackageDirectorySync(cwd ?? process$1.cwd(), ignoreTypeOnlyPackageJson);
1324
- }
1325
-
1326
1254
  /**
1327
- * Built-in default prompts for the synthesis pipeline.
1255
+ * Build a minimal MetaNode from a known meta path using watcher walk.
1328
1256
  *
1329
- * Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
1330
- * 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.
1331
1260
  *
1332
- * @module prompts
1261
+ * @module discovery/buildMinimalNode
1333
1262
  */
1334
- const packageRoot = packageDirectorySync({
1335
- cwd: fileURLToPath(import.meta.url),
1336
- });
1337
- const promptDir = join(packageRoot, 'dist', 'prompts');
1338
- /** Built-in default architect prompt. */
1339
- const DEFAULT_ARCHITECT_PROMPT = readFileSync(join(promptDir, 'architect.md'), 'utf8');
1340
- /** Built-in default critic prompt. */
1341
- const DEFAULT_CRITIC_PROMPT = readFileSync(join(promptDir, 'critic.md'), 'utf8');
1342
-
1343
1263
  /**
1344
- * List archive snapshot files in chronological order.
1264
+ * Build a minimal MetaNode for a known meta path.
1345
1265
  *
1346
- * @module archive/listArchive
1347
- */
1348
- /**
1349
- * 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).
1350
1268
  *
1351
- * @param metaPath - Absolute path to the .meta directory.
1352
- * @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.
1353
1272
  */
1354
- function listArchiveFiles(metaPath) {
1355
- const archiveDir = join(metaPath, 'archive');
1356
- try {
1357
- return readdirSync(archiveDir)
1358
- .filter((f) => f.endsWith('.json'))
1359
- .sort()
1360
- .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);
1361
1294
  }
1362
- catch {
1363
- 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;
1364
1311
  }
1312
+ return node;
1365
1313
  }
1366
1314
 
1367
1315
  /**
1368
- * Prune old archive snapshots beyond maxArchive.
1316
+ * Pure phase-state transition functions.
1369
1317
  *
1370
- * @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
1371
1322
  */
1372
1323
  /**
1373
- * Prune archive directory to keep at most maxArchive snapshots.
1374
- * Removes the oldest files.
1375
- *
1376
- * @param metaPath - Absolute path to the .meta directory.
1377
- * @param maxArchive - Maximum snapshots to retain.
1378
- * @returns Number of files pruned.
1324
+ * Create a fresh (fully-complete) phase state.
1379
1325
  */
1380
- async function pruneArchive(metaPath, maxArchive) {
1381
- const files = listArchiveFiles(metaPath);
1382
- const toRemove = files.length - maxArchive;
1383
- if (toRemove <= 0)
1384
- return 0;
1385
- await Promise.all(files.slice(0, toRemove).map(unlink));
1386
- return toRemove;
1326
+ function freshPhaseState() {
1327
+ return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
1387
1328
  }
1388
-
1389
1329
  /**
1390
- * Archive reading helpers — watcher scan with filesystem fallback.
1391
- *
1392
- * Used by the GET /metas/:path route to retrieve archive history.
1393
- * Prefers watcher scan for performance; falls back to disk reads
1394
- * when the watcher is unavailable.
1395
- *
1396
- * @module archive/readArchive
1330
+ * Create a phase state for a never-synthesized meta (all pending from architect).
1397
1331
  */
1398
- /** Build a Qdrant filter for archive points under a given meta path. */
1399
- function buildArchiveScanFilter(metaPath, metaArchiveProperty) {
1400
- const must = [
1401
- {
1402
- key: 'file_path',
1403
- match: { text: normalizePath(join(metaPath, 'archive')) },
1404
- },
1405
- ];
1406
- for (const [key, value] of Object.entries(metaArchiveProperty)) {
1407
- must.push({ key, match: { value } });
1408
- }
1409
- return { must };
1410
- }
1411
- /** Extract file_path from a scan point payload. */
1412
- function getArchiveFilePath(point) {
1413
- const value = point.payload?.file_path;
1414
- return typeof value === 'string' ? value : '';
1415
- }
1416
- /** Project archive metadata, stripping indexing-internal fields. */
1417
- function projectArchivePayload(point, projectMeta) {
1418
- const payload = point.payload ?? {};
1419
- if (Object.keys(payload).length === 0)
1420
- return null;
1421
- const archiveMeta = { ...payload };
1422
- delete archiveMeta.file_path;
1423
- delete archiveMeta.chunk_text;
1424
- delete archiveMeta.chunk_index;
1425
- delete archiveMeta.total_chunks;
1426
- delete archiveMeta.content_hash;
1427
- delete archiveMeta.matched_rules;
1428
- return projectMeta(archiveMeta);
1332
+ function initialPhaseState() {
1333
+ return { architect: 'pending', builder: 'stale', critic: 'stale' };
1429
1334
  }
1430
1335
  /**
1431
- * 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.
1432
1338
  *
1433
- * @param watcher - WatcherClient with scan support.
1434
- * @param metaPath - Absolute path to the .meta/ directory.
1435
- * @param metaArchiveProperty - Additional filter properties for archive points.
1436
- * @param limit - Max entries to return (newest first). Undefined = all.
1437
- * @param projectMeta - Field projection function.
1438
- * @returns Array of projected archive entries, newest first.
1339
+ * Stale phases that become the first non-fresh phase are promoted to pending.
1439
1340
  */
1440
- async function readArchiveFromWatcher(watcher, metaPath, metaArchiveProperty, limit, projectMeta) {
1441
- if (limit === 0)
1442
- return [];
1443
- if (!watcher.scan) {
1444
- 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
+ }
1445
1363
  }
1446
- const points = [];
1447
- let cursor;
1448
- do {
1449
- const result = await watcher.scan({
1450
- filter: buildArchiveScanFilter(metaPath, metaArchiveProperty),
1451
- limit: 100,
1452
- cursor,
1453
- });
1454
- points.push(...result.points);
1455
- cursor = result.cursor ?? undefined;
1456
- } while (cursor);
1457
- const sorted = points.sort((a, b) => getArchiveFilePath(a).localeCompare(getArchiveFilePath(b)));
1458
- return sorted
1459
- .slice(limit ? -limit : 0)
1460
- .reverse()
1461
- .map((point) => projectArchivePayload(point, projectMeta))
1462
- .filter((value) => value !== null);
1364
+ return result;
1463
1365
  }
1366
+ // ── Invalidation cascades ──────────────────────────────────────────────
1464
1367
  /**
1465
- * Read archive history from disk (fallback).
1466
- *
1467
- * @param metaPath - Absolute path to the .meta/ directory.
1468
- * @param limit - Max entries to return (newest first). Undefined = all.
1469
- * @param projectMeta - Field projection function.
1470
- * @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.
1471
1371
  */
1472
- async function readArchiveFromDisk(metaPath, limit, projectMeta) {
1473
- if (limit === 0)
1474
- return [];
1475
- const archiveFiles = listArchiveFiles(metaPath);
1476
- const selected = (limit ? archiveFiles.slice(-limit) : archiveFiles).reverse();
1477
- return Promise.all(selected.map(async (archiveFile) => {
1478
- const raw = await readFile(archiveFile, 'utf8');
1479
- return projectMeta(JSON.parse(raw));
1480
- }));
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
+ });
1481
1378
  }
1482
-
1483
1379
  /**
1484
- * Read the latest archive snapshot for steer change detection.
1485
- *
1486
- * @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.
1487
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 ──────────────────────────────────────────
1488
1402
  /**
1489
- * Read the most recent archive snapshot.
1490
- *
1491
- * @param metaPath - Absolute path to the .meta directory.
1492
- * @returns The latest archived meta, or null if no archives exist.
1403
+ * Architect completes successfully.
1404
+ * architect → fresh; builder → pending; critic → stale.
1493
1405
  */
1494
- async function readLatestArchive(metaPath) {
1495
- const files = listArchiveFiles(metaPath);
1496
- if (files.length === 0)
1497
- return null;
1498
- const raw = await readFile(files[files.length - 1], 'utf8');
1499
- 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
+ });
1500
1412
  }
1501
-
1502
1413
  /**
1503
- * Create archive snapshots of meta.json.
1504
- *
1505
- * Copies current meta.json to archive/\{ISO-timestamp\}.json with
1506
- * _archived: true and _archivedAt added.
1507
- *
1508
- * @module archive/snapshot
1414
+ * Builder completes successfully.
1415
+ * builder → fresh; critic → pending.
1509
1416
  */
1417
+ function builderSuccess(state) {
1418
+ return enforceInvariant({
1419
+ ...state,
1420
+ builder: 'fresh',
1421
+ critic: state.critic === 'failed' ? 'failed' : 'pending',
1422
+ });
1423
+ }
1510
1424
  /**
1511
- * Create an archive snapshot of the current meta.json.
1512
- *
1513
- * @param metaPath - Absolute path to the .meta directory.
1514
- * @param meta - Current meta.json content.
1515
- * @returns The archive file path.
1425
+ * Critic completes successfully.
1426
+ * critic → fresh. Meta becomes fully fresh.
1516
1427
  */
1517
- async function createSnapshot(metaPath, meta) {
1518
- const archiveDir = join(metaPath, 'archive');
1519
- await mkdir(archiveDir, { recursive: true });
1520
- const now = new Date().toISOString().replace(/[:.]/g, '-');
1521
- const archiveFile = join(archiveDir, now + '.json');
1522
- const archived = {
1523
- ...meta,
1524
- _archived: true,
1525
- _archivedAt: new Date().toISOString(),
1526
- };
1527
- await writeFile(archiveFile, JSON.stringify(archived, null, 2) + '\n');
1528
- return archiveFile;
1428
+ function criticSuccess(state) {
1429
+ return enforceInvariant({
1430
+ ...state,
1431
+ critic: 'fresh',
1432
+ });
1529
1433
  }
1530
-
1434
+ // ── Failure transition ─────────────────────────────────────────────────
1531
1435
  /**
1532
- * Build the MetaContext for a synthesis cycle.
1533
- *
1534
- * Computes shared inputs once: scope files, delta files, child meta outputs,
1535
- * previous content/feedback, steer, and archive paths.
1536
- *
1537
- * @module orchestrator/contextPackage
1436
+ * A phase fails (error, timeout, or abort).
1437
+ * Target phase → failed; upstream and downstream unchanged.
1538
1438
  */
1439
+ function phaseFailed(state, phase) {
1440
+ return enforceInvariant({
1441
+ ...state,
1442
+ [phase]: 'failed',
1443
+ });
1444
+ }
1445
+ // ── Surgical retry ─────────────────────────────────────────────────────
1539
1446
  /**
1540
- * Condense a file list into glob-like summaries.
1541
- * Groups by directory + extension pattern.
1542
- *
1543
- * @param files - Array of file paths.
1544
- * @param maxIndividual - Show individual files up to this count.
1545
- * @returns Condensed summary string.
1447
+ * Retry a failed phase: failed → pending.
1448
+ * Only valid when the phase is currently failed.
1546
1449
  */
1547
- function condenseScopeFiles(files, maxIndividual = 30) {
1548
- if (files.length <= maxIndividual)
1549
- return files.join('\n');
1550
- // Group by dir + extension
1551
- const groups = new Map();
1552
- for (const f of files) {
1553
- const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
1554
- const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
1555
- const key = dir + '*' + ext;
1556
- groups.set(key, (groups.get(key) ?? 0) + 1);
1557
- }
1558
- // Sort by count descending
1559
- const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
1560
- return sorted
1561
- .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
1562
- .join('\n');
1450
+ function retryPhase(state, phase) {
1451
+ if (state[phase] !== 'failed')
1452
+ return state;
1453
+ return enforceInvariant({
1454
+ ...state,
1455
+ [phase]: 'pending',
1456
+ });
1563
1457
  }
1564
1458
  /**
1565
- * Read a meta.json file and extract `_content` and `_generatedAt`.
1566
- *
1567
- * @param metaJsonPath - Absolute path to a meta.json file.
1568
- * @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.
1569
1461
  */
1570
- async function readChildMeta(metaJsonPath) {
1571
- try {
1572
- const raw = await readFile(metaJsonPath, 'utf8');
1573
- const meta = JSON.parse(raw);
1574
- return {
1575
- content: meta._content ?? null,
1576
- generatedAt: meta._generatedAt,
1577
- };
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
+ }
1578
1468
  }
1579
- catch {
1580
- 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;
1581
1489
  }
1490
+ return null;
1582
1491
  }
1583
1492
  /**
1584
- * Read a meta.json file and extract its `_content` field.
1585
- *
1586
- * @param metaJsonPath - Absolute path to a meta.json file.
1587
- * @returns The `_content` string, or null if missing/unreadable.
1493
+ * Check if a meta is fully fresh (all phases fresh).
1588
1494
  */
1589
- async function readMetaContent(metaJsonPath) {
1590
- const info = await readChildMeta(metaJsonPath);
1591
- return info.content;
1495
+ function isFullyFresh(state) {
1496
+ return (state.architect === 'fresh' &&
1497
+ state.builder === 'fresh' &&
1498
+ state.critic === 'fresh');
1592
1499
  }
1593
1500
  /**
1594
- * 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.
1595
1517
  *
1596
- * @param node - The meta node being synthesized.
1597
- * @param meta - Current meta.json content.
1598
- * @param watcher - WatcherClient for scope enumeration.
1599
- * @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
1600
1523
  */
1601
- async function buildContextPackage(node, meta, watcher, logger) {
1602
- // Scope and delta files via watcher walk
1603
- const scopeStart = Date.now();
1604
- const { scopeFiles } = await getScopeFiles(node, watcher, logger);
1605
- const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
1606
- logger?.debug({
1607
- scopeFiles: scopeFiles.length,
1608
- deltaFiles: deltaFiles.length,
1609
- durationMs: Date.now() - scopeStart,
1610
- }, 'scope and delta files computed');
1611
- // Child meta outputs (parallel reads, delta-aware).
1612
- // Only include full _content for children synthesized AFTER the parent's
1613
- // _generatedAt. Non-delta children are already folded into the parent's
1614
- // previous _content and are listed as path-only references (#169).
1615
- const parentGeneratedAt = meta._generatedAt;
1616
- const childMetas = {};
1617
- const childEntries = await Promise.all(node.children.map(async (child) => {
1618
- const info = await readChildMeta(join(child.metaPath, 'meta.json'));
1619
- return [child.ownerPath, info];
1620
- }));
1621
- let deltaChildCount = 0;
1622
- for (const [path, info] of childEntries) {
1623
- // Delta child: no parent timestamp (first run), or child synthesized
1624
- // after parent. Include full content.
1625
- if (!parentGeneratedAt ||
1626
- !info.generatedAt ||
1627
- info.generatedAt > parentGeneratedAt) {
1628
- childMetas[path] = info.content ?? undefined;
1629
- deltaChildCount++;
1630
- }
1631
- else {
1632
- // Non-delta: already incorporated in previous synthesis.
1633
- // Include path with null content so the builder knows it exists.
1634
- childMetas[path] = null;
1635
- }
1636
- }
1637
- logger?.debug({
1638
- totalChildren: childEntries.length,
1639
- deltaChildren: deltaChildCount,
1640
- }, 'child metas filtered by delta');
1641
- // Cross-referenced meta outputs (parallel reads)
1642
- const crossRefMetas = {};
1643
- const seen = new Set();
1644
- const crossRefPaths = [];
1645
- for (const refPath of meta._crossRefs ?? []) {
1646
- if (refPath === node.ownerPath || refPath === node.metaPath)
1647
- continue;
1648
- if (seen.has(refPath))
1649
- continue;
1650
- seen.add(refPath);
1651
- crossRefPaths.push(refPath);
1652
- }
1653
- const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
1654
- const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
1655
- return [refPath, content];
1656
- }));
1657
- for (const [path, content] of crossRefEntries) {
1658
- crossRefMetas[path] = content;
1659
- }
1660
- // Archive paths
1661
- const archives = listArchiveFiles(node.metaPath);
1662
- // Nearest ancestor _builder output
1663
- let ancestorBuilder;
1664
- const ancestor = getAncestorMeta(node);
1665
- if (ancestor) {
1666
- try {
1667
- const raw = await readFile(join(ancestor.metaPath, 'meta.json'), 'utf8');
1668
- const ancestorMeta = JSON.parse(raw);
1669
- if (ancestorMeta._builder) {
1670
- 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';
1671
1555
  }
1672
1556
  }
1673
- catch {
1674
- // Ancestor meta.json unreadable — skip
1557
+ // If builder failed, critic is stale
1558
+ if (failedPhase === 'builder') {
1559
+ state.critic = 'stale';
1675
1560
  }
1561
+ return state;
1676
1562
  }
1677
- return {
1678
- path: node.metaPath,
1679
- scopeFiles,
1680
- deltaFiles,
1681
- childMetas,
1682
- crossRefMetas,
1683
- previousContent: meta._content ?? null,
1684
- previousFeedback: meta._feedback ?? null,
1685
- steer: meta._steer ?? null,
1686
- previousState: meta._state ?? null,
1687
- archives,
1688
- ancestorBuilder,
1689
- };
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();
1690
1585
  }
1691
1586
 
1692
1587
  /**
1693
- * Build task prompts for each synthesis step.
1694
- *
1695
- * Prompts are compiled as Handlebars templates with access to config,
1696
- * meta, and scope context. The architect can write template expressions
1697
- * into its _builder output; these resolve when the builder task is compiled.
1588
+ * List archive snapshot files in chronological order.
1698
1589
  *
1699
- * @module orchestrator/buildTask
1590
+ * @module archive/listArchive
1700
1591
  */
1701
- Handlebars.registerHelper('gt', (a, b) => a > b);
1702
- /** Build the template context from synthesis inputs. */
1703
- function buildTemplateContext(ctx, meta, config) {
1704
- return {
1705
- config,
1706
- meta,
1707
- scope: {
1708
- fileCount: ctx.scopeFiles.length,
1709
- deltaCount: ctx.deltaFiles.length,
1710
- childCount: Object.keys(ctx.childMetas).length,
1711
- crossRefCount: Object.keys(ctx.crossRefMetas).length,
1712
- },
1713
- };
1714
- }
1715
1592
  /**
1716
- * Compile a string as a Handlebars template with the given context.
1717
- * 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.
1718
1597
  */
1719
- function compileTemplate(text, context) {
1598
+ function listArchiveFiles(metaPath) {
1599
+ const archiveDir = join(metaPath, 'archive');
1720
1600
  try {
1721
- 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));
1722
1605
  }
1723
1606
  catch {
1724
- return text;
1607
+ return [];
1725
1608
  }
1726
1609
  }
1610
+
1727
1611
  /**
1728
- * Append a keyed record of meta outputs as subsections, if non-empty.
1612
+ * Prune old archive snapshots beyond maxArchive.
1729
1613
  *
1730
- * @param condenseMissing - When true, null entries are condensed into a
1731
- * path listing (delta-aware child metas). When false, null entries get
1732
- * individual subsections with "(not yet synthesized)" (cross-refs).
1614
+ * @module archive/prune
1733
1615
  */
1734
- function appendMetaSections(sections, heading, metas, condenseMissing = false) {
1735
- if (Object.keys(metas).length === 0)
1736
- return;
1737
- if (!condenseMissing) {
1738
- // Original behavior: every entry gets a subsection.
1739
- sections.push('', heading);
1740
- for (const [path, content] of Object.entries(metas)) {
1741
- sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
1742
- }
1743
- return;
1744
- }
1745
- // Delta-aware: separate entries into three categories (#169).
1746
- // - string content → full subsection (delta child with content)
1747
- // - null → non-delta, already folded into previous synthesis
1748
- // - undefined → delta child not yet synthesized
1749
- const withContent = [];
1750
- const unchanged = [];
1751
- const notYetSynthesized = [];
1752
- for (const [path, content] of Object.entries(metas)) {
1753
- if (typeof content === 'string') {
1754
- withContent.push([path, content]);
1755
- }
1756
- else if (content === null) {
1757
- unchanged.push(path);
1758
- }
1759
- else {
1760
- notYetSynthesized.push(path);
1761
- }
1762
- }
1763
- sections.push('', heading);
1764
- for (const [path, content] of withContent) {
1765
- sections.push(`### ${path}`, content);
1766
- }
1767
- if (notYetSynthesized.length > 0) {
1768
- sections.push('', `### Not yet synthesized (${notYetSynthesized.length.toString()})`, ...notYetSynthesized.map((p) => `- ${p}`));
1769
- }
1770
- if (unchanged.length > 0) {
1771
- sections.push('', `### Other children (${unchanged.length.toString()} \u2014 unchanged since last synthesis)`, ...unchanged.map((p) => `- ${p}`));
1772
- }
1773
- }
1774
- /** Inject nearest ancestor's organizational context, if available. */
1775
- function appendAncestorContext(sections, ctx) {
1776
- if (ctx.ancestorBuilder) {
1777
- sections.push('', '## PARENT ORGANIZATIONAL CONTEXT', ctx.ancestorBuilder);
1778
- }
1779
- }
1780
- /** Append optional context sections shared across all step prompts. */
1781
- function appendSharedSections(sections, ctx, options) {
1782
- const opts = {
1783
- includeSteer: true,
1784
- includePreviousContent: true,
1785
- includePreviousFeedback: true,
1786
- feedbackHeading: '## PREVIOUS FEEDBACK',
1787
- includeChildMetas: true,
1788
- includeCrossRefs: true,
1789
- ...options,
1790
- };
1791
- if (opts.includeSteer && ctx.steer) {
1792
- sections.push('', '## STEERING PROMPT', ctx.steer);
1793
- }
1794
- if (opts.includePreviousContent && ctx.previousContent) {
1795
- sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
1796
- }
1797
- if (opts.includePreviousFeedback && ctx.previousFeedback) {
1798
- sections.push('', opts.feedbackHeading, ctx.previousFeedback);
1799
- }
1800
- if (opts.includeChildMetas) {
1801
- appendMetaSections(sections, '## CHILD META OUTPUTS', ctx.childMetas, true);
1802
- }
1803
- if (opts.includeCrossRefs) {
1804
- appendMetaSections(sections, '## CROSS-REFERENCED METAS', ctx.crossRefMetas);
1805
- }
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;
1806
1631
  }
1632
+
1807
1633
  /**
1808
- * Build the architect task prompt.
1634
+ * Read the latest archive snapshot for steer change detection.
1809
1635
  *
1810
- * @param ctx - Synthesis context.
1811
- * @param meta - Current meta.json.
1812
- * @param config - Synthesis config.
1813
- * @returns The architect task prompt string.
1636
+ * @module archive/readLatest
1814
1637
  */
1815
- function buildArchitectTask(ctx, meta, config) {
1816
- const sections = [
1817
- `# jeeves-meta · ARCHITECT · ${ctx.path}`,
1818
- '',
1819
- DEFAULT_ARCHITECT_PROMPT,
1820
- '',
1821
- '## SCOPE',
1822
- `Path: ${ctx.path}`,
1823
- `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
1824
- `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
1825
- '',
1826
- '### File listing (scope)',
1827
- condenseScopeFiles(ctx.scopeFiles),
1828
- ];
1829
- appendAncestorContext(sections, ctx);
1830
- // Inject previous _builder so architect can see its own prior output
1831
- if (meta._builder) {
1832
- sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
1833
- }
1834
- appendSharedSections(sections, ctx);
1835
- if (ctx.archives.length > 0) {
1836
- 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.');
1837
- }
1838
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
1839
- }
1840
1638
  /**
1841
- * Build the builder task prompt.
1639
+ * Read the most recent archive snapshot.
1842
1640
  *
1843
- * @param ctx - Synthesis context.
1844
- * @param meta - Current meta.json.
1845
- * @param config - Synthesis config.
1846
- * @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.
1847
1643
  */
1848
- function buildBuilderTask(ctx, meta, config) {
1849
- const sections = [
1850
- `# jeeves-meta · BUILDER · ${ctx.path}`,
1851
- '',
1852
- '## TASK BRIEF (from Architect)',
1853
- meta._builder ?? '(No architect brief available)',
1854
- '',
1855
- '## SCOPE',
1856
- `Path: ${ctx.path}`,
1857
- `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
1858
- ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
1859
- ];
1860
- appendAncestorContext(sections, ctx);
1861
- if (ctx.previousState != null) {
1862
- 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), '```');
1863
- }
1864
- appendSharedSections(sections, ctx, {
1865
- includeSteer: false,
1866
- feedbackHeading: '## FEEDBACK FROM CRITIC',
1867
- });
1868
- 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.');
1869
- 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);
1870
1650
  }
1651
+
1871
1652
  /**
1872
- * Build the critic task prompt.
1653
+ * Create archive snapshots of meta.json.
1873
1654
  *
1874
- * @param ctx - Synthesis context.
1875
- * @param meta - Current meta.json (with _content already set by builder).
1876
- * @param config - Synthesis config.
1877
- * @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
1878
1659
  */
1879
- function buildCriticTask(ctx, meta, config) {
1880
- const sections = [
1881
- `# jeeves-meta · CRITIC · ${ctx.path}`,
1882
- '',
1883
- DEFAULT_CRITIC_PROMPT,
1884
- '',
1885
- '## SYNTHESIS TO EVALUATE',
1886
- meta._content ?? '(No content produced)',
1887
- '',
1888
- '## SCOPE',
1889
- `Path: ${ctx.path}`,
1890
- `Files in scope: ${ctx.scopeFiles.length.toString()}`,
1891
- ];
1892
- appendSharedSections(sections, ctx, {
1893
- includePreviousContent: false,
1894
- feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
1895
- includeChildMetas: false,
1896
- includeCrossRefs: false,
1897
- });
1898
- sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
1899
- 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);
1900
1767
  }
1901
1768
 
1902
1769
  /**
1903
- * Build a minimal MetaNode from a known meta path using watcher walk.
1770
+ * Built-in default prompts for the synthesis pipeline.
1904
1771
  *
1905
- * Used for targeted synthesis (when a specific path is requested) to avoid
1906
- * the full discovery + ownership tree build. Discovers only immediate child
1907
- * `.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.
1908
1775
  *
1909
- * @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
1910
1782
  */
1911
1783
  /**
1912
- * Build a minimal MetaNode for a known meta path.
1913
- *
1914
- * Walks the owner directory for child `.meta/meta.json` files and constructs
1915
- * a shallow ownership tree (self + direct children only).
1784
+ * Resolve the directory holding the built-in prompt files for a module.
1916
1785
  *
1917
- * @param metaPath - Absolute path to the `.meta/` directory.
1918
- * @param watcher - WatcherClient for filesystem enumeration.
1919
- * @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).
1920
1789
  */
1921
- async function buildMinimalNode(metaPath, watcher) {
1922
- const normalized = normalizePath(metaPath);
1923
- const ownerPath = posix.dirname(normalized);
1924
- // Find child metas using watcher walk.
1925
- // We include only *direct* children (nearest descendants in the ownership tree)
1926
- // to match the ownership semantics used elsewhere.
1927
- const rawMetaJsonPaths = await watcher.walk([
1928
- `${escapeGlob(ownerPath)}/**/.meta/meta.json`,
1929
- ]);
1930
- const candidateMetaPaths = [
1931
- ...new Set(rawMetaJsonPaths.map((p) => posix.dirname(normalizePath(p)))),
1932
- ].filter((p) => p !== normalized);
1933
- const candidates = candidateMetaPaths
1934
- .map((mp) => ({ metaPath: mp, ownerPath: posix.dirname(mp) }))
1935
- .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
1936
- const directChildren = [];
1937
- for (const c of candidates) {
1938
- const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
1939
- c.ownerPath.startsWith(d.ownerPath + '/'));
1940
- if (!nestedUnderExisting)
1941
- 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}`);
1942
1794
  }
1943
- const children = directChildren.map((c) => ({
1944
- metaPath: c.metaPath,
1945
- ownerPath: c.ownerPath,
1946
- treeDepth: 1,
1947
- children: [],
1948
- parent: null,
1949
- }));
1950
- const node = {
1951
- metaPath: normalized,
1952
- ownerPath,
1953
- treeDepth: 0,
1954
- children,
1955
- parent: null,
1956
- };
1957
- for (const child of children) {
1958
- 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');
1959
1807
  }
1960
- 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');
1961
1827
  }
1962
1828
 
1963
1829
  /**
1964
- * Pure phase-state transition functions.
1830
+ * Compute a structure hash from a sorted file listing.
1965
1831
  *
1966
- * Implements every row of the §8 "Transitions and invalidation cascade" table.
1967
- * No I/O — pure functions over PhaseState and documented inputs.
1832
+ * Used to detect when directory structure changes, triggering
1833
+ * an architect re-run.
1968
1834
  *
1969
- * @module phaseState/phaseTransitions
1835
+ * @module structureHash
1970
1836
  */
1971
1837
  /**
1972
- * 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.
1973
1842
  */
1974
- function freshPhaseState() {
1975
- 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');
1976
1847
  }
1848
+
1977
1849
  /**
1978
- * 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
1979
1856
  */
1980
- function initialPhaseState() {
1981
- 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;
1982
1864
  }
1983
1865
  /**
1984
- * Enforce the per-meta invariant: at most one phase is pending or running,
1985
- * and it is the first non-fresh phase in pipeline order.
1866
+ * Compute invalidation inputs and apply cascade for a single meta.
1986
1867
  *
1987
- * 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.
1988
1875
  */
1989
- function enforceInvariant(state) {
1990
- const result = { ...state };
1991
- let foundNonFresh = false;
1992
- for (const phase of ['architect', 'builder', 'critic']) {
1993
- const s = result[phase];
1994
- if (s === 'fresh')
1995
- continue;
1996
- if (!foundNonFresh) {
1997
- foundNonFresh = true;
1998
- // First non-fresh: if stale, promote to pending
1999
- if (s === 'stale') {
2000
- result[phase] = 'pending';
2001
- }
2002
- // 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);
2003
1911
  }
2004
1912
  else {
2005
- // Subsequent non-fresh: must not be pending or running
2006
- if (s === 'pending') {
2007
- result[phase] = 'stale';
2008
- }
2009
- // running in non-first position would be a bug, but don't mask it
1913
+ architectInvalidators.push('structureHash');
2010
1914
  }
2011
1915
  }
2012
- 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
+ };
2013
1943
  }
2014
- // ── Invalidation cascades ──────────────────────────────────────────────
1944
+
2015
1945
  /**
2016
- * Architect invalidated: architect → pending; builder, critic → stale.
2017
- * Triggers: first run, _structureHash change, _steer change,
2018
- * _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
2019
1951
  */
2020
- function invalidateArchitect(state) {
2021
- return enforceInvariant({
2022
- architect: state.architect === 'failed' ? 'failed' : 'pending',
2023
- builder: state.builder === 'fresh' ? 'stale' : state.builder,
2024
- critic: state.critic === 'fresh' ? 'stale' : state.critic,
2025
- });
2026
- }
2027
1952
  /**
2028
- * Builder invalidated (scope mtime or cross-ref _content change):
2029
- * builder → pending; critic → stale.
2030
- * 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.
2031
1964
  */
2032
- function invalidateBuilder(state) {
2033
- if (state.architect !== 'fresh') {
2034
- // Architect is not fresh — builder stays stale (or whatever it is)
2035
- return enforceInvariant({
2036
- ...state,
2037
- builder: state.builder === 'fresh' || state.builder === 'stale'
2038
- ? 'stale'
2039
- : state.builder,
2040
- critic: state.critic === 'fresh' ? 'stale' : state.critic,
2041
- });
2042
- }
2043
- return enforceInvariant({
2044
- ...state,
2045
- builder: state.builder === 'failed' ? 'failed' : 'pending',
2046
- 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
+ };
2047
1980
  });
2048
1981
  }
2049
- // ── Phase success transitions ──────────────────────────────────────────
1982
+
2050
1983
  /**
2051
- * Architect completes successfully.
2052
- * 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
2053
1991
  */
2054
- function architectSuccess(state) {
2055
- return enforceInvariant({
2056
- architect: 'fresh',
2057
- builder: state.builder === 'failed' ? 'failed' : 'pending',
2058
- critic: state.critic === 'fresh' ? 'stale' : state.critic,
2059
- });
2060
- }
2061
1992
  /**
2062
- * Builder completes successfully.
2063
- * 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.
2064
1998
  */
2065
- function builderSuccess(state) {
2066
- return enforceInvariant({
2067
- ...state,
2068
- builder: 'fresh',
2069
- 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
+ };
2070
2018
  });
2071
2019
  }
2072
2020
  /**
2073
- * Critic completes successfully.
2074
- * 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).
2075
2027
  */
2076
- function criticSuccess(state) {
2077
- return enforceInvariant({
2078
- ...state,
2079
- 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';
2080
2039
  });
2081
- }
2082
- // ── Failure transition ─────────────────────────────────────────────────
2083
- /**
2084
- * A phase fails (error, timeout, or abort).
2085
- * Target phase → failed; upstream and downstream unchanged.
2086
- */
2087
- function phaseFailed(state, phase) {
2088
- return enforceInvariant({
2089
- ...state,
2090
- [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
+ };
2091
2061
  });
2092
- }
2093
- // ── Surgical retry ─────────────────────────────────────────────────────
2094
- /**
2095
- * Retry a failed phase: failed → pending.
2096
- * Only valid when the phase is currently failed.
2097
- */
2098
- function retryPhase(state, phase) {
2099
- if (state[phase] !== 'failed')
2100
- return state;
2101
- return enforceInvariant({
2102
- ...state,
2103
- [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;
2104
2067
  });
2068
+ return candidates;
2105
2069
  }
2106
2070
  /**
2107
- * Retry all failed phases: each failed phase → pending.
2108
- * Used by scheduler ticks and queue reads to auto-promote failed phases.
2109
- */
2110
- function retryAllFailed(state) {
2111
- let result = state;
2112
- for (const phase of ['architect', 'builder', 'critic']) {
2113
- if (result[phase] === 'failed') {
2114
- result = retryPhase(result, phase);
2115
- }
2116
- }
2117
- return result;
2118
- }
2119
- // ── Running transition ─────────────────────────────────────────────────
2120
- /**
2121
- * 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.
2122
2076
  */
2123
- function phaseRunning(state, phase) {
2124
- return {
2125
- ...state,
2126
- [phase]: 'running',
2127
- };
2077
+ function selectPhaseCandidate(metas, depthWeight) {
2078
+ return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
2128
2079
  }
2129
- // ── Query helpers ──────────────────────────────────────────────────────
2130
2080
  /**
2131
- * 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.
2132
2083
  */
2133
- function getOwedPhase(state) {
2134
- for (const phase of ['architect', 'builder', 'critic']) {
2135
- if (state[phase] !== 'fresh')
2136
- return phase;
2137
- }
2138
- 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 }));
2139
2089
  }
2090
+
2140
2091
  /**
2141
- * Check if a meta is fully fresh (all phases fresh).
2092
+ * Shared error utilities.
2093
+ *
2094
+ * @module errors
2142
2095
  */
2143
- function isFullyFresh(state) {
2144
- return (state.architect === 'fresh' &&
2145
- state.builder === 'fresh' &&
2146
- state.critic === 'fresh');
2147
- }
2148
2096
  /**
2149
- * Get the scheduler priority band for a meta's owed phase.
2150
- * 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.
2151
2103
  */
2152
- function getPriorityBand(state) {
2153
- const owed = getOwedPhase(state);
2154
- if (!owed)
2155
- return null;
2156
- if (owed === 'critic')
2157
- return 1;
2158
- if (owed === 'builder')
2159
- return 2;
2160
- 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
+ };
2161
2110
  }
2162
2111
 
2163
2112
  /**
2164
- * Backward-compatible derivation of _phaseState from existing meta fields.
2113
+ * Build the MetaContext for a synthesis cycle.
2165
2114
  *
2166
- * When a meta is loaded from disk without _phaseState, this reconstructs
2167
- * the phase state from _content, _builder, _state, _error.step, and
2168
- * the architect-invalidating inputs.
2115
+ * Computes shared inputs once: scope files, delta files, child meta outputs,
2116
+ * previous content/feedback, steer, and archive paths.
2169
2117
  *
2170
- * @module phaseState/derivePhaseState
2118
+ * @module orchestrator/contextPackage
2171
2119
  */
2172
2120
  /**
2173
- * Derive _phaseState from existing meta fields.
2174
- *
2175
- * If the meta already has _phaseState, returns it as-is.
2176
- *
2177
- * Otherwise, reconstructs from available fields:
2178
- * - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
2179
- * - Errored meta: the failed phase is mapped from _error.step.
2180
- * - Mid-cycle meta with cached _builder but no _content: builder pending.
2181
- * - Fully-fresh meta: all phases fresh.
2182
- * - Meta with stale architect inputs: architect pending, downstream stale.
2121
+ * Condense a file list into glob-like summaries.
2122
+ * Groups by directory + extension pattern.
2183
2123
  *
2184
- * @param meta - The meta.json content.
2185
- * @param inputs - Optional derivation inputs. If not provided, a simpler
2186
- * heuristic is used (no architect invalidation check).
2187
- * @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.
2188
2127
  */
2189
- function derivePhaseState(meta, inputs) {
2190
- // Already has _phaseState — use it
2191
- if (meta._phaseState)
2192
- return meta._phaseState;
2193
- // Check for errors first — _error.step maps directly to failed phase
2194
- if (meta._error) {
2195
- const failedPhase = meta._error.step;
2196
- const state = freshPhaseState();
2197
- state[failedPhase] = 'failed';
2198
- // If architect failed and no _builder, downstream is stale
2199
- if (failedPhase === 'architect') {
2200
- if (!meta._builder) {
2201
- state.builder = 'stale';
2202
- state.critic = 'stale';
2203
- }
2204
- }
2205
- // If builder failed, critic is stale
2206
- if (failedPhase === 'builder') {
2207
- state.critic = 'stale';
2208
- }
2209
- return state;
2210
- }
2211
- // Never synthesized: no _content AND no _builder (and no error)
2212
- if (!meta._content && !meta._builder) {
2213
- 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);
2214
2138
  }
2215
- // Has _builder but no _content: builder is pending
2216
- 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);
2217
2155
  return {
2218
- architect: 'fresh',
2219
- builder: 'pending',
2220
- critic: 'stale',
2156
+ content: meta._content ?? null,
2157
+ generatedAt: meta._generatedAt,
2221
2158
  };
2222
2159
  }
2223
- // Has _content but no _feedback: critic is pending
2224
- if (meta._content && !meta._feedback) {
2225
- return {
2226
- architect: 'fresh',
2227
- builder: 'fresh',
2228
- critic: 'pending',
2229
- };
2160
+ catch {
2161
+ return { content: null, generatedAt: undefined };
2230
2162
  }
2231
- // Default: fully fresh
2232
- return freshPhaseState();
2233
2163
  }
2234
-
2235
- /**
2236
- * Compute a structure hash from a sorted file listing.
2237
- *
2238
- * Used to detect when directory structure changes, triggering
2239
- * an architect re-run.
2240
- *
2241
- * @module structureHash
2242
- */
2243
2164
  /**
2244
- * Compute a SHA-256 hash of a sorted file listing.
2165
+ * Read a meta.json file and extract its `_content` field.
2245
2166
  *
2246
- * @param filePaths - Array of file paths in scope.
2247
- * @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.
2248
2169
  */
2249
- function computeStructureHash(filePaths) {
2250
- const sorted = [...filePaths].sort();
2251
- const content = sorted.join('\n');
2252
- return createHash('sha256').update(content).digest('hex');
2170
+ async function readMetaContent(metaJsonPath) {
2171
+ const info = await readChildMeta(metaJsonPath);
2172
+ return info.content;
2253
2173
  }
2254
-
2255
2174
  /**
2256
- * Per-tick invalidation pass.
2257
- *
2258
- * Computes architect-invalidating and builder-invalidating inputs for a meta,
2259
- * then applies the cascade to update _phaseState.
2175
+ * Build the context package for a synthesis cycle.
2260
2176
  *
2261
- * @module phaseState/invalidate
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.
2262
2181
  */
2263
- /**
2264
- * Check whether a persisted prompt snapshot mismatches the currently-resolved prompt.
2265
- * Returns true when the snapshot exists and differs from the resolved prompt.
2266
- * This is informational only — it does NOT trigger invalidation.
2267
- */
2268
- function isPromptStale(snapshot, resolved) {
2269
- return snapshot !== undefined && snapshot !== resolved;
2270
- }
2271
- /**
2272
- * Compute invalidation inputs and apply cascade for a single meta.
2273
- *
2274
- * @param meta - Current meta.json content with existing _phaseState.
2275
- * @param scopeFiles - Sorted file list from scope.
2276
- * @param config - MetaConfig for architectEvery.
2277
- * @param node - MetaNode for archive access.
2278
- * @param crossRefMetas - Map of cross-ref owner paths to their current _content.
2279
- * @param archiveCrossRefContent - Map of cross-ref owner paths to their archived _content.
2280
- * @returns Updated phase state and invalidation details.
2281
- */
2282
- async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas, archiveCrossRefContent) {
2283
- let phaseState = meta._phaseState ?? {
2284
- architect: 'fresh',
2285
- builder: 'fresh',
2286
- critic: 'fresh',
2287
- };
2288
- // ── Architect-level inputs ──
2289
- const structureHash = computeStructureHash(scopeFiles);
2290
- const structureChanged = structureHash !== meta._structureHash;
2291
- const latestArchive = await readLatestArchive(node.metaPath);
2292
- const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2293
- // Prompt staleness detection: compare persisted prompt snapshots against
2294
- // currently-resolved prompts. This is INFORMATIONAL ONLY — reported via
2295
- // inputStatus so /preview can surface it, but it must NEVER feed into
2296
- // the invalidation cascade. When a meta naturally reaches architectEvery
2297
- // through real builder cycles, architect runs with the current prompt and
2298
- // the snapshot updates. Coupling prompt changes to invalidation causes a
2299
- // corpus-wide synthesis storm (see #163).
2300
- const architectChanged = isPromptStale(meta._architect, DEFAULT_ARCHITECT_PROMPT);
2301
- const criticChanged = isPromptStale(meta._critic, DEFAULT_CRITIC_PROMPT);
2302
- const effectiveSynthesisCount = meta._synthesisCount ?? 0;
2303
- // _crossRefs declaration change
2304
- const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
2305
- const archiveRefs = (latestArchive?._crossRefs ?? [])
2306
- .slice()
2307
- .sort()
2308
- .join(',');
2309
- const crossRefsDeclChanged = latestArchive
2310
- ? currentRefs !== archiveRefs
2311
- : currentRefs.length > 0;
2312
- const architectInvalidators = [];
2313
- if (structureChanged) {
2314
- if (meta._state !== undefined) {
2315
- // Progressive entity: new files → builder only (cursor handles incremental)
2316
- 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++;
2317
2211
  }
2318
2212
  else {
2319
- 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;
2320
2216
  }
2321
2217
  }
2322
- if (steerChanged)
2323
- architectInvalidators.push('steer');
2324
- if (crossRefsDeclChanged)
2325
- architectInvalidators.push('_crossRefs');
2326
- if (effectiveSynthesisCount >= config.architectEvery) {
2327
- 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);
2328
2233
  }
2329
- if (!meta._builder)
2330
- architectInvalidators.push('firstRun');
2331
- if (architectInvalidators.length > 0) {
2332
- 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
+ }
2333
2257
  }
2334
- // ── Builder-level inputs ──
2335
- // Cross-ref _content change (builder-invalidating)
2336
- let crossRefContentChanged = false;
2337
2258
  return {
2338
- phaseState,
2339
- architectInvalidators,
2340
- inputStatus: {
2341
- structureHash,
2342
- steerChanged,
2343
- architectChanged,
2344
- criticChanged,
2345
- crossRefsDeclChanged,
2346
- crossRefContentChanged,
2347
- },
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,
2348
2270
  };
2349
2271
  }
2350
2272
 
2351
2273
  /**
2352
- * Weighted staleness formula for candidate selection.
2353
- *
2354
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
2355
- *
2356
- * @module scheduling/weightedFormula
2357
- */
2358
- /**
2359
- * Compute effective staleness for a set of candidates.
2360
- *
2361
- * Normalizes depths so the minimum becomes 0, then applies the formula:
2362
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
2274
+ * Build task prompts for each synthesis step.
2363
2275
  *
2364
- * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
2365
- * 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.
2366
2279
  *
2367
- * @param candidates - Array of \{ node, meta, actualStaleness \}.
2368
- * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
2369
- * @returns Same array with effectiveStaleness computed.
2280
+ * @module orchestrator/buildTask
2370
2281
  */
2371
- function computeEffectiveStaleness(candidates, depthWeight) {
2372
- if (candidates.length === 0)
2373
- return [];
2374
- // Get depth for each candidate: use _depth override or tree depth
2375
- const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
2376
- // Normalize: shift so minimum becomes 0
2377
- const minDepth = Math.min(...depths);
2378
- const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
2379
- return candidates.map((c, i) => {
2380
- const emphasis = c.meta._emphasis ?? 1;
2381
- return {
2382
- ...c,
2383
- effectiveStaleness: c.actualStaleness *
2384
- Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
2385
- };
2386
- });
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
+ };
2387
2295
  }
2388
-
2389
2296
  /**
2390
- * Corpus-wide phase scheduler.
2391
- *
2392
- * Selects the highest-priority ready phase across all metas.
2393
- * Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
2394
- * Tiebreak within band: weighted staleness (§3.9).
2395
- *
2396
- * @module phaseState/phaseScheduler
2297
+ * Compile a string as a Handlebars template with the given context.
2298
+ * Returns the original string unchanged if compilation fails.
2397
2299
  */
2300
+ function compileTemplate(text, context) {
2301
+ try {
2302
+ return Handlebars.compile(text, { noEscape: true })(context);
2303
+ }
2304
+ catch {
2305
+ return text;
2306
+ }
2307
+ }
2398
2308
  /**
2399
- * Build phase candidates from listMetas entries.
2309
+ * Append a keyed record of meta outputs as subsections, if non-empty.
2400
2310
  *
2401
- * Derives phase state, auto-retries failed phases, and applies Tier 1
2402
- * cheap-invalidation (no I/O) for metas with persisted _phaseState.
2403
- * 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).
2404
2314
  */
2405
- function buildPhaseCandidates(entries, architectEvery) {
2406
- return entries.map((entry) => {
2407
- let ps = retryAllFailed(derivePhaseState(entry.meta));
2408
- // Tier 1 cheap invalidation for metas with persisted _phaseState
2409
- if (entry.meta._phaseState) {
2410
- const needsArchitect = !entry.meta._builder ||
2411
- (entry.meta._synthesisCount ?? 0) >= architectEvery;
2412
- if (needsArchitect && isFullyFresh(ps)) {
2413
- ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
2414
- }
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)');
2415
2323
  }
2416
- return {
2417
- node: entry.node,
2418
- meta: entry.meta,
2419
- phaseState: ps,
2420
- actualStaleness: entry.stalenessSeconds,
2421
- locked: entry.locked,
2422
- disabled: entry.disabled,
2423
- };
2424
- });
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
+ }
2425
2354
  }
2426
- /**
2427
- * Rank all eligible phase candidates by priority.
2428
- *
2429
- * Filters to pending phases, computes effective staleness, and sorts by
2430
- * band (ascending: critic first) then effective staleness (descending).
2431
- *
2432
- * Used by selectPhaseCandidate (returns first) and the queue route (returns all).
2433
- */
2434
- function rankPhaseCandidates(metas, depthWeight) {
2435
- // Filter to metas with a pending (scheduler-eligible) phase
2436
- const eligible = metas.filter((m) => {
2437
- if (m.locked)
2438
- return false;
2439
- if (m.disabled && !m.isOverride)
2440
- return false;
2441
- const owed = getOwedPhase(m.phaseState);
2442
- if (!owed)
2443
- return false;
2444
- return m.phaseState[owed] === 'pending';
2445
- });
2446
- if (eligible.length === 0)
2447
- return [];
2448
- // Compute effective staleness for tiebreaking
2449
- const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
2450
- node: m.node,
2451
- meta: m.meta,
2452
- actualStaleness: m.actualStaleness,
2453
- })), depthWeight);
2454
- // Build candidates with band info
2455
- const candidates = withStaleness.map((ws, i) => {
2456
- const m = eligible[i];
2457
- const owedPhase = getOwedPhase(m.phaseState);
2458
- return {
2459
- node: ws.node,
2460
- meta: ws.meta,
2461
- phaseState: m.phaseState,
2462
- owedPhase,
2463
- band: getPriorityBand(m.phaseState),
2464
- actualStaleness: ws.actualStaleness,
2465
- effectiveStaleness: ws.effectiveStaleness,
2466
- };
2467
- });
2468
- // Sort by band (ascending = critic first) then effective staleness (descending)
2469
- candidates.sort((a, b) => {
2470
- if (a.band !== b.band)
2471
- return a.band - b.band;
2472
- return b.effectiveStaleness - a.effectiveStaleness;
2473
- });
2474
- return candidates;
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
+ }
2475
2387
  }
2476
2388
  /**
2477
- * Select the best phase candidate across the corpus.
2389
+ * Build the architect task prompt.
2478
2390
  *
2479
- * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
2480
- * @param depthWeight - Config depthWeight for staleness tiebreak.
2481
- * @returns The winning candidate, or null if no phase is ready.
2482
- */
2483
- function selectPhaseCandidate(metas, depthWeight) {
2484
- return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
2485
- }
2486
- /**
2487
- * Select all fully-fresh, non-disabled, non-locked metas sorted by staleness
2488
- * (descending — stalest first) for Tier 2 invalidation scanning.
2391
+ * @param ctx - Synthesis context.
2392
+ * @param meta - Current meta.json.
2393
+ * @param config - Synthesis config.
2394
+ * @returns The architect task prompt string.
2489
2395
  */
2490
- function selectAllTier2Candidates(metas) {
2491
- return metas
2492
- .filter((m) => !m.locked && !m.disabled && isFullyFresh(m.phaseState))
2493
- .sort((a, b) => b.actualStaleness - a.actualStaleness)
2494
- .map((m) => ({ node: m.node, meta: m.meta }));
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));
2495
2420
  }
2496
-
2497
2421
  /**
2498
- * Shared error utilities.
2422
+ * Build the builder task prompt.
2499
2423
  *
2500
- * @module errors
2424
+ * @param ctx - Synthesis context.
2425
+ * @param meta - Current meta.json.
2426
+ * @param config - Synthesis config.
2427
+ * @returns The builder task prompt string.
2501
2428
  */
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));
2451
+ }
2502
2452
  /**
2503
- * Wrap an unknown caught value into a MetaError.
2453
+ * Build the critic task prompt.
2504
2454
  *
2505
- * @param step - Which synthesis step failed.
2506
- * @param err - The caught error value.
2507
- * @param code - Error classification code.
2508
- * @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.
2509
2459
  */
2510
- function toMetaError(step, err, code = 'FAILED') {
2511
- return {
2512
- step,
2513
- code,
2514
- message: err instanceof Error ? err.message : String(err),
2515
- };
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));
2516
2481
  }
2517
2482
 
2518
2483
  /**
@@ -2707,7 +2672,7 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
2707
2672
  ps = architectSuccess(ps);
2708
2673
  const architectUpdates = {
2709
2674
  _builder: builderBrief,
2710
- _architect: DEFAULT_ARCHITECT_PROMPT,
2675
+ _architect: getDefaultArchitectPrompt(),
2711
2676
  _synthesisCount: 0,
2712
2677
  _architectTokens: architectTokens,
2713
2678
  _generatedAt: new Date().toISOString(),
@@ -2860,7 +2825,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
2860
2825
  const cycleComplete = isFullyFresh(ps);
2861
2826
  const updates = {
2862
2827
  _feedback: feedback,
2863
- _critic: DEFAULT_CRITIC_PROMPT,
2828
+ _critic: getDefaultCriticPrompt(),
2864
2829
  _criticTokens: criticTokens,
2865
2830
  _error: undefined,
2866
2831
  };
@@ -3055,128 +3020,118 @@ async function executePhase(node, currentMeta, phaseState, phase, config, execut
3055
3020
  /**
3056
3021
  * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
3057
3022
  *
3023
+ * Progress events are rendered using Handlebars templates. Owner-path links
3024
+ * are resolved via the jeeves-server resolve-path API, with graceful
3025
+ * fallback to raw filesystem paths when the server is unreachable.
3026
+ *
3058
3027
  * @module progress
3059
3028
  */
3029
+ /** Compile raw template strings into Handlebars delegates. */
3030
+ function compileTemplates(raw) {
3031
+ const merged = {
3032
+ phaseStart: raw?.phaseStart ?? DEFAULT_TEMPLATE_STRINGS.phaseStart,
3033
+ phaseEnd: raw?.phaseEnd ?? DEFAULT_TEMPLATE_STRINGS.phaseEnd,
3034
+ phaseError: raw?.phaseError ?? DEFAULT_TEMPLATE_STRINGS.phaseError,
3035
+ };
3036
+ return {
3037
+ phaseStart: Handlebars.compile(merged.phaseStart),
3038
+ phaseEnd: Handlebars.compile(merged.phaseEnd),
3039
+ phaseError: Handlebars.compile(merged.phaseError),
3040
+ };
3041
+ }
3060
3042
  function formatNumber(n) {
3061
3043
  return n.toLocaleString('en-US');
3062
3044
  }
3063
- function formatTokens(tokens) {
3064
- return tokens !== undefined
3065
- ? formatNumber(tokens) + ' tokens'
3066
- : 'unknown tokens';
3067
- }
3068
- function formatSeconds(durationMs) {
3069
- const seconds = durationMs / 1000;
3070
- return Math.round(seconds).toString() + 's';
3071
- }
3072
- function titleCasePhase(phase) {
3073
- return phase.charAt(0).toUpperCase() + phase.slice(1);
3074
- }
3075
- /**
3076
- * URL-encode each path segment individually so that spaces and special
3077
- * characters are safe while preserving the `/` separators.
3078
- */
3079
- function encodePathSegments(p) {
3080
- return p
3081
- .split('/')
3082
- .map((seg) => encodeURIComponent(seg))
3083
- .join('/');
3084
- }
3085
- /**
3086
- * Resolve a filesystem path to a jeeves-server browse path segment.
3087
- *
3088
- * - Windows: `j:/domains/foo` → `/j/domains/foo` (drive-letter transform).
3089
- * - Linux with serverDriveRoots: `/opt/jeeves/content/slack` + roots
3090
- * `{ content: "/opt/jeeves/content" }` → `/content/slack`.
3091
- * - Linux without serverDriveRoots: returns the normalized path unchanged
3092
- * (browse link will be invalid, but this is the pre-existing behavior).
3093
- */
3094
- function resolveServerPath(path, serverDriveRoots) {
3095
- const normalized = normalizePath(path);
3096
- // Windows: drive letter present — use existing transform
3097
- if (/^[A-Za-z]:/.test(normalized)) {
3098
- return normalized.replace(/^([A-Za-z]):/, '/$1');
3099
- }
3100
- // Linux: attempt serverDriveRoots resolution (longest/most-specific root wins)
3101
- if (serverDriveRoots) {
3102
- const sorted = Object.entries(serverDriveRoots)
3103
- .map(([label, root]) => [label, normalizePath(root).replace(/\/+$/, '')])
3104
- .sort((a, b) => b[1].length - a[1].length);
3105
- for (const [label, normalizedRoot] of sorted) {
3106
- if (normalized === normalizedRoot ||
3107
- normalized.startsWith(normalizedRoot + '/')) {
3108
- const relative = normalized
3109
- .slice(normalizedRoot.length)
3110
- .replace(/^\//, '');
3111
- return `/${label}${relative ? '/' + relative : ''}`;
3112
- }
3045
+ /**
3046
+ * Resolve a filesystem path to a browse URL via the jeeves-server
3047
+ * resolve-path API. Returns the raw path as fallback on any failure.
3048
+ *
3049
+ * On HTTP 200, uses publicUrl if present, otherwise prefixes serverUrl
3050
+ * to browseUrl. On any error or non-200 response, returns fsPath as-is.
3051
+ */
3052
+ /** Timeout for resolve-path API calls (ms). */
3053
+ const RESOLVE_PATH_TIMEOUT_MS = 3000;
3054
+ async function resolveLink(fsPath, serverUrl) {
3055
+ try {
3056
+ const url = new URL('/api/resolve-path', serverUrl);
3057
+ url.searchParams.set('fsPath', fsPath);
3058
+ const res = await fetchWithTimeout(url.toString(), RESOLVE_PATH_TIMEOUT_MS);
3059
+ if (!res.ok)
3060
+ return fsPath;
3061
+ const data = (await res.json());
3062
+ if (data.publicUrl)
3063
+ return data.publicUrl;
3064
+ if (data.browseUrl) {
3065
+ const base = serverUrl.replace(/\/+$/, '');
3066
+ return base + data.browseUrl;
3113
3067
  }
3068
+ return fsPath;
3069
+ }
3070
+ catch {
3071
+ return fsPath;
3114
3072
  }
3115
- // Fallback: no drive roots configured or no match — return as-is
3116
- return normalized;
3117
- }
3118
- /** Build a link (or plain path) to the owner directory. */
3119
- function buildDirectoryLink(path, serverBaseUrl, serverDriveRoots) {
3120
- const resolved = resolveServerPath(path, serverDriveRoots);
3121
- const encoded = encodePathSegments(resolved);
3122
- if (!serverBaseUrl)
3123
- return resolved;
3124
- const base = serverBaseUrl.replace(/\/+$/, '');
3125
- return `${base}/path${encoded}`;
3126
- }
3127
- /** Build a link (or plain path) to the entity's meta.json output file. */
3128
- function buildMetaJsonLink(path, serverBaseUrl, serverDriveRoots) {
3129
- const resolved = resolveServerPath(path, serverDriveRoots);
3130
- const metaJsonPath = `${resolved}/.meta/meta.json`;
3131
- const encoded = encodePathSegments(metaJsonPath);
3132
- if (!serverBaseUrl)
3133
- return metaJsonPath;
3134
- const base = serverBaseUrl.replace(/\/+$/, '');
3135
- return `${base}/path${encoded}`;
3136
- }
3137
- function formatProgressEvent(event, serverBaseUrl, serverDriveRoots) {
3073
+ }
3074
+ /** Resolve browse links for both the owner directory and its meta.json. */
3075
+ async function resolveLinks(ownerPath, serverUrl) {
3076
+ const dirLink = await resolveLink(ownerPath, serverUrl);
3077
+ // Derive metaLink by appending /.meta/meta.json to the resolved dir link
3078
+ const metaLink = dirLink.replace(/\/+$/, '') + '/.meta/meta.json';
3079
+ return { dirLink, metaLink };
3080
+ }
3081
+ /**
3082
+ * Render the appropriate template for a progress event.
3083
+ *
3084
+ * @param event - The progress event to render.
3085
+ * @param templates - Compiled Handlebars templates.
3086
+ * @param serverUrl - jeeves-server URL for resolve-path API.
3087
+ * @returns Rendered message string.
3088
+ */
3089
+ async function renderProgressEvent(event, templates, serverUrl) {
3090
+ const ownerPath = event.path;
3091
+ const metaPath = ownerPath.replace(/\/+$/, '') + '/.meta/meta.json';
3092
+ const phase = (event.phase ?? 'unknown').toUpperCase();
3093
+ const { dirLink, metaLink } = await resolveLinks(ownerPath, serverUrl);
3094
+ const base = {
3095
+ dirPath: ownerPath,
3096
+ metaPath,
3097
+ dirLink,
3098
+ metaLink,
3099
+ phase,
3100
+ };
3138
3101
  switch (event.type) {
3139
- case 'synthesis_start': {
3140
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl, serverDriveRoots);
3141
- return `🔬 Started meta synthesis: ${dirLink}`;
3142
- }
3143
- case 'phase_start': {
3144
- if (!event.phase) {
3145
- return ' ⚙️ Phase started';
3146
- }
3147
- return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
3148
- }
3102
+ case 'phase_start':
3103
+ return templates.phaseStart(base);
3149
3104
  case 'phase_complete': {
3150
- const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
3151
- const tokenStr = formatTokens(event.tokens);
3152
- const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
3153
- return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
3154
- }
3155
- case 'synthesis_complete': {
3156
- const metaLink = buildMetaJsonLink(event.path, serverBaseUrl, serverDriveRoots);
3157
- const tokenStr = formatTokens(event.tokens);
3158
- const duration = event.durationMs !== undefined
3159
- ? formatSeconds(event.durationMs)
3160
- : '0.0s';
3161
- return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
3105
+ const seconds = event.durationMs !== undefined
3106
+ ? String(Math.round(event.durationMs / 1000))
3107
+ : '0';
3108
+ const tokens = event.tokens !== undefined ? formatNumber(event.tokens) : 'unknown';
3109
+ return templates.phaseEnd({ ...base, seconds, tokens });
3162
3110
  }
3163
3111
  case 'error': {
3164
- const dirLink = buildDirectoryLink(event.path, serverBaseUrl, serverDriveRoots);
3165
- const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
3166
- const error = event.error ?? 'Unknown error';
3167
- return `❌ Synthesis failed at ${phase}phase: ${dirLink}\n Error: ${error}`;
3112
+ const seconds = event.durationMs !== undefined
3113
+ ? String(Math.round(event.durationMs / 1000))
3114
+ : '0';
3115
+ const errorMsg = event.error ?? 'Unknown error';
3116
+ return templates.phaseError({ ...base, seconds, error: errorMsg });
3168
3117
  }
3169
- default: {
3118
+ default:
3170
3119
  return 'Unknown progress event';
3171
- }
3172
3120
  }
3173
3121
  }
3174
3122
  class ProgressReporter {
3175
3123
  config;
3176
3124
  logger;
3125
+ templates;
3126
+ /** Snapshot of template source strings used to compile `this.templates`. */
3127
+ lastTemplateSource;
3128
+ serverUrl;
3177
3129
  constructor(config, logger) {
3178
3130
  this.config = config;
3179
3131
  this.logger = logger;
3132
+ this.templates = compileTemplates(config.templates);
3133
+ this.lastTemplateSource = JSON.stringify(config.templates);
3134
+ this.serverUrl = config.serverUrl ?? 'http://127.0.0.1:1934';
3180
3135
  }
3181
3136
  async report(event) {
3182
3137
  // Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
@@ -3184,7 +3139,27 @@ class ProgressReporter {
3184
3139
  const target = this.config.reportTarget ?? this.config.reportChannel;
3185
3140
  if (!target)
3186
3141
  return;
3187
- const message = formatProgressEvent(event, this.config.serverBaseUrl, this.config.serverDriveRoots);
3142
+ // Detect template hot-reload: config.templates may be mutated by
3143
+ // applyHotReloadedConfig; recompile when the source strings change.
3144
+ const currentSource = JSON.stringify(this.config.templates);
3145
+ if (currentSource !== this.lastTemplateSource) {
3146
+ try {
3147
+ this.templates = compileTemplates(this.config.templates);
3148
+ this.lastTemplateSource = currentSource;
3149
+ this.logger.info('Progress templates recompiled (hot-reload)');
3150
+ }
3151
+ catch (err) {
3152
+ this.logger.warn({ err }, 'Failed to compile hot-reloaded templates; keeping previous templates');
3153
+ }
3154
+ }
3155
+ let message;
3156
+ try {
3157
+ message = await renderProgressEvent(event, this.templates, this.serverUrl);
3158
+ }
3159
+ catch (err) {
3160
+ this.logger.warn({ err }, 'Progress event rendering failed');
3161
+ return;
3162
+ }
3188
3163
  const url = new URL('/tools/invoke', this.config.gatewayUrl);
3189
3164
  const args = {
3190
3165
  action: 'send',
@@ -4144,6 +4119,100 @@ function registerConfigApplyRoute(app, configPath) {
4144
4119
  });
4145
4120
  }
4146
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
+
4147
4216
  /**
4148
4217
  * GET /metas — list metas with optional filters.
4149
4218
  * GET /metas/:path — single meta detail.
@@ -4762,6 +4831,7 @@ function registerStatusRoute(app, deps) {
4762
4831
  critic: emptyPhaseCounts(),
4763
4832
  };
4764
4833
  let nextPhase = null;
4834
+ let metaCounts = null;
4765
4835
  try {
4766
4836
  const metaResult = await cache.get(config, watcher);
4767
4837
  // Count raw phase states (before retry) for display
@@ -4783,6 +4853,17 @@ function registerStatusRoute(app, deps) {
4783
4853
  staleness: winner.effectiveStaleness,
4784
4854
  };
4785
4855
  }
4856
+ // Meta counts summary
4857
+ const summary = computeSummary(metaResult.entries, config.depthWeight);
4858
+ metaCounts = {
4859
+ total: summary.total,
4860
+ enabled: summary.total - summary.disabled,
4861
+ disabled: summary.disabled,
4862
+ neverSynthesized: summary.neverSynthesized,
4863
+ stale: summary.stale,
4864
+ errors: summary.errors,
4865
+ locked: summary.locked,
4866
+ };
4786
4867
  }
4787
4868
  catch {
4788
4869
  // Watcher unreachable — phase summary unavailable
@@ -4811,6 +4892,7 @@ function registerStatusRoute(app, deps) {
4811
4892
  },
4812
4893
  phaseStateSummary,
4813
4894
  nextPhase,
4895
+ metaCounts,
4814
4896
  };
4815
4897
  },
4816
4898
  });
@@ -5120,11 +5202,10 @@ class HttpWatcherClient {
5120
5202
  async post(endpoint, body) {
5121
5203
  const url = this.baseUrl + endpoint;
5122
5204
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
5123
- const res = await fetch(url, {
5205
+ const res = await fetchWithTimeout(url, this.timeoutMs, {
5124
5206
  method: 'POST',
5125
5207
  headers: { 'Content-Type': 'application/json' },
5126
5208
  body: JSON.stringify(body),
5127
- signal: AbortSignal.timeout(this.timeoutMs),
5128
5209
  });
5129
5210
  if (res.ok) {
5130
5211
  return res.json();
@@ -5166,25 +5247,6 @@ class HttpWatcherClient {
5166
5247
  *
5167
5248
  * @module bootstrap
5168
5249
  */
5169
- /**
5170
- * Compute per-cycle token total from a completed meta.
5171
- *
5172
- * Exported for testing.
5173
- *
5174
- * Uses `_synthesisCount` as a discriminator: after increment by `runCritic`,
5175
- * a value of 1 means architect ran this cycle (was 0 pre-increment),
5176
- * so all three phase token fields are summed. A value \> 1 means architect
5177
- * was skipped (cached brief reused), so only builder + critic are summed.
5178
- */
5179
- function computeCycleTokens(meta) {
5180
- const builderTokens = meta._builderTokens ?? 0;
5181
- const criticTokens = meta._criticTokens ?? 0;
5182
- const architectRan = (meta._synthesisCount ?? 1) === 1;
5183
- const architectTokens = architectRan
5184
- ? (meta._architectTokens ?? 0)
5185
- : 0;
5186
- return architectTokens + builderTokens + criticTokens;
5187
- }
5188
5250
  /**
5189
5251
  * Bootstrap the service: create logger, build server, start listening,
5190
5252
  * wire scheduler, queue processing, rule registration, config hot-reload,
@@ -5261,16 +5323,7 @@ async function startService(config, configPath) {
5261
5323
  // Track whether synthesis_start has been emitted (deferred until
5262
5324
  // a phase actually begins, avoiding orphan "Started" messages
5263
5325
  // when the meta is skipped/locked/fresh). See #165.
5264
- let synthesisStartEmitted = false;
5265
5326
  const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
5266
- // Emit synthesis_start on first phase_start (deferred guard)
5267
- if (evt.type === 'phase_start' && !synthesisStartEmitted) {
5268
- synthesisStartEmitted = true;
5269
- await progress.report({
5270
- type: 'synthesis_start',
5271
- path: ownerPath,
5272
- });
5273
- }
5274
5327
  // Wire current-phase tracking for GET /queue and POST /synthesize/abort
5275
5328
  if (evt.type === 'phase_start' && evt.phase) {
5276
5329
  queue.setCurrentPhase(ownerPath, evt.phase);
@@ -5312,19 +5365,8 @@ async function startService(config, configPath) {
5312
5365
  // Task #9: Reset backoff on ANY successful phase execution
5313
5366
  scheduler.resetBackoff();
5314
5367
  }
5315
- // Emit synthesis_complete only on full-cycle completion
5316
- if (result.cycleComplete) {
5317
- const updatedMeta = result.phaseResult?.updatedMeta;
5318
- const tokens = updatedMeta
5319
- ? computeCycleTokens(updatedMeta)
5320
- : undefined;
5321
- await progress.report({
5322
- type: 'synthesis_complete',
5323
- path: ownerPath,
5324
- durationMs,
5325
- tokens,
5326
- });
5327
- }
5368
+ // Note: synthesis_complete is no longer emitted; the phaseEnd template
5369
+ // for the final critic phase serves as the completion signal (#129).
5328
5370
  }
5329
5371
  catch (err) {
5330
5372
  stats.totalErrors++;
@@ -5552,8 +5594,8 @@ function registerCustomCliCommands(program) {
5552
5594
  /**
5553
5595
  * Jeeves component descriptor for jeeves-meta.
5554
5596
  *
5555
- * Single source of truth consumed by the service CLI, plugin writer, and
5556
- * config-apply pipeline.
5597
+ * Single source of truth consumed by the service CLI and the config-apply
5598
+ * pipeline.
5557
5599
  *
5558
5600
  * @module descriptor
5559
5601
  */
@@ -5566,8 +5608,6 @@ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
5566
5608
  servicePackage: META_COMPONENT.servicePackage,
5567
5609
  pluginPackage: META_COMPONENT.pluginPackage,
5568
5610
  defaultPort: META_COMPONENT.defaultPort,
5569
- // The runtime Zod custom validator only checks for a .parse() method.
5570
- // Use unknown cast to bridge the Zod v4 (service) → v3 (core SDK) type gap.
5571
5611
  configSchema: serviceConfigSchema,
5572
5612
  configFileName: 'config.json',
5573
5613
  initTemplate: () => serviceConfigSchema.parse({
@@ -5589,10 +5629,6 @@ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
5589
5629
  '-c',
5590
5630
  configPath,
5591
5631
  ],
5592
- sectionId: 'Meta',
5593
- refreshIntervalSeconds: 73,
5594
- generateToolsContent: () => '',
5595
- dependencies: { hard: ['watcher'], soft: [] },
5596
5632
  customCliCommands: registerCustomCliCommands,
5597
5633
  });
5598
5634