@karmaniverous/jeeves-meta 0.15.3 → 0.15.5

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 (89) hide show
  1. package/dist/archive/index.d.ts +10 -0
  2. package/dist/archive/listArchive.d.ts +12 -0
  3. package/dist/archive/prune.d.ts +14 -0
  4. package/dist/archive/readArchive.d.ts +30 -0
  5. package/dist/archive/readLatest.d.ts +13 -0
  6. package/dist/archive/snapshot.d.ts +17 -0
  7. package/dist/bootstrap.d.ts +15 -0
  8. package/dist/cache.d.ts +22 -0
  9. package/dist/cli/jeeves-meta/architect.md +17 -0
  10. package/dist/cli/jeeves-meta/index.js +811 -734
  11. package/dist/cli.d.ts +10 -0
  12. package/dist/configHotReload.d.ts +30 -0
  13. package/dist/configLoader.d.ts +37 -0
  14. package/dist/constants.d.ts +13 -0
  15. package/dist/customCliCommands.d.ts +13 -0
  16. package/dist/descriptor.d.ts +19 -0
  17. package/dist/discovery/buildMinimalNode.d.ts +22 -0
  18. package/dist/discovery/computeSummary.d.ts +17 -0
  19. package/dist/discovery/discoverMetas.d.ts +19 -0
  20. package/dist/discovery/index.d.ts +11 -0
  21. package/dist/discovery/listMetas.d.ts +63 -0
  22. package/dist/discovery/ownershipTree.d.ts +25 -0
  23. package/dist/discovery/scope.d.ts +47 -0
  24. package/dist/discovery/types.d.ts +25 -0
  25. package/dist/ema.d.ts +14 -0
  26. package/dist/errors.d.ts +15 -0
  27. package/dist/escapeGlob.d.ts +23 -0
  28. package/dist/executor/GatewayExecutor.d.ts +48 -0
  29. package/dist/executor/SpawnAbortedError.d.ts +9 -0
  30. package/dist/executor/SpawnTimeoutError.d.ts +13 -0
  31. package/dist/executor/index.d.ts +8 -0
  32. package/dist/index.d.ts +34 -1660
  33. package/dist/index.js +1434 -1767
  34. package/dist/interfaces/MetaContext.d.ts +36 -0
  35. package/dist/interfaces/MetaExecutor.d.ts +46 -0
  36. package/dist/interfaces/WatcherClient.d.ts +75 -0
  37. package/dist/interfaces/index.d.ts +8 -0
  38. package/dist/lock.d.ts +70 -0
  39. package/dist/logger/index.d.ts +27 -0
  40. package/dist/mtimeFilter.d.ts +26 -0
  41. package/dist/normalizePath.d.ts +6 -0
  42. package/dist/orchestrator/buildTask.d.ts +38 -0
  43. package/dist/orchestrator/contextPackage.d.ts +30 -0
  44. package/dist/orchestrator/index.d.ts +10 -0
  45. package/dist/orchestrator/orchestratePhase.d.ts +38 -0
  46. package/dist/orchestrator/parseOutput.d.ts +41 -0
  47. package/dist/orchestrator/runPhase.d.ts +40 -0
  48. package/dist/phaseState/derivePhaseState.d.ts +41 -0
  49. package/dist/phaseState/index.d.ts +9 -0
  50. package/dist/phaseState/invalidate.d.ts +41 -0
  51. package/dist/phaseState/phaseScheduler.d.ts +57 -0
  52. package/dist/phaseState/phaseTransitions.d.ts +83 -0
  53. package/dist/progress/index.d.ts +38 -0
  54. package/dist/prompts/architect.md +17 -0
  55. package/dist/prompts/index.d.ts +15 -0
  56. package/dist/queue/index.d.ts +131 -0
  57. package/dist/readMetaJson.d.ts +17 -0
  58. package/dist/routes/__testUtils.d.ts +37 -0
  59. package/dist/routes/config.d.ts +11 -0
  60. package/dist/routes/configApply.d.ts +13 -0
  61. package/dist/routes/index.d.ts +50 -0
  62. package/dist/routes/metas.d.ts +9 -0
  63. package/dist/routes/metasUpdate.d.ts +11 -0
  64. package/dist/routes/preview.d.ts +8 -0
  65. package/dist/routes/queue.d.ts +13 -0
  66. package/dist/routes/seed.d.ts +8 -0
  67. package/dist/routes/status.d.ts +13 -0
  68. package/dist/routes/synthesize.d.ts +12 -0
  69. package/dist/routes/unlock.d.ts +8 -0
  70. package/dist/rules/healthCheck.d.ts +36 -0
  71. package/dist/rules/index.d.ts +39 -0
  72. package/dist/rules/verify.d.ts +22 -0
  73. package/dist/scheduler/index.d.ts +66 -0
  74. package/dist/scheduling/index.d.ts +7 -0
  75. package/dist/scheduling/staleness.d.ts +68 -0
  76. package/dist/scheduling/weightedFormula.d.ts +38 -0
  77. package/dist/schema/config.d.ts +54 -0
  78. package/dist/schema/error.d.ts +6 -0
  79. package/dist/schema/index.d.ts +8 -0
  80. package/dist/schema/meta.d.ts +71 -0
  81. package/dist/seed/autoSeed.d.ts +30 -0
  82. package/dist/seed/createMeta.d.ts +38 -0
  83. package/dist/seed/index.d.ts +7 -0
  84. package/dist/server.d.ts +24 -0
  85. package/dist/shutdown/index.d.ts +33 -0
  86. package/dist/structureHash.d.ts +15 -0
  87. package/dist/watcher-client/HttpWatcherClient.d.ts +38 -0
  88. package/dist/watcher-client/index.d.ts +6 -0
  89. package/package.json +17 -27
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import fs, { readdirSync, readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, mkdirSync, copyFileSync, statSync, watchFile } from 'node:fs';
1
+ import fs, { readdirSync, readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync, mkdirSync, copyFileSync, watchFile } from 'node:fs';
2
2
  import path, { join, dirname, resolve, basename, relative, posix } from 'node:path';
3
3
  import { unlink, readFile, mkdir, writeFile, copyFile } from 'node:fs/promises';
4
+ import { z } from 'zod';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import process$1 from 'node:process';
6
7
  import { randomUUID, createHash } from 'node:crypto';
@@ -12,7 +13,6 @@ import require$$4 from 'util';
12
13
  import require$$5 from 'assert';
13
14
  import require$$2 from 'events';
14
15
  import vm from 'vm';
15
- import { z } from 'zod';
16
16
  import * as commander from 'commander';
17
17
  import 'node:child_process';
18
18
  import { tmpdir } from 'node:os';
@@ -67,6 +67,72 @@ async function pruneArchive(metaPath, maxArchive) {
67
67
  return toRemove;
68
68
  }
69
69
 
70
+ /**
71
+ * Shared component descriptor constants for jeeves-meta.
72
+ *
73
+ * Single source of truth consumed by both the service descriptor and
74
+ * the OpenClaw plugin registration.
75
+ */
76
+ /** Shared jeeves-meta component descriptor constants. */
77
+ const META_COMPONENT = {
78
+ name: 'meta',
79
+ servicePackage: '@karmaniverous/jeeves-meta',
80
+ pluginPackage: '@karmaniverous/jeeves-meta-openclaw',
81
+ defaultPort: 1938};
82
+
83
+ /**
84
+ * Structured error schema from a synthesis step failure.
85
+ *
86
+ */
87
+ /** Zod schema for synthesis step errors. */
88
+ const metaErrorSchema = z.object({
89
+ /** Which step failed: 'architect', 'builder', or 'critic'. */
90
+ step: z.enum(['architect', 'builder', 'critic']),
91
+ /** Error classification code. */
92
+ code: z.string(),
93
+ /** Human-readable error message. */
94
+ message: z.string(),
95
+ });
96
+
97
+ /** Zod schema for the core (library-compatible) meta configuration. */
98
+ /** Zod schema for the core (library-compatible) meta configuration. */
99
+ const metaConfigSchema = z.object({
100
+ /** Watcher service base URL. */
101
+ watcherUrl: z.url(),
102
+ /** OpenClaw gateway base URL for subprocess spawning. */
103
+ gatewayUrl: z.url().default('http://127.0.0.1:18789'),
104
+ /** Optional API key for gateway authentication. */
105
+ gatewayApiKey: z.string().optional(),
106
+ /** Run architect every N cycles (per meta). */
107
+ architectEvery: z.number().int().min(1).default(10),
108
+ /** Exponent for depth weighting in staleness formula. */
109
+ depthWeight: z.number().min(0).default(0.5),
110
+ /** Maximum archive snapshots to retain per meta. */
111
+ maxArchive: z.number().int().min(1).default(20),
112
+ /** Maximum lines of context to include in subprocess prompts. */
113
+ maxLines: z.number().int().min(50).default(500),
114
+ /** Architect subprocess timeout in seconds. */
115
+ architectTimeout: z.number().int().min(30).default(180),
116
+ /** Builder subprocess timeout in seconds. */
117
+ builderTimeout: z.number().int().min(60).default(360),
118
+ /** Critic subprocess timeout in seconds. */
119
+ criticTimeout: z.number().int().min(30).default(240),
120
+ /** Thinking level for spawned synthesis sessions. */
121
+ thinking: z.string().default('low'),
122
+ /** Resolved architect system prompt text. Falls back to built-in default. */
123
+ defaultArchitect: z.string().optional(),
124
+ /** Resolved critic system prompt text. Falls back to built-in default. */
125
+ defaultCritic: z.string().optional(),
126
+ /** Skip unchanged candidates, bump _generatedAt. */
127
+ skipUnchanged: z.boolean().default(true),
128
+ /** Watcher metadata properties applied to live .meta/meta.json files. */
129
+ metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
130
+ /** Watcher metadata properties applied to archive snapshots. */
131
+ metaArchiveProperty: z
132
+ .record(z.string(), z.unknown())
133
+ .default({ _meta: 'archive' }),
134
+ });
135
+
70
136
  /**
71
137
  * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
72
138
  *
@@ -74,7 +140,6 @@ async function pruneArchive(metaPath, maxArchive) {
74
140
  * ensures all paths in the library use the same convention, regardless of
75
141
  * the platform's native separator.
76
142
  *
77
- * @module normalizePath
78
143
  */
79
144
  /**
80
145
  * Normalize a file path to forward slashes.
@@ -85,6 +150,22 @@ async function pruneArchive(metaPath, maxArchive) {
85
150
  function normalizePath(p) {
86
151
  return p.replaceAll('\\', '/');
87
152
  }
153
+ /** Valid states for a synthesis phase. */
154
+ const phaseStatuses = [
155
+ 'fresh',
156
+ 'stale',
157
+ 'pending',
158
+ 'running',
159
+ 'failed',
160
+ ];
161
+ /** Zod schema for a per-phase status value. */
162
+ const phaseStatusSchema = z.enum(phaseStatuses);
163
+ /** Zod schema for the per-meta phase state record. */
164
+ const phaseStateSchema = z.object({
165
+ architect: phaseStatusSchema,
166
+ builder: phaseStatusSchema,
167
+ critic: phaseStatusSchema,
168
+ });
88
169
 
89
170
  /**
90
171
  * Archive reading helpers — watcher scan with filesystem fallback.
@@ -322,7 +403,7 @@ function packageDirectorySync({cwd, ignoreTypeOnlyPackageJson} = {}) {
322
403
  * @module constants
323
404
  */
324
405
  /** Default HTTP port for the jeeves-meta service. */
325
- const DEFAULT_PORT = 1938;
406
+ const DEFAULT_PORT = META_COMPONENT.defaultPort;
326
407
  /** Default port as a string (for Commander CLI defaults). */
327
408
  const DEFAULT_PORT_STR = String(DEFAULT_PORT);
328
409
  /** Service name identifier. */
@@ -1503,11 +1584,11 @@ var hasRequiredRetry$1;
1503
1584
  function requireRetry$1 () {
1504
1585
  if (hasRequiredRetry$1) return retry$1;
1505
1586
  hasRequiredRetry$1 = 1;
1506
- (function (exports$1) {
1587
+ (function (exports) {
1507
1588
  var RetryOperation = requireRetry_operation();
1508
1589
 
1509
- exports$1.operation = function(options) {
1510
- var timeouts = exports$1.timeouts(options);
1590
+ exports.operation = function(options) {
1591
+ var timeouts = exports.timeouts(options);
1511
1592
  return new RetryOperation(timeouts, {
1512
1593
  forever: options && options.forever,
1513
1594
  unref: options && options.unref,
@@ -1515,7 +1596,7 @@ function requireRetry$1 () {
1515
1596
  });
1516
1597
  };
1517
1598
 
1518
- exports$1.timeouts = function(options) {
1599
+ exports.timeouts = function(options) {
1519
1600
  if (options instanceof Array) {
1520
1601
  return [].concat(options);
1521
1602
  }
@@ -1552,7 +1633,7 @@ function requireRetry$1 () {
1552
1633
  return timeouts;
1553
1634
  };
1554
1635
 
1555
- exports$1.createTimeout = function(attempt, opts) {
1636
+ exports.createTimeout = function(attempt, opts) {
1556
1637
  var random = (opts.randomize)
1557
1638
  ? (Math.random() + 1)
1558
1639
  : 1;
@@ -1563,7 +1644,7 @@ function requireRetry$1 () {
1563
1644
  return timeout;
1564
1645
  };
1565
1646
 
1566
- exports$1.wrap = function(obj, options, methods) {
1647
+ exports.wrap = function(obj, options, methods) {
1567
1648
  if (options instanceof Array) {
1568
1649
  methods = options;
1569
1650
  options = null;
@@ -1583,7 +1664,7 @@ function requireRetry$1 () {
1583
1664
  var original = obj[method];
1584
1665
 
1585
1666
  obj[method] = function retryWrapper(original) {
1586
- var op = exports$1.operation(options);
1667
+ var op = exports.operation(options);
1587
1668
  var args = Array.prototype.slice.call(arguments, 1);
1588
1669
  var callback = args.pop();
1589
1670
 
@@ -4592,7 +4673,7 @@ var hasRequiredRe;
4592
4673
  function requireRe () {
4593
4674
  if (hasRequiredRe) return re.exports;
4594
4675
  hasRequiredRe = 1;
4595
- (function (module, exports$1) {
4676
+ (function (module, exports) {
4596
4677
 
4597
4678
  const {
4598
4679
  MAX_SAFE_COMPONENT_LENGTH,
@@ -4600,14 +4681,14 @@ function requireRe () {
4600
4681
  MAX_LENGTH,
4601
4682
  } = requireConstants();
4602
4683
  const debug = requireDebug();
4603
- exports$1 = module.exports = {};
4684
+ exports = module.exports = {};
4604
4685
 
4605
4686
  // The actual regexps go on exports.re
4606
- const re = exports$1.re = [];
4607
- const safeRe = exports$1.safeRe = [];
4608
- const src = exports$1.src = [];
4609
- const safeSrc = exports$1.safeSrc = [];
4610
- const t = exports$1.t = {};
4687
+ const re = exports.re = [];
4688
+ const safeRe = exports.safeRe = [];
4689
+ const src = exports.src = [];
4690
+ const safeSrc = exports.safeSrc = [];
4691
+ const t = exports.t = {};
4611
4692
  let R = 0;
4612
4693
 
4613
4694
  const LETTERDASHNUMBER = '[a-zA-Z0-9-]';
@@ -4730,7 +4811,7 @@ function requireRe () {
4730
4811
  createToken('GTLT', '((?:<|>)?=?)');
4731
4812
 
4732
4813
  // Something like "2.*" or "1.2.x".
4733
- // Note that "x.x" is a valid xRange identifer, meaning "any version"
4814
+ // Note that "x.x" is a valid xRange identifier, meaning "any version"
4734
4815
  // Only the first item is strictly required.
4735
4816
  createToken('XRANGEIDENTIFIERLOOSE', `${src[t.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`);
4736
4817
  createToken('XRANGEIDENTIFIER', `${src[t.NUMERICIDENTIFIER]}|x|X|\\*`);
@@ -4771,7 +4852,7 @@ function requireRe () {
4771
4852
  createToken('LONETILDE', '(?:~>?)');
4772
4853
 
4773
4854
  createToken('TILDETRIM', `(\\s*)${src[t.LONETILDE]}\\s+`, true);
4774
- exports$1.tildeTrimReplace = '$1~';
4855
+ exports.tildeTrimReplace = '$1~';
4775
4856
 
4776
4857
  createToken('TILDE', `^${src[t.LONETILDE]}${src[t.XRANGEPLAIN]}$`);
4777
4858
  createToken('TILDELOOSE', `^${src[t.LONETILDE]}${src[t.XRANGEPLAINLOOSE]}$`);
@@ -4781,7 +4862,7 @@ function requireRe () {
4781
4862
  createToken('LONECARET', '(?:\\^)');
4782
4863
 
4783
4864
  createToken('CARETTRIM', `(\\s*)${src[t.LONECARET]}\\s+`, true);
4784
- exports$1.caretTrimReplace = '$1^';
4865
+ exports.caretTrimReplace = '$1^';
4785
4866
 
4786
4867
  createToken('CARET', `^${src[t.LONECARET]}${src[t.XRANGEPLAIN]}$`);
4787
4868
  createToken('CARETLOOSE', `^${src[t.LONECARET]}${src[t.XRANGEPLAINLOOSE]}$`);
@@ -4794,7 +4875,7 @@ function requireRe () {
4794
4875
  // it modifies, so that `> 1.2.3` ==> `>1.2.3`
4795
4876
  createToken('COMPARATORTRIM', `(\\s*)${src[t.GTLT]
4796
4877
  }\\s*(${src[t.LOOSEPLAIN]}|${src[t.XRANGEPLAIN]})`, true);
4797
- exports$1.comparatorTrimReplace = '$1$2$3';
4878
+ exports.comparatorTrimReplace = '$1$2$3';
4798
4879
 
4799
4880
  // Something like `1.2.3 - 1.2.4`
4800
4881
  // Note that these all use the loose form, because they'll be
@@ -5726,6 +5807,62 @@ function requireCoerce () {
5726
5807
  return coerce_1;
5727
5808
  }
5728
5809
 
5810
+ var truncate_1;
5811
+ var hasRequiredTruncate;
5812
+
5813
+ function requireTruncate () {
5814
+ if (hasRequiredTruncate) return truncate_1;
5815
+ hasRequiredTruncate = 1;
5816
+
5817
+ const parse = requireParse();
5818
+ const constants = requireConstants();
5819
+ const SemVer = requireSemver$1();
5820
+
5821
+ const truncate = (version, truncation, options) => {
5822
+ if (!constants.RELEASE_TYPES.includes(truncation)) {
5823
+ return null
5824
+ }
5825
+
5826
+ const clonedVersion = cloneInputVersion(version, options);
5827
+ return clonedVersion && doTruncation(clonedVersion, truncation)
5828
+ };
5829
+
5830
+ const cloneInputVersion = (version, options) => {
5831
+ const versionStringToParse = (
5832
+ version instanceof SemVer ? version.version : version
5833
+ );
5834
+
5835
+ return parse(versionStringToParse, options)
5836
+ };
5837
+
5838
+ const doTruncation = (version, truncation) => {
5839
+ if (isPrerelease(truncation)) {
5840
+ return version.version
5841
+ }
5842
+
5843
+ version.prerelease = [];
5844
+
5845
+ switch (truncation) {
5846
+ case 'major':
5847
+ version.minor = 0;
5848
+ version.patch = 0;
5849
+ break
5850
+ case 'minor':
5851
+ version.patch = 0;
5852
+ break
5853
+ }
5854
+
5855
+ return version.format()
5856
+ };
5857
+
5858
+ const isPrerelease = (type) => {
5859
+ return type.startsWith('pre')
5860
+ };
5861
+
5862
+ truncate_1 = truncate;
5863
+ return truncate_1;
5864
+ }
5865
+
5729
5866
  var lrucache;
5730
5867
  var hasRequiredLrucache;
5731
5868
 
@@ -7175,6 +7312,7 @@ function requireSemver () {
7175
7312
  const lte = requireLte();
7176
7313
  const cmp = requireCmp();
7177
7314
  const coerce = requireCoerce();
7315
+ const truncate = requireTruncate();
7178
7316
  const Comparator = requireComparator();
7179
7317
  const Range = requireRange();
7180
7318
  const satisfies = requireSatisfies();
@@ -7213,6 +7351,7 @@ function requireSemver () {
7213
7351
  lte,
7214
7352
  cmp,
7215
7353
  coerce,
7354
+ truncate,
7216
7355
  Comparator,
7217
7356
  Range,
7218
7357
  satisfies,
@@ -7510,31 +7649,31 @@ var hasRequiredExtraTypings;
7510
7649
  function requireExtraTypings () {
7511
7650
  if (hasRequiredExtraTypings) return extraTypings.exports;
7512
7651
  hasRequiredExtraTypings = 1;
7513
- (function (module, exports$1) {
7652
+ (function (module, exports) {
7514
7653
  const commander = require$$0;
7515
7654
 
7516
- exports$1 = module.exports = {};
7655
+ exports = module.exports = {};
7517
7656
 
7518
7657
  // Return a different global program than commander,
7519
7658
  // and don't also return it as default export.
7520
- exports$1.program = new commander.Command();
7659
+ exports.program = new commander.Command();
7521
7660
 
7522
7661
  /**
7523
7662
  * Expose classes. The FooT versions are just types, so return Commander original implementations!
7524
7663
  */
7525
7664
 
7526
- exports$1.Argument = commander.Argument;
7527
- exports$1.Command = commander.Command;
7528
- exports$1.CommanderError = commander.CommanderError;
7529
- exports$1.Help = commander.Help;
7530
- exports$1.InvalidArgumentError = commander.InvalidArgumentError;
7531
- exports$1.InvalidOptionArgumentError = commander.InvalidArgumentError; // Deprecated
7532
- exports$1.Option = commander.Option;
7665
+ exports.Argument = commander.Argument;
7666
+ exports.Command = commander.Command;
7667
+ exports.CommanderError = commander.CommanderError;
7668
+ exports.Help = commander.Help;
7669
+ exports.InvalidArgumentError = commander.InvalidArgumentError;
7670
+ exports.InvalidOptionArgumentError = commander.InvalidArgumentError; // Deprecated
7671
+ exports.Option = commander.Option;
7533
7672
 
7534
- exports$1.createCommand = (name) => new commander.Command(name);
7535
- exports$1.createOption = (flags, description) =>
7673
+ exports.createCommand = (name) => new commander.Command(name);
7674
+ exports.createOption = (flags, description) =>
7536
7675
  new commander.Option(flags, description);
7537
- exports$1.createArgument = (name, description) =>
7676
+ exports.createArgument = (name, description) =>
7538
7677
  new commander.Argument(name, description);
7539
7678
  } (extraTypings, extraTypings.exports));
7540
7679
  return extraTypings.exports;
@@ -8116,508 +8255,247 @@ function registerCustomCliCommands(program) {
8116
8255
  }
8117
8256
 
8118
8257
  /**
8119
- * Shared live config hot-reload support.
8258
+ * Compute summary statistics from an array of MetaEntry objects.
8120
8259
  *
8121
- * Used by both file-watch reloads in bootstrap and POST /config/apply
8122
- * via the component descriptor's onConfigApply callback.
8260
+ * Shared between listMetas() (full list) and route handlers (filtered lists).
8123
8261
  *
8124
- * @module configHotReload
8262
+ * @module discovery/computeSummary
8125
8263
  */
8126
8264
  /**
8127
- * Fields that require a service restart to take effect.
8265
+ * Compute summary statistics from a list of meta entries.
8128
8266
  *
8129
- * Shared between the descriptor's `onConfigApply` and the file-watcher
8130
- * hot-reload in `bootstrap.ts`.
8267
+ * @param entries - Enriched meta entries (full or filtered).
8268
+ * @param depthWeight - Config depth weight for effective staleness calculation.
8269
+ * @returns Aggregated summary statistics.
8131
8270
  */
8132
- const RESTART_REQUIRED_FIELDS = [
8133
- 'port',
8134
- 'watcherUrl',
8135
- 'gatewayUrl',
8136
- 'gatewayApiKey',
8137
- 'defaultArchitect',
8138
- 'defaultCritic',
8139
- ];
8140
- let runtime = null;
8141
- /** Register the active service runtime for config-apply hot reload. */
8142
- function registerConfigHotReloadRuntime(nextRuntime) {
8143
- runtime = nextRuntime;
8144
- }
8145
- /** Apply hot-reloadable config changes to the live shared config object. */
8146
- function applyHotReloadedConfig(newConfig) {
8147
- if (!runtime)
8148
- return;
8149
- const { config, logger, scheduler } = runtime;
8150
- for (const field of RESTART_REQUIRED_FIELDS) {
8151
- const oldVal = config[field];
8152
- const nextVal = newConfig[field];
8153
- if (oldVal !== nextVal) {
8154
- logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
8271
+ function computeSummary(entries, depthWeight) {
8272
+ let staleCount = 0;
8273
+ let errorCount = 0;
8274
+ let lockedCount = 0;
8275
+ let disabledCount = 0;
8276
+ let neverSynthesizedCount = 0;
8277
+ let totalArchitectTokens = 0;
8278
+ let totalBuilderTokens = 0;
8279
+ let totalCriticTokens = 0;
8280
+ let stalestPath = null;
8281
+ let stalestEffective = -1;
8282
+ let lastSynthesizedPath = null;
8283
+ let lastSynthesizedAt = null;
8284
+ for (const e of entries) {
8285
+ if (e.stalenessSeconds > 0)
8286
+ staleCount++;
8287
+ if (e.hasError)
8288
+ errorCount++;
8289
+ if (e.locked)
8290
+ lockedCount++;
8291
+ if (e.disabled)
8292
+ disabledCount++;
8293
+ if (e.lastSynthesized === null)
8294
+ neverSynthesizedCount++;
8295
+ totalArchitectTokens += e.architectTokens ?? 0;
8296
+ totalBuilderTokens += e.builderTokens ?? 0;
8297
+ totalCriticTokens += e.criticTokens ?? 0;
8298
+ // Track last synthesized
8299
+ if (e.lastSynthesized &&
8300
+ (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
8301
+ lastSynthesizedAt = e.lastSynthesized;
8302
+ lastSynthesizedPath = e.path;
8303
+ }
8304
+ // Track stalest (effective staleness for scheduling)
8305
+ const depthFactor = Math.pow(1 + depthWeight, e.depth);
8306
+ const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
8307
+ if (effectiveStaleness > stalestEffective) {
8308
+ stalestEffective = effectiveStaleness;
8309
+ stalestPath = e.path;
8155
8310
  }
8156
8311
  }
8157
- if (newConfig.schedule !== config.schedule) {
8158
- scheduler?.updateSchedule(newConfig.schedule);
8159
- config.schedule = newConfig.schedule;
8160
- logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
8161
- }
8162
- if (newConfig.logging.level !== config.logging.level) {
8163
- logger.level = newConfig.logging.level;
8164
- config.logging.level = newConfig.logging.level;
8165
- logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
8166
- }
8167
- const restartSet = new Set(RESTART_REQUIRED_FIELDS);
8168
- for (const key of Object.keys(newConfig)) {
8169
- if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
8312
+ return {
8313
+ total: entries.length,
8314
+ stale: staleCount,
8315
+ errors: errorCount,
8316
+ locked: lockedCount,
8317
+ disabled: disabledCount,
8318
+ neverSynthesized: neverSynthesizedCount,
8319
+ tokens: {
8320
+ architect: totalArchitectTokens,
8321
+ builder: totalBuilderTokens,
8322
+ critic: totalCriticTokens,
8323
+ },
8324
+ stalestPath,
8325
+ lastSynthesizedPath,
8326
+ lastSynthesizedAt,
8327
+ };
8328
+ }
8329
+
8330
+ /**
8331
+ * Discover .meta/ directories via watcher `/walk` endpoint.
8332
+ *
8333
+ * Uses filesystem enumeration through the watcher (not Qdrant) to find
8334
+ * all `.meta/meta.json` files and returns deduplicated meta directory paths.
8335
+ *
8336
+ * @module discovery/discoverMetas
8337
+ */
8338
+ /**
8339
+ * Discover all .meta/ directories via watcher walk.
8340
+ *
8341
+ * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
8342
+ * and returns deduplicated meta directory paths.
8343
+ *
8344
+ * @param watcher - WatcherClient for walk queries.
8345
+ * @returns Array of normalized .meta/ directory paths.
8346
+ */
8347
+ async function discoverMetas(watcher) {
8348
+ const allPaths = await watcher.walk(['**/.meta/meta.json']);
8349
+ // Deduplicate by .meta/ directory path (handles multi-chunk files)
8350
+ const seen = new Set();
8351
+ const metaPaths = [];
8352
+ for (const filePath of allPaths) {
8353
+ const fp = normalizePath(filePath);
8354
+ // Derive .meta/ directory from file_path (strip /meta.json)
8355
+ const metaPath = fp.replace(/\/meta\.json$/, '');
8356
+ if (seen.has(metaPath))
8170
8357
  continue;
8171
- }
8172
- const oldVal = config[key];
8173
- const nextVal = newConfig[key];
8174
- if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
8175
- config[key] = nextVal;
8176
- logger.info({ field: key }, 'Config field hot-reloaded');
8177
- }
8358
+ seen.add(metaPath);
8359
+ metaPaths.push(metaPath);
8178
8360
  }
8361
+ return metaPaths;
8179
8362
  }
8180
8363
 
8181
8364
  /**
8182
- * Zod schema for jeeves-meta service configuration.
8365
+ * File-system lock for preventing concurrent synthesis on the same meta.
8183
8366
  *
8184
- * The service config is a strict superset of the core (library-compatible) meta config.
8367
+ * Lock file: .meta/.lock containing `_lockPid` + `_lockStartedAt` (underscore-prefixed
8368
+ * reserved keys, consistent with meta.json conventions).
8369
+ * Stale timeout: 30 minutes.
8185
8370
  *
8186
- * @module schema/config
8371
+ * @module lock
8187
8372
  */
8188
- /** Zod schema for the core (library-compatible) meta configuration. */
8189
- const metaConfigSchema = z.object({
8190
- /** Watcher service base URL. */
8191
- watcherUrl: z.url(),
8192
- /** OpenClaw gateway base URL for subprocess spawning. */
8193
- gatewayUrl: z.url().default('http://127.0.0.1:18789'),
8194
- /** Optional API key for gateway authentication. */
8195
- gatewayApiKey: z.string().optional(),
8196
- /** Run architect every N cycles (per meta). */
8197
- architectEvery: z.number().int().min(1).default(10),
8198
- /** Exponent for depth weighting in staleness formula. */
8199
- depthWeight: z.number().min(0).default(0.5),
8200
- /** Maximum archive snapshots to retain per meta. */
8201
- maxArchive: z.number().int().min(1).default(20),
8202
- /** Maximum lines of context to include in subprocess prompts. */
8203
- maxLines: z.number().int().min(50).default(500),
8204
- /** Architect subprocess timeout in seconds. */
8205
- architectTimeout: z.number().int().min(30).default(180),
8206
- /** Builder subprocess timeout in seconds. */
8207
- builderTimeout: z.number().int().min(60).default(360),
8208
- /** Critic subprocess timeout in seconds. */
8209
- criticTimeout: z.number().int().min(30).default(240),
8210
- /** Thinking level for spawned synthesis sessions. */
8211
- thinking: z.string().default('low'),
8212
- /** Resolved architect system prompt text. Falls back to built-in default. */
8213
- defaultArchitect: z.string().optional(),
8214
- /** Resolved critic system prompt text. Falls back to built-in default. */
8215
- defaultCritic: z.string().optional(),
8216
- /** Skip unchanged candidates, bump _generatedAt. */
8217
- skipUnchanged: z.boolean().default(true),
8218
- /** Watcher metadata properties applied to live .meta/meta.json files. */
8219
- metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
8220
- /** Watcher metadata properties applied to archive snapshots. */
8221
- metaArchiveProperty: z
8222
- .record(z.string(), z.unknown())
8223
- .default({ _meta: 'archive' }),
8224
- });
8225
- /** Zod schema for logging configuration. */
8226
- const loggingSchema = z.object({
8227
- /** Log level. */
8228
- level: z.string().default('info'),
8229
- /** Optional file path for log output. */
8230
- file: z.string().optional(),
8231
- });
8232
- /** Zod schema for a single auto-seed policy rule. */
8233
- const autoSeedRuleSchema = z.object({
8234
- /** Glob pattern matched against watcher walk results. */
8235
- match: z.string(),
8236
- /** Optional steering prompt for seeded metas. */
8237
- steer: z.string().optional(),
8238
- /** Optional cross-references for seeded metas. */
8239
- crossRefs: z.array(z.string()).optional(),
8240
- });
8241
- /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
8242
- const serviceConfigSchema = metaConfigSchema.extend({
8243
- /** HTTP port for the service (default: 1938). */
8244
- port: z.number().int().min(1).max(65535).default(1938),
8245
- /** Cron schedule for synthesis cycles (default: every 30 min). */
8246
- schedule: z.string().default('*/30 * * * *'),
8247
- /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
8248
- reportChannel: z.string().optional(),
8249
- /** Channel/user ID to send progress messages to. */
8250
- reportTarget: z.string().optional(),
8251
- /** Optional base URL for the service, used to construct entity links in progress reports. */
8252
- serverBaseUrl: z.string().optional(),
8253
- /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
8254
- watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
8255
- /** Logging configuration. */
8256
- logging: loggingSchema.default(() => loggingSchema.parse({})),
8257
- /**
8258
- * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
8259
- * Rules are evaluated in order; last match wins for steer/crossRefs.
8260
- */
8261
- autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
8262
- });
8263
-
8373
+ const LOCK_FILE = '.lock';
8264
8374
  /**
8265
- * Load and resolve jeeves-meta service config.
8375
+ * Resolve a path to a .meta directory.
8266
8376
  *
8267
- * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
8377
+ * If the path already ends with '.meta', returns it as-is.
8378
+ * Otherwise, appends '.meta' as a subdirectory.
8268
8379
  *
8269
- * @module configLoader
8380
+ * @param inputPath - Path that may or may not end with '.meta'.
8381
+ * @returns The resolved .meta directory path.
8270
8382
  */
8383
+ function resolveMetaDir(inputPath) {
8384
+ return inputPath.endsWith('.meta') ? inputPath : join(inputPath, '.meta');
8385
+ }
8386
+ const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
8271
8387
  /**
8272
- * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
8388
+ * Read and classify the state of a .meta/.lock file.
8273
8389
  *
8274
- * @param value - Arbitrary JSON-compatible value.
8275
- * @returns Value with env-var placeholders resolved.
8390
+ * @param metaPath - Absolute path to the .meta directory.
8391
+ * @returns Parsed lock state.
8276
8392
  */
8277
- function substituteEnvVars(value) {
8278
- if (typeof value === 'string') {
8279
- return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
8280
- const envVal = process.env[name];
8281
- if (envVal === undefined) {
8282
- throw new Error(`Environment variable ${name} is not set`);
8283
- }
8284
- return envVal;
8285
- });
8286
- }
8287
- if (Array.isArray(value)) {
8288
- return value.map(substituteEnvVars);
8393
+ function readLockState(metaPath) {
8394
+ const lockPath = join(metaPath, LOCK_FILE);
8395
+ if (!existsSync(lockPath)) {
8396
+ return { exists: false, staged: false, active: false, data: null };
8289
8397
  }
8290
- if (value !== null && typeof value === 'object') {
8291
- const result = {};
8292
- for (const [key, val] of Object.entries(value)) {
8293
- result[key] = substituteEnvVars(val);
8398
+ try {
8399
+ const raw = readFileSync(lockPath, 'utf8');
8400
+ const data = JSON.parse(raw);
8401
+ if ('_id' in data) {
8402
+ return { exists: true, staged: true, active: false, data };
8294
8403
  }
8295
- return result;
8404
+ const startedAt = data._lockStartedAt;
8405
+ if (startedAt) {
8406
+ const lockAge = Date.now() - new Date(startedAt).getTime();
8407
+ return {
8408
+ exists: true,
8409
+ staged: false,
8410
+ active: lockAge < STALE_TIMEOUT_MS,
8411
+ data,
8412
+ };
8413
+ }
8414
+ return { exists: true, staged: false, active: false, data };
8415
+ }
8416
+ catch {
8417
+ return { exists: true, staged: false, active: false, data: null };
8296
8418
  }
8297
- return value;
8298
8419
  }
8299
8420
  /**
8300
- * Resolve \@file: references in a config value.
8421
+ * Attempt to acquire a lock on a .meta directory.
8301
8422
  *
8302
- * @param value - String value that may start with "\@file:".
8303
- * @param baseDir - Base directory for resolving relative paths.
8304
- * @returns The resolved string (file contents or original value).
8423
+ * @param metaPath - Absolute path to the .meta directory.
8424
+ * @returns True if lock was acquired, false if already locked (non-stale).
8305
8425
  */
8306
- function resolveFileRef(value, baseDir) {
8307
- if (!value.startsWith('@file:'))
8308
- return value;
8309
- const filePath = join(baseDir, value.slice(6));
8310
- return readFileSync(filePath, 'utf8');
8426
+ function acquireLock(metaPath) {
8427
+ const state = readLockState(metaPath);
8428
+ // Active non-stale lock — cannot acquire
8429
+ if (state.active)
8430
+ return false;
8431
+ // Staged, stale, corrupt, or missing — safe to (over)write
8432
+ const lockPath = join(metaPath, LOCK_FILE);
8433
+ const lock = {
8434
+ _lockPid: process.pid,
8435
+ _lockStartedAt: new Date().toISOString(),
8436
+ };
8437
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
8438
+ return true;
8311
8439
  }
8312
8440
  /**
8313
- * Migrate legacy config path to the new canonical location.
8314
- *
8315
- * If the old path `{configRoot}/jeeves-meta.config.json` exists and the new
8316
- * path `{configRoot}/jeeves-meta/config.json` does NOT exist, copies the file
8317
- * to the new location and logs a warning.
8441
+ * Release a lock on a .meta directory.
8318
8442
  *
8319
- * @param configRoot - Root directory for configuration files.
8320
- * @param warn - Optional callback for logging the migration warning.
8443
+ * @param metaPath - Absolute path to the .meta directory.
8321
8444
  */
8322
- function migrateConfigPath(configRoot, warn) {
8323
- const oldPath = join(configRoot, 'jeeves-meta.config.json');
8324
- const newDir = join(configRoot, 'jeeves-meta');
8325
- const newPath = join(newDir, 'config.json');
8326
- if (existsSync(oldPath) && !existsSync(newPath)) {
8327
- mkdirSync(newDir, { recursive: true });
8328
- copyFileSync(oldPath, newPath);
8329
- const message = `Migrated config from ${oldPath} to ${newPath}. The old file can be removed.`;
8330
- if (warn) {
8331
- warn(message);
8332
- }
8333
- else {
8334
- console.warn(`[jeeves-meta] ${message}`);
8335
- }
8445
+ function releaseLock(metaPath) {
8446
+ const lockPath = join(metaPath, LOCK_FILE);
8447
+ try {
8448
+ unlinkSync(lockPath);
8449
+ }
8450
+ catch {
8451
+ // Already removed or never existed
8336
8452
  }
8337
8453
  }
8338
8454
  /**
8339
- * Resolve config path from --config flag or JEEVES_META_CONFIG env var.
8455
+ * Check if a .meta directory is currently locked (non-stale).
8340
8456
  *
8341
- * @param args - CLI arguments (process.argv.slice(2)).
8342
- * @returns Resolved config path.
8343
- * @throws If no config path found.
8457
+ * @param metaPath - Absolute path to the .meta directory.
8458
+ * @returns True if locked and not stale.
8344
8459
  */
8345
- function resolveConfigPath(args) {
8346
- let configIdx = args.indexOf('--config');
8347
- if (configIdx === -1)
8348
- configIdx = args.indexOf('-c');
8349
- if (configIdx !== -1 && args[configIdx + 1]) {
8350
- return args[configIdx + 1];
8351
- }
8352
- const envPath = process.env['JEEVES_META_CONFIG'];
8353
- if (envPath)
8354
- return envPath;
8355
- throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
8460
+ function isLocked(metaPath) {
8461
+ return readLockState(metaPath).active;
8356
8462
  }
8357
8463
  /**
8358
- * Load service config from a JSON file.
8464
+ * Clean up stale lock files on startup.
8359
8465
  *
8360
- * Resolves \@file: references for defaultArchitect and defaultCritic,
8361
- * and substitutes environment-variable placeholders throughout.
8466
+ * For each .meta directory found via the provided paths:
8467
+ * - If lock contains PID-only data (synthesis incomplete), delete it.
8468
+ * - If lock contains staged result (_id present), log warning and delete.
8362
8469
  *
8363
- * @param configPath - Path to config JSON file.
8364
- * @returns Validated ServiceConfig.
8470
+ * @param metaPaths - Array of .meta directory paths to check.
8471
+ * @param logger - Optional logger for warnings.
8365
8472
  */
8366
- function loadServiceConfig(configPath) {
8367
- const rawText = readFileSync(configPath, 'utf8');
8368
- const raw = substituteEnvVars(JSON.parse(rawText));
8369
- const baseDir = dirname(configPath);
8370
- if (typeof raw['defaultArchitect'] === 'string') {
8371
- raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
8372
- }
8373
- if (typeof raw['defaultCritic'] === 'string') {
8374
- raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
8473
+ function cleanupStaleLocks(metaPaths, logger) {
8474
+ for (const metaPath of metaPaths) {
8475
+ const state = readLockState(metaPath);
8476
+ if (!state.exists)
8477
+ continue;
8478
+ const lockPath = join(metaPath, LOCK_FILE);
8479
+ if (state.staged) {
8480
+ logger?.warn({ metaPath }, 'Found staged synthesis result in lock file from previous crash — deleting (conservative: not auto-finalizing)');
8481
+ }
8482
+ else {
8483
+ logger?.warn({ metaPath }, 'Found stale lock file from previous crash — deleting');
8484
+ }
8485
+ try {
8486
+ unlinkSync(lockPath);
8487
+ }
8488
+ catch {
8489
+ // Already gone
8490
+ }
8375
8491
  }
8376
- return serviceConfigSchema.parse(raw);
8377
8492
  }
8378
8493
 
8379
8494
  /**
8380
- * Compute summary statistics from an array of MetaEntry objects.
8495
+ * Read and parse a meta.json file from a `.meta/` directory.
8381
8496
  *
8382
- * Shared between listMetas() (full list) and route handlers (filtered lists).
8383
- *
8384
- * @module discovery/computeSummary
8385
- */
8386
- /**
8387
- * Compute summary statistics from a list of meta entries.
8388
- *
8389
- * @param entries - Enriched meta entries (full or filtered).
8390
- * @param depthWeight - Config depth weight for effective staleness calculation.
8391
- * @returns Aggregated summary statistics.
8392
- */
8393
- function computeSummary(entries, depthWeight) {
8394
- let staleCount = 0;
8395
- let errorCount = 0;
8396
- let lockedCount = 0;
8397
- let disabledCount = 0;
8398
- let neverSynthesizedCount = 0;
8399
- let totalArchitectTokens = 0;
8400
- let totalBuilderTokens = 0;
8401
- let totalCriticTokens = 0;
8402
- let stalestPath = null;
8403
- let stalestEffective = -1;
8404
- let lastSynthesizedPath = null;
8405
- let lastSynthesizedAt = null;
8406
- for (const e of entries) {
8407
- if (e.stalenessSeconds > 0)
8408
- staleCount++;
8409
- if (e.hasError)
8410
- errorCount++;
8411
- if (e.locked)
8412
- lockedCount++;
8413
- if (e.disabled)
8414
- disabledCount++;
8415
- if (e.lastSynthesized === null)
8416
- neverSynthesizedCount++;
8417
- totalArchitectTokens += e.architectTokens ?? 0;
8418
- totalBuilderTokens += e.builderTokens ?? 0;
8419
- totalCriticTokens += e.criticTokens ?? 0;
8420
- // Track last synthesized
8421
- if (e.lastSynthesized &&
8422
- (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
8423
- lastSynthesizedAt = e.lastSynthesized;
8424
- lastSynthesizedPath = e.path;
8425
- }
8426
- // Track stalest (effective staleness for scheduling)
8427
- const depthFactor = Math.pow(1 + depthWeight, e.depth);
8428
- const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
8429
- if (effectiveStaleness > stalestEffective) {
8430
- stalestEffective = effectiveStaleness;
8431
- stalestPath = e.path;
8432
- }
8433
- }
8434
- return {
8435
- total: entries.length,
8436
- stale: staleCount,
8437
- errors: errorCount,
8438
- locked: lockedCount,
8439
- disabled: disabledCount,
8440
- neverSynthesized: neverSynthesizedCount,
8441
- tokens: {
8442
- architect: totalArchitectTokens,
8443
- builder: totalBuilderTokens,
8444
- critic: totalCriticTokens,
8445
- },
8446
- stalestPath,
8447
- lastSynthesizedPath,
8448
- lastSynthesizedAt,
8449
- };
8450
- }
8451
-
8452
- /**
8453
- * Discover .meta/ directories via watcher `/walk` endpoint.
8454
- *
8455
- * Uses filesystem enumeration through the watcher (not Qdrant) to find
8456
- * all `.meta/meta.json` files and returns deduplicated meta directory paths.
8457
- *
8458
- * @module discovery/discoverMetas
8459
- */
8460
- /**
8461
- * Discover all .meta/ directories via watcher walk.
8462
- *
8463
- * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
8464
- * and returns deduplicated meta directory paths.
8465
- *
8466
- * @param watcher - WatcherClient for walk queries.
8467
- * @returns Array of normalized .meta/ directory paths.
8468
- */
8469
- async function discoverMetas(watcher) {
8470
- const allPaths = await watcher.walk(['**/.meta/meta.json']);
8471
- // Deduplicate by .meta/ directory path (handles multi-chunk files)
8472
- const seen = new Set();
8473
- const metaPaths = [];
8474
- for (const filePath of allPaths) {
8475
- const fp = normalizePath(filePath);
8476
- // Derive .meta/ directory from file_path (strip /meta.json)
8477
- const metaPath = fp.replace(/\/meta\.json$/, '');
8478
- if (seen.has(metaPath))
8479
- continue;
8480
- seen.add(metaPath);
8481
- metaPaths.push(metaPath);
8482
- }
8483
- return metaPaths;
8484
- }
8485
-
8486
- /**
8487
- * File-system lock for preventing concurrent synthesis on the same meta.
8488
- *
8489
- * Lock file: .meta/.lock containing `_lockPid` + `_lockStartedAt` (underscore-prefixed
8490
- * reserved keys, consistent with meta.json conventions).
8491
- * Stale timeout: 30 minutes.
8492
- *
8493
- * @module lock
8494
- */
8495
- const LOCK_FILE = '.lock';
8496
- /**
8497
- * Resolve a path to a .meta directory.
8498
- *
8499
- * If the path already ends with '.meta', returns it as-is.
8500
- * Otherwise, appends '.meta' as a subdirectory.
8501
- *
8502
- * @param inputPath - Path that may or may not end with '.meta'.
8503
- * @returns The resolved .meta directory path.
8504
- */
8505
- function resolveMetaDir(inputPath) {
8506
- return inputPath.endsWith('.meta') ? inputPath : join(inputPath, '.meta');
8507
- }
8508
- const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
8509
- /**
8510
- * Read and classify the state of a .meta/.lock file.
8511
- *
8512
- * @param metaPath - Absolute path to the .meta directory.
8513
- * @returns Parsed lock state.
8514
- */
8515
- function readLockState(metaPath) {
8516
- const lockPath = join(metaPath, LOCK_FILE);
8517
- if (!existsSync(lockPath)) {
8518
- return { exists: false, staged: false, active: false, data: null };
8519
- }
8520
- try {
8521
- const raw = readFileSync(lockPath, 'utf8');
8522
- const data = JSON.parse(raw);
8523
- if ('_id' in data) {
8524
- return { exists: true, staged: true, active: false, data };
8525
- }
8526
- const startedAt = data._lockStartedAt;
8527
- if (startedAt) {
8528
- const lockAge = Date.now() - new Date(startedAt).getTime();
8529
- return {
8530
- exists: true,
8531
- staged: false,
8532
- active: lockAge < STALE_TIMEOUT_MS,
8533
- data,
8534
- };
8535
- }
8536
- return { exists: true, staged: false, active: false, data };
8537
- }
8538
- catch {
8539
- return { exists: true, staged: false, active: false, data: null };
8540
- }
8541
- }
8542
- /**
8543
- * Attempt to acquire a lock on a .meta directory.
8544
- *
8545
- * @param metaPath - Absolute path to the .meta directory.
8546
- * @returns True if lock was acquired, false if already locked (non-stale).
8547
- */
8548
- function acquireLock(metaPath) {
8549
- const state = readLockState(metaPath);
8550
- // Active non-stale lock — cannot acquire
8551
- if (state.active)
8552
- return false;
8553
- // Staged, stale, corrupt, or missing — safe to (over)write
8554
- const lockPath = join(metaPath, LOCK_FILE);
8555
- const lock = {
8556
- _lockPid: process.pid,
8557
- _lockStartedAt: new Date().toISOString(),
8558
- };
8559
- writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
8560
- return true;
8561
- }
8562
- /**
8563
- * Release a lock on a .meta directory.
8564
- *
8565
- * @param metaPath - Absolute path to the .meta directory.
8566
- */
8567
- function releaseLock(metaPath) {
8568
- const lockPath = join(metaPath, LOCK_FILE);
8569
- try {
8570
- unlinkSync(lockPath);
8571
- }
8572
- catch {
8573
- // Already removed or never existed
8574
- }
8575
- }
8576
- /**
8577
- * Check if a .meta directory is currently locked (non-stale).
8578
- *
8579
- * @param metaPath - Absolute path to the .meta directory.
8580
- * @returns True if locked and not stale.
8581
- */
8582
- function isLocked(metaPath) {
8583
- return readLockState(metaPath).active;
8584
- }
8585
- /**
8586
- * Clean up stale lock files on startup.
8587
- *
8588
- * For each .meta directory found via the provided paths:
8589
- * - If lock contains PID-only data (synthesis incomplete), delete it.
8590
- * - If lock contains staged result (_id present), log warning and delete.
8591
- *
8592
- * @param metaPaths - Array of .meta directory paths to check.
8593
- * @param logger - Optional logger for warnings.
8594
- */
8595
- function cleanupStaleLocks(metaPaths, logger) {
8596
- for (const metaPath of metaPaths) {
8597
- const state = readLockState(metaPath);
8598
- if (!state.exists)
8599
- continue;
8600
- const lockPath = join(metaPath, LOCK_FILE);
8601
- if (state.staged) {
8602
- logger?.warn({ metaPath }, 'Found staged synthesis result in lock file from previous crash — deleting (conservative: not auto-finalizing)');
8603
- }
8604
- else {
8605
- logger?.warn({ metaPath }, 'Found stale lock file from previous crash — deleting');
8606
- }
8607
- try {
8608
- unlinkSync(lockPath);
8609
- }
8610
- catch {
8611
- // Already gone
8612
- }
8613
- }
8614
- }
8615
-
8616
- /**
8617
- * Read and parse a meta.json file from a `.meta/` directory.
8618
- *
8619
- * Shared utility to eliminate repeated `JSON.parse(readFileSync(...))` across
8620
- * discovery, orchestration, and route handlers.
8497
+ * Shared utility to eliminate repeated `JSON.parse(readFileSync(...))` across
8498
+ * discovery, orchestration, and route handlers.
8621
8499
  *
8622
8500
  * @module readMetaJson
8623
8501
  */
@@ -9030,22 +8908,287 @@ function getDeltaFiles(generatedAt, scopeFiles) {
9030
8908
  }
9031
8909
 
9032
8910
  /**
9033
- * Error thrown when a spawned subprocess is aborted via AbortController.
8911
+ * In-memory cache for listMetas results with TTL and concurrent refresh guard.
9034
8912
  *
9035
- * @module executor/SpawnAbortedError
8913
+ * @module cache
9036
8914
  */
9037
- /** Error indicating a spawn was deliberately aborted. */
9038
- class SpawnAbortedError extends Error {
9039
- constructor(message = 'Synthesis was aborted') {
9040
- super(message);
9041
- this.name = 'SpawnAbortedError';
8915
+ const TTL_MS = 60_000;
8916
+ /**
8917
+ * Caches listMetas results to avoid expensive repeated filesystem walks.
8918
+ * Supports concurrent refresh coalescing and manual invalidation.
8919
+ */
8920
+ class MetaCache {
8921
+ result = null;
8922
+ updatedAt = 0;
8923
+ refreshPromise = null;
8924
+ /** Get cached result or refresh if stale. */
8925
+ async get(config, watcher) {
8926
+ if (this.result && Date.now() - this.updatedAt < TTL_MS) {
8927
+ return this.result;
8928
+ }
8929
+ return this.refresh(config, watcher);
8930
+ }
8931
+ /** Force-expire the cache so next get() triggers a refresh. */
8932
+ invalidate() {
8933
+ this.updatedAt = 0;
8934
+ }
8935
+ async refresh(config, watcher) {
8936
+ if (this.refreshPromise)
8937
+ return this.refreshPromise;
8938
+ this.refreshPromise = listMetas(config, watcher)
8939
+ .then((result) => {
8940
+ this.result = result;
8941
+ this.updatedAt = Date.now();
8942
+ return result;
8943
+ })
8944
+ .finally(() => {
8945
+ this.refreshPromise = null;
8946
+ });
8947
+ return this.refreshPromise;
9042
8948
  }
9043
8949
  }
9044
8950
 
9045
8951
  /**
9046
- * Error thrown when a spawned subprocess times out.
8952
+ * Shared live config hot-reload support.
9047
8953
  *
9048
- * Carries the output file path so callers can attempt partial output recovery.
8954
+ * Used by both file-watch reloads in bootstrap and POST /config/apply
8955
+ * via the component descriptor's onConfigApply callback.
8956
+ *
8957
+ * @module configHotReload
8958
+ */
8959
+ /**
8960
+ * Fields that require a service restart to take effect.
8961
+ *
8962
+ * Shared between the descriptor's `onConfigApply` and the file-watcher
8963
+ * hot-reload in `bootstrap.ts`.
8964
+ */
8965
+ const RESTART_REQUIRED_FIELDS = [
8966
+ 'port',
8967
+ 'watcherUrl',
8968
+ 'gatewayUrl',
8969
+ 'gatewayApiKey',
8970
+ 'defaultArchitect',
8971
+ 'defaultCritic',
8972
+ ];
8973
+ let runtime = null;
8974
+ /** Register the active service runtime for config-apply hot reload. */
8975
+ function registerConfigHotReloadRuntime(nextRuntime) {
8976
+ runtime = nextRuntime;
8977
+ }
8978
+ /** Apply hot-reloadable config changes to the live shared config object. */
8979
+ function applyHotReloadedConfig(newConfig) {
8980
+ if (!runtime)
8981
+ return;
8982
+ const { config, logger, scheduler } = runtime;
8983
+ for (const field of RESTART_REQUIRED_FIELDS) {
8984
+ const oldVal = config[field];
8985
+ const nextVal = newConfig[field];
8986
+ if (oldVal !== nextVal) {
8987
+ logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
8988
+ }
8989
+ }
8990
+ if (newConfig.schedule !== config.schedule) {
8991
+ scheduler?.updateSchedule(newConfig.schedule);
8992
+ config.schedule = newConfig.schedule;
8993
+ logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
8994
+ }
8995
+ if (newConfig.logging.level !== config.logging.level) {
8996
+ logger.level = newConfig.logging.level;
8997
+ config.logging.level = newConfig.logging.level;
8998
+ logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
8999
+ }
9000
+ const restartSet = new Set(RESTART_REQUIRED_FIELDS);
9001
+ for (const key of Object.keys(newConfig)) {
9002
+ if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
9003
+ continue;
9004
+ }
9005
+ const oldVal = config[key];
9006
+ const nextVal = newConfig[key];
9007
+ if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
9008
+ config[key] = nextVal;
9009
+ logger.info({ field: key }, 'Config field hot-reloaded');
9010
+ }
9011
+ }
9012
+ }
9013
+
9014
+ /**
9015
+ * Zod schema for jeeves-meta service configuration.
9016
+ *
9017
+ * The service config is a strict superset of the core (library-compatible) meta config.
9018
+ *
9019
+ * @module schema/config
9020
+ */
9021
+ /** Zod schema for logging configuration. */
9022
+ const loggingSchema = z.object({
9023
+ /** Log level. */
9024
+ level: z.string().default('info'),
9025
+ /** Optional file path for log output. */
9026
+ file: z.string().optional(),
9027
+ });
9028
+ /** Zod schema for a single auto-seed policy rule. */
9029
+ const autoSeedRuleSchema = z.object({
9030
+ /** Glob pattern matched against watcher walk results. */
9031
+ match: z.string(),
9032
+ /** Optional steering prompt for seeded metas. */
9033
+ steer: z.string().optional(),
9034
+ /** Optional cross-references for seeded metas. */
9035
+ crossRefs: z.array(z.string()).optional(),
9036
+ });
9037
+ /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
9038
+ const serviceConfigSchema = metaConfigSchema.extend({
9039
+ /** HTTP port for the service (default: 1938). */
9040
+ port: z.number().int().min(1).max(65535).default(1938),
9041
+ /** Cron schedule for synthesis cycles (default: every 30 min). */
9042
+ schedule: z.string().default('*/30 * * * *'),
9043
+ /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
9044
+ reportChannel: z.string().optional(),
9045
+ /** Channel/user ID to send progress messages to. */
9046
+ reportTarget: z.string().optional(),
9047
+ /** Optional base URL for the service, used to construct entity links in progress reports. */
9048
+ serverBaseUrl: z.string().optional(),
9049
+ /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
9050
+ watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
9051
+ /** Logging configuration. */
9052
+ logging: loggingSchema.default(() => loggingSchema.parse({})),
9053
+ /**
9054
+ * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
9055
+ * Rules are evaluated in order; last match wins for steer/crossRefs.
9056
+ */
9057
+ autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
9058
+ });
9059
+
9060
+ /**
9061
+ * Load and resolve jeeves-meta service config.
9062
+ *
9063
+ * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
9064
+ *
9065
+ * @module configLoader
9066
+ */
9067
+ /**
9068
+ * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
9069
+ *
9070
+ * @param value - Arbitrary JSON-compatible value.
9071
+ * @returns Value with env-var placeholders resolved.
9072
+ */
9073
+ function substituteEnvVars(value) {
9074
+ if (typeof value === 'string') {
9075
+ return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
9076
+ const envVal = process.env[name];
9077
+ if (envVal === undefined) {
9078
+ throw new Error(`Environment variable ${name} is not set`);
9079
+ }
9080
+ return envVal;
9081
+ });
9082
+ }
9083
+ if (Array.isArray(value)) {
9084
+ return value.map(substituteEnvVars);
9085
+ }
9086
+ if (value !== null && typeof value === 'object') {
9087
+ const result = {};
9088
+ for (const [key, val] of Object.entries(value)) {
9089
+ result[key] = substituteEnvVars(val);
9090
+ }
9091
+ return result;
9092
+ }
9093
+ return value;
9094
+ }
9095
+ /**
9096
+ * Resolve \@file: references in a config value.
9097
+ *
9098
+ * @param value - String value that may start with "\@file:".
9099
+ * @param baseDir - Base directory for resolving relative paths.
9100
+ * @returns The resolved string (file contents or original value).
9101
+ */
9102
+ function resolveFileRef(value, baseDir) {
9103
+ if (!value.startsWith('@file:'))
9104
+ return value;
9105
+ const filePath = join(baseDir, value.slice(6));
9106
+ return readFileSync(filePath, 'utf8');
9107
+ }
9108
+ /**
9109
+ * Migrate legacy config path to the new canonical location.
9110
+ *
9111
+ * If the old path `{configRoot}/jeeves-meta.config.json` exists and the new
9112
+ * path `{configRoot}/jeeves-meta/config.json` does NOT exist, copies the file
9113
+ * to the new location and logs a warning.
9114
+ *
9115
+ * @param configRoot - Root directory for configuration files.
9116
+ * @param warn - Optional callback for logging the migration warning.
9117
+ */
9118
+ function migrateConfigPath(configRoot, warn) {
9119
+ const oldPath = join(configRoot, 'jeeves-meta.config.json');
9120
+ const newDir = join(configRoot, 'jeeves-meta');
9121
+ const newPath = join(newDir, 'config.json');
9122
+ if (existsSync(oldPath) && !existsSync(newPath)) {
9123
+ mkdirSync(newDir, { recursive: true });
9124
+ copyFileSync(oldPath, newPath);
9125
+ const message = `Migrated config from ${oldPath} to ${newPath}. The old file can be removed.`;
9126
+ if (warn) {
9127
+ warn(message);
9128
+ }
9129
+ else {
9130
+ console.warn(`[jeeves-meta] ${message}`);
9131
+ }
9132
+ }
9133
+ }
9134
+ /**
9135
+ * Resolve config path from --config flag or JEEVES_META_CONFIG env var.
9136
+ *
9137
+ * @param args - CLI arguments (process.argv.slice(2)).
9138
+ * @returns Resolved config path.
9139
+ * @throws If no config path found.
9140
+ */
9141
+ function resolveConfigPath(args) {
9142
+ let configIdx = args.indexOf('--config');
9143
+ if (configIdx === -1)
9144
+ configIdx = args.indexOf('-c');
9145
+ if (configIdx !== -1 && args[configIdx + 1]) {
9146
+ return args[configIdx + 1];
9147
+ }
9148
+ const envPath = process.env['JEEVES_META_CONFIG'];
9149
+ if (envPath)
9150
+ return envPath;
9151
+ throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
9152
+ }
9153
+ /**
9154
+ * Load service config from a JSON file.
9155
+ *
9156
+ * Resolves \@file: references for defaultArchitect and defaultCritic,
9157
+ * and substitutes environment-variable placeholders throughout.
9158
+ *
9159
+ * @param configPath - Path to config JSON file.
9160
+ * @returns Validated ServiceConfig.
9161
+ */
9162
+ function loadServiceConfig(configPath) {
9163
+ const rawText = readFileSync(configPath, 'utf8');
9164
+ const raw = substituteEnvVars(JSON.parse(rawText));
9165
+ const baseDir = dirname(configPath);
9166
+ if (typeof raw['defaultArchitect'] === 'string') {
9167
+ raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
9168
+ }
9169
+ if (typeof raw['defaultCritic'] === 'string') {
9170
+ raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
9171
+ }
9172
+ return serviceConfigSchema.parse(raw);
9173
+ }
9174
+
9175
+ /**
9176
+ * Error thrown when a spawned subprocess is aborted via AbortController.
9177
+ *
9178
+ * @module executor/SpawnAbortedError
9179
+ */
9180
+ /** Error indicating a spawn was deliberately aborted. */
9181
+ class SpawnAbortedError extends Error {
9182
+ constructor(message = 'Synthesis was aborted') {
9183
+ super(message);
9184
+ this.name = 'SpawnAbortedError';
9185
+ }
9186
+ }
9187
+
9188
+ /**
9189
+ * Error thrown when a spawned subprocess times out.
9190
+ *
9191
+ * Carries the output file path so callers can attempt partial output recovery.
9049
9192
  *
9050
9193
  * @module executor/SpawnTimeoutError
9051
9194
  */
@@ -9127,21 +9270,29 @@ class GatewayExecutor {
9127
9270
  }
9128
9271
  return data;
9129
9272
  }
9130
- /** Look up totalTokens for a session via sessions_list. */
9131
- async getSessionTokens(sessionKey) {
9273
+ /** Look up session metadata (tokens, completion status) via sessions_list. */
9274
+ async getSessionInfo(sessionKey) {
9132
9275
  try {
9133
9276
  const result = await this.invoke('sessions_list', {
9134
- limit: 20,
9277
+ limit: 200,
9135
9278
  messageLimit: 0,
9136
9279
  });
9137
9280
  const sessions = (result.result?.details?.sessions ??
9138
9281
  result.result?.sessions ??
9139
9282
  []);
9140
9283
  const match = sessions.find((s) => s.key === sessionKey);
9141
- return match?.totalTokens ?? undefined;
9284
+ if (!match) {
9285
+ // Session absent from list — likely cleaned up after completion.
9286
+ // With limit=200 this is reliable; a false positive here only
9287
+ // means we read the output file slightly early (still correct
9288
+ // if the file exists).
9289
+ return { completed: true };
9290
+ }
9291
+ const done = match.status === 'completed' || match.status === 'done';
9292
+ return { tokens: match.totalTokens, completed: done };
9142
9293
  }
9143
9294
  catch {
9144
- return undefined;
9295
+ return { completed: false };
9145
9296
  }
9146
9297
  }
9147
9298
  /** Whether this executor has been aborted by the operator. */
@@ -9183,8 +9334,10 @@ class GatewayExecutor {
9183
9334
  ...(options?.thinking ? { thinking: options.thinking } : {}),
9184
9335
  ...(options?.model ? { model: options.model } : {}),
9185
9336
  });
9186
- const details = (spawnResult.result?.details ?? spawnResult.result);
9187
- const sessionKey = details?.childSessionKey ?? details?.sessionKey;
9337
+ const details = (spawnResult.result?.details ??
9338
+ spawnResult.result ??
9339
+ {});
9340
+ const sessionKey = details.childSessionKey ?? details.sessionKey;
9188
9341
  if (typeof sessionKey !== 'string' || !sessionKey) {
9189
9342
  throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
9190
9343
  JSON.stringify(spawnResult));
@@ -9207,48 +9360,53 @@ class GatewayExecutor {
9207
9360
  historyResult.result?.messages ??
9208
9361
  [];
9209
9362
  const msgArray = messages;
9363
+ // Check 1: terminal stop reason in history
9364
+ let historyDone = false;
9210
9365
  if (msgArray.length > 0) {
9211
9366
  const lastMsg = msgArray[msgArray.length - 1];
9212
- // Complete when last message is assistant with a terminal stop reason
9213
9367
  if (lastMsg.role === 'assistant' &&
9214
9368
  lastMsg.stopReason &&
9215
9369
  lastMsg.stopReason !== 'toolUse' &&
9216
9370
  lastMsg.stopReason !== 'error') {
9217
- // Fetch token usage from session metadata
9218
- const tokens = await this.getSessionTokens(sessionKey);
9219
- // Read output from file (sub-agent wrote it via Write tool)
9220
- if (existsSync(outputPath)) {
9371
+ historyDone = true;
9372
+ }
9373
+ }
9374
+ // Check 2: session completion status via sessions_list
9375
+ const sessionInfo = await this.getSessionInfo(sessionKey);
9376
+ if (historyDone || sessionInfo.completed) {
9377
+ const tokens = sessionInfo.tokens;
9378
+ // Read output from file (sub-agent wrote it via Write tool)
9379
+ if (existsSync(outputPath)) {
9380
+ try {
9381
+ const output = readFileSync(outputPath, 'utf8');
9382
+ return { output, tokens };
9383
+ }
9384
+ finally {
9221
9385
  try {
9222
- const output = readFileSync(outputPath, 'utf8');
9223
- return { output, tokens };
9386
+ unlinkSync(outputPath);
9224
9387
  }
9225
- finally {
9226
- try {
9227
- unlinkSync(outputPath);
9228
- }
9229
- catch {
9230
- /* cleanup best-effort */
9231
- }
9388
+ catch {
9389
+ /* cleanup best-effort */
9232
9390
  }
9233
9391
  }
9234
- // Fallback: extract from message content if file wasn't written
9235
- for (let i = msgArray.length - 1; i >= 0; i--) {
9236
- const msg = msgArray[i];
9237
- if (msg.role === 'assistant' && msg.content) {
9238
- const text = typeof msg.content === 'string'
9392
+ }
9393
+ // Fallback: extract from message content if file wasn't written
9394
+ for (let i = msgArray.length - 1; i >= 0; i--) {
9395
+ const msg = msgArray[i];
9396
+ if (msg.role === 'assistant' && msg.content) {
9397
+ const text = typeof msg.content === 'string'
9398
+ ? msg.content
9399
+ : Array.isArray(msg.content)
9239
9400
  ? msg.content
9240
- : Array.isArray(msg.content)
9241
- ? msg.content
9242
- .filter((b) => b.type === 'text' && b.text)
9243
- .map((b) => b.text)
9244
- .join('\n')
9245
- : '';
9246
- if (text)
9247
- return { output: text, tokens };
9248
- }
9401
+ .filter((b) => b.type === 'text' && b.text)
9402
+ .map((b) => b.text)
9403
+ .join('\n')
9404
+ : '';
9405
+ if (text)
9406
+ return { output: text, tokens };
9249
9407
  }
9250
- return { output: '', tokens };
9251
9408
  }
9409
+ return { output: '', tokens };
9252
9410
  }
9253
9411
  }
9254
9412
  catch {
@@ -9423,6 +9581,7 @@ async function buildContextPackage(node, meta, watcher, logger) {
9423
9581
  *
9424
9582
  * @module orchestrator/buildTask
9425
9583
  */
9584
+ Handlebars.registerHelper('gt', (a, b) => a > b);
9426
9585
  /** Build the template context from synthesis inputs. */
9427
9586
  function buildTemplateContext(ctx, meta, config) {
9428
9587
  return {
@@ -9578,264 +9737,16 @@ function buildCriticTask(ctx, meta, config) {
9578
9737
  }
9579
9738
 
9580
9739
  /**
9581
- * Exponential moving average helper for token tracking.
9740
+ * Build a minimal MetaNode from a known meta path using watcher walk.
9582
9741
  *
9583
- * @module ema
9742
+ * Used for targeted synthesis (when a specific path is requested) to avoid
9743
+ * the full discovery + ownership tree build. Discovers only immediate child
9744
+ * `.meta/` directories.
9745
+ *
9746
+ * @module discovery/buildMinimalNode
9584
9747
  */
9585
- const DEFAULT_DECAY = 0.3;
9586
9748
  /**
9587
- * Compute exponential moving average.
9588
- *
9589
- * @param current - New observation.
9590
- * @param previous - Previous EMA value, or undefined for first observation.
9591
- * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
9592
- * @returns Updated EMA.
9593
- */
9594
- function computeEma(current, previous, decay = DEFAULT_DECAY) {
9595
- if (previous === undefined)
9596
- return current;
9597
- return decay * current + (1 - decay) * previous;
9598
- }
9599
-
9600
- /**
9601
- * Structured error from a synthesis step failure.
9602
- *
9603
- * @module schema/error
9604
- */
9605
- /** Zod schema for synthesis step errors. */
9606
- const metaErrorSchema = z.object({
9607
- /** Which step failed: 'architect', 'builder', or 'critic'. */
9608
- step: z.enum(['architect', 'builder', 'critic']),
9609
- /** Error classification code. */
9610
- code: z.string(),
9611
- /** Human-readable error message. */
9612
- message: z.string(),
9613
- });
9614
-
9615
- /**
9616
- * Zod schema for .meta/meta.json files.
9617
- *
9618
- * Reserved properties are underscore-prefixed and engine-managed.
9619
- * All other keys are open schema (builder output).
9620
- *
9621
- * @module schema/meta
9622
- */
9623
- /** Valid states for a synthesis phase. */
9624
- const phaseStatuses = [
9625
- 'fresh',
9626
- 'stale',
9627
- 'pending',
9628
- 'running',
9629
- 'failed',
9630
- ];
9631
- /** Zod schema for a per-phase status value. */
9632
- const phaseStatusSchema = z.enum(phaseStatuses);
9633
- /** Zod schema for the per-meta phase state record. */
9634
- const phaseStateSchema = z.object({
9635
- architect: phaseStatusSchema,
9636
- builder: phaseStatusSchema,
9637
- critic: phaseStatusSchema,
9638
- });
9639
- /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
9640
- const metaJsonSchema = z
9641
- .object({
9642
- /** Stable identity. Auto-generated on first synthesis if not provided. */
9643
- _id: z.uuid().optional(),
9644
- /** Human-provided steering prompt. Optional. */
9645
- _steer: z.string().optional(),
9646
- /**
9647
- * Explicit cross-references to other meta owner paths.
9648
- * Referenced metas' _content is included as architect/builder context.
9649
- */
9650
- _crossRefs: z.array(z.string()).optional(),
9651
- /** Architect system prompt used this turn. Defaults from config. */
9652
- _architect: z.string().optional(),
9653
- /**
9654
- * Task brief generated by the architect. Cached and reused across cycles;
9655
- * regenerated only when triggered.
9656
- */
9657
- _builder: z.string().optional(),
9658
- /** Critic system prompt used this turn. Defaults from config. */
9659
- _critic: z.string().optional(),
9660
- /** Timestamp of last synthesis. ISO 8601. */
9661
- _generatedAt: z.iso.datetime().optional(),
9662
- /** Narrative synthesis output. Rendered by watcher for embedding. */
9663
- _content: z.string().optional(),
9664
- /**
9665
- * Hash of sorted file listing in scope. Detects directory structure
9666
- * changes that trigger an architect re-run.
9667
- */
9668
- _structureHash: z.string().optional(),
9669
- /**
9670
- * Cycles since last architect run. Reset to 0 when architect runs.
9671
- * Used with architectEvery to trigger periodic re-prompting.
9672
- */
9673
- _synthesisCount: z.number().int().min(0).optional(),
9674
- /** Critic evaluation of the last synthesis. */
9675
- _feedback: z.string().optional(),
9676
- /**
9677
- * Present and true on archive snapshots. Distinguishes live vs. archived
9678
- * metas.
9679
- */
9680
- _archived: z.boolean().optional(),
9681
- /** Timestamp when this snapshot was archived. ISO 8601. */
9682
- _archivedAt: z.iso.datetime().optional(),
9683
- /**
9684
- * Scheduling priority. Higher = updates more often. Negative allowed;
9685
- * normalized to min 0 at scheduling time.
9686
- */
9687
- _depth: z.number().optional(),
9688
- /**
9689
- * Emphasis multiplier for depth weighting in scheduling.
9690
- * Default 1. Higher values increase this meta's scheduling priority
9691
- * relative to its depth. Set to 0.5 to halve the depth effect,
9692
- * 2 to double it, 0 to ignore depth entirely for this meta.
9693
- */
9694
- _emphasis: z.number().min(0).optional(),
9695
- /** Token count from last architect subprocess call. */
9696
- _architectTokens: z.number().int().optional(),
9697
- /** Token count from last builder subprocess call. */
9698
- _builderTokens: z.number().int().optional(),
9699
- /** Token count from last critic subprocess call. */
9700
- _criticTokens: z.number().int().optional(),
9701
- /** Exponential moving average of architect token usage (decay 0.3). */
9702
- _architectTokensAvg: z.number().optional(),
9703
- /** Exponential moving average of builder token usage (decay 0.3). */
9704
- _builderTokensAvg: z.number().optional(),
9705
- /** Exponential moving average of critic token usage (decay 0.3). */
9706
- _criticTokensAvg: z.number().optional(),
9707
- /**
9708
- * Opaque state carried across synthesis cycles for progressive work.
9709
- * Set by the builder, passed back as context on next cycle.
9710
- */
9711
- _state: z.unknown().optional(),
9712
- /**
9713
- * Structured error from last cycle. Present when a step failed.
9714
- * Cleared on successful cycle.
9715
- */
9716
- _error: metaErrorSchema.optional(),
9717
- /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
9718
- _disabled: z.boolean().optional(),
9719
- /**
9720
- * Per-phase state machine record. Engine-managed.
9721
- * Keyed by phase name (architect, builder, critic) with status values.
9722
- * Persisted to survive ticks; derived on first load for back-compat.
9723
- */
9724
- _phaseState: phaseStateSchema.optional(),
9725
- })
9726
- .loose();
9727
-
9728
- /**
9729
- * Merge synthesis results into meta.json.
9730
- *
9731
- * Preserves human-set fields (_id, _steer, _depth).
9732
- * Writes engine fields (_generatedAt, _structureHash, etc.).
9733
- * Validates against schema before writing.
9734
- *
9735
- * @module orchestrator/merge
9736
- */
9737
- /**
9738
- * Merge results into meta.json and write atomically.
9739
- *
9740
- * @param options - Merge options.
9741
- * @returns The updated MetaJson.
9742
- * @throws If validation fails (malformed output).
9743
- */
9744
- async function mergeAndWrite(options) {
9745
- const merged = {
9746
- // Preserve human-set fields (auto-generate _id on first synthesis)
9747
- _id: options.current._id ?? randomUUID(),
9748
- _steer: options.current._steer,
9749
- _depth: options.current._depth,
9750
- _emphasis: options.current._emphasis,
9751
- // Engine fields
9752
- _architect: options.architect,
9753
- _builder: options.builder,
9754
- _critic: options.critic,
9755
- _generatedAt: options.stateOnly
9756
- ? options.current._generatedAt
9757
- : new Date().toISOString(),
9758
- _structureHash: options.structureHash,
9759
- _synthesisCount: options.synthesisCount,
9760
- // Token tracking
9761
- _architectTokens: options.architectTokens,
9762
- _builderTokens: options.builderTokens,
9763
- _criticTokens: options.criticTokens,
9764
- _architectTokensAvg: options.architectTokens !== undefined
9765
- ? computeEma(options.architectTokens, options.current._architectTokensAvg)
9766
- : options.current._architectTokensAvg,
9767
- _builderTokensAvg: options.builderTokens !== undefined
9768
- ? computeEma(options.builderTokens, options.current._builderTokensAvg)
9769
- : options.current._builderTokensAvg,
9770
- _criticTokensAvg: options.criticTokens !== undefined
9771
- ? computeEma(options.criticTokens, options.current._criticTokensAvg)
9772
- : options.current._criticTokensAvg,
9773
- // Content from builder (stateOnly preserves previous content)
9774
- _content: options.stateOnly
9775
- ? options.current._content
9776
- : (options.builderOutput?.content ?? options.current._content),
9777
- // Feedback from critic
9778
- _feedback: options.feedback ?? options.current._feedback,
9779
- // Progressive state
9780
- _state: options.state,
9781
- // Error handling
9782
- _error: options.error ?? undefined,
9783
- // Phase state machine
9784
- _phaseState: options.phaseState,
9785
- // Spread structured fields from builder
9786
- ...options.builderOutput?.fields,
9787
- };
9788
- // Clean up undefined optional fields
9789
- if (merged._steer === undefined)
9790
- delete merged._steer;
9791
- if (merged._depth === undefined)
9792
- delete merged._depth;
9793
- if (merged._emphasis === undefined)
9794
- delete merged._emphasis;
9795
- if (merged._architectTokens === undefined)
9796
- delete merged._architectTokens;
9797
- if (merged._builderTokens === undefined)
9798
- delete merged._builderTokens;
9799
- if (merged._criticTokens === undefined)
9800
- delete merged._criticTokens;
9801
- if (merged._architectTokensAvg === undefined)
9802
- delete merged._architectTokensAvg;
9803
- if (merged._builderTokensAvg === undefined)
9804
- delete merged._builderTokensAvg;
9805
- if (merged._criticTokensAvg === undefined)
9806
- delete merged._criticTokensAvg;
9807
- if (merged._state === undefined)
9808
- delete merged._state;
9809
- if (merged._error === undefined)
9810
- delete merged._error;
9811
- if (merged._content === undefined)
9812
- delete merged._content;
9813
- if (merged._feedback === undefined)
9814
- delete merged._feedback;
9815
- if (merged._phaseState === undefined)
9816
- delete merged._phaseState;
9817
- // Validate
9818
- const result = metaJsonSchema.safeParse(merged);
9819
- if (!result.success) {
9820
- throw new Error(`Meta validation failed: ${result.error.message}`);
9821
- }
9822
- // Write to specified path (lock staging) or default meta.json
9823
- const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
9824
- await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
9825
- return result.data;
9826
- }
9827
-
9828
- /**
9829
- * Build a minimal MetaNode from a known meta path using watcher walk.
9830
- *
9831
- * Used for targeted synthesis (when a specific path is requested) to avoid
9832
- * the full discovery + ownership tree build. Discovers only immediate child
9833
- * `.meta/` directories.
9834
- *
9835
- * @module discovery/buildMinimalNode
9836
- */
9837
- /**
9838
- * Build a minimal MetaNode for a known meta path.
9749
+ * Build a minimal MetaNode for a known meta path.
9839
9750
  *
9840
9751
  * Walks the owner directory for child `.meta/meta.json` files and constructs
9841
9752
  * a shallow ownership tree (self + direct children only).
@@ -9887,103 +9798,301 @@ async function buildMinimalNode(metaPath, watcher) {
9887
9798
  }
9888
9799
 
9889
9800
  /**
9890
- * Weighted staleness formula for candidate selection.
9801
+ * Pure phase-state transition functions.
9891
9802
  *
9892
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
9803
+ * Implements every row of the §8 "Transitions and invalidation cascade" table.
9804
+ * No I/O — pure functions over PhaseState and documented inputs.
9893
9805
  *
9894
- * @module scheduling/weightedFormula
9806
+ * @module phaseState/phaseTransitions
9895
9807
  */
9896
9808
  /**
9897
- * Compute effective staleness for a set of candidates.
9898
- *
9899
- * Normalizes depths so the minimum becomes 0, then applies the formula:
9900
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
9901
- *
9902
- * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
9903
- * metas to tune how much their tree position affects scheduling.
9904
- *
9905
- * @param candidates - Array of \{ node, meta, actualStaleness \}.
9906
- * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
9907
- * @returns Same array with effectiveStaleness computed.
9809
+ * Create a fresh (fully-complete) phase state.
9908
9810
  */
9909
- function computeEffectiveStaleness(candidates, depthWeight) {
9910
- if (candidates.length === 0)
9911
- return [];
9912
- // Get depth for each candidate: use _depth override or tree depth
9913
- const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
9914
- // Normalize: shift so minimum becomes 0
9915
- const minDepth = Math.min(...depths);
9916
- const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
9917
- return candidates.map((c, i) => {
9918
- const emphasis = c.meta._emphasis ?? 1;
9919
- return {
9920
- ...c,
9921
- effectiveStaleness: c.actualStaleness *
9922
- Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
9923
- };
9924
- });
9811
+ function freshPhaseState() {
9812
+ return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
9925
9813
  }
9926
-
9927
9814
  /**
9928
- * Select the best synthesis candidate from stale metas.
9929
- *
9930
- * Picks the meta with highest effective staleness.
9931
- *
9932
- * @module scheduling/selectCandidate
9815
+ * Create a phase state for a never-synthesized meta (all pending from architect).
9933
9816
  */
9817
+ function initialPhaseState() {
9818
+ return { architect: 'pending', builder: 'stale', critic: 'stale' };
9819
+ }
9934
9820
  /**
9935
- * Select the candidate with the highest effective staleness.
9821
+ * Enforce the per-meta invariant: at most one phase is pending or running,
9822
+ * and it is the first non-fresh phase in pipeline order.
9936
9823
  *
9937
- * @param candidates - Array of candidates with computed effective staleness.
9938
- * @returns The winning candidate, or null if no candidates.
9824
+ * Stale phases that become the first non-fresh phase are promoted to pending.
9939
9825
  */
9940
- function selectCandidate(candidates) {
9941
- if (candidates.length === 0)
9942
- return null;
9943
- let best = candidates[0];
9944
- for (let i = 1; i < candidates.length; i++) {
9945
- if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
9946
- best = candidates[i];
9826
+ function enforceInvariant(state) {
9827
+ const result = { ...state };
9828
+ let foundNonFresh = false;
9829
+ for (const phase of ['architect', 'builder', 'critic']) {
9830
+ const s = result[phase];
9831
+ if (s === 'fresh')
9832
+ continue;
9833
+ if (!foundNonFresh) {
9834
+ foundNonFresh = true;
9835
+ // First non-fresh: if stale, promote to pending
9836
+ if (s === 'stale') {
9837
+ result[phase] = 'pending';
9838
+ }
9839
+ // pending, running, failed stay as-is
9840
+ }
9841
+ else {
9842
+ // Subsequent non-fresh: must not be pending or running
9843
+ if (s === 'pending') {
9844
+ result[phase] = 'stale';
9845
+ }
9846
+ // running in non-first position would be a bug, but don't mask it
9947
9847
  }
9948
9848
  }
9949
- return best;
9849
+ return result;
9950
9850
  }
9851
+ // ── Invalidation cascades ──────────────────────────────────────────────
9951
9852
  /**
9952
- * Extract stale candidates from a list and return the stalest path.
9953
- *
9954
- * Consolidates the repeated pattern of:
9955
- * filter → computeEffectiveStaleness → selectCandidate → return path
9956
- *
9957
- * @param candidates - Array with node, meta, and stalenessSeconds.
9958
- * @param depthWeight - Depth weighting exponent from config.
9959
- * @returns The stalest candidate's metaPath, or null if none are stale.
9853
+ * Architect invalidated: architect pending; builder, critic stale.
9854
+ * Triggers: _structureHash change, _steer change, _architect change,
9855
+ * _crossRefs declaration change, _synthesisCount \>= architectEvery.
9960
9856
  */
9961
- function discoverStalestPath(candidates, depthWeight) {
9962
- const weighted = computeEffectiveStaleness(candidates, depthWeight);
9963
- const winner = selectCandidate(weighted);
9964
- return winner?.node.metaPath ?? null;
9857
+ function invalidateArchitect(state) {
9858
+ return enforceInvariant({
9859
+ architect: state.architect === 'failed' ? 'failed' : 'pending',
9860
+ builder: state.builder === 'fresh' ? 'stale' : state.builder,
9861
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
9862
+ });
9965
9863
  }
9966
-
9967
9864
  /**
9968
- * Shared error utilities.
9969
- *
9970
- * @module errors
9865
+ * Builder invalidated (scope mtime or cross-ref _content change):
9866
+ * builder → pending; critic → stale.
9867
+ * Only applies when architect is fresh; otherwise, builder stays stale.
9971
9868
  */
9869
+ function invalidateBuilder(state) {
9870
+ if (state.architect !== 'fresh') {
9871
+ // Architect is not fresh — builder stays stale (or whatever it is)
9872
+ return enforceInvariant({
9873
+ ...state,
9874
+ builder: state.builder === 'fresh' || state.builder === 'stale'
9875
+ ? 'stale'
9876
+ : state.builder,
9877
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
9878
+ });
9879
+ }
9880
+ return enforceInvariant({
9881
+ ...state,
9882
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
9883
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
9884
+ });
9885
+ }
9886
+ // ── Phase success transitions ──────────────────────────────────────────
9972
9887
  /**
9973
- * Wrap an unknown caught value into a MetaError.
9974
- *
9975
- * @param step - Which synthesis step failed.
9976
- * @param err - The caught error value.
9977
- * @param code - Error classification code.
9978
- * @returns A structured MetaError.
9888
+ * Architect completes successfully.
9889
+ * architect → fresh; builder → pending; critic → stale.
9979
9890
  */
9980
- function toMetaError(step, err, code = 'FAILED') {
9891
+ function architectSuccess(state) {
9892
+ return enforceInvariant({
9893
+ architect: 'fresh',
9894
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
9895
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
9896
+ });
9897
+ }
9898
+ /**
9899
+ * Builder completes successfully.
9900
+ * builder → fresh; critic → pending.
9901
+ */
9902
+ function builderSuccess(state) {
9903
+ return enforceInvariant({
9904
+ ...state,
9905
+ builder: 'fresh',
9906
+ critic: state.critic === 'failed' ? 'failed' : 'pending',
9907
+ });
9908
+ }
9909
+ /**
9910
+ * Critic completes successfully.
9911
+ * critic → fresh. Meta becomes fully fresh.
9912
+ */
9913
+ function criticSuccess(state) {
9914
+ return enforceInvariant({
9915
+ ...state,
9916
+ critic: 'fresh',
9917
+ });
9918
+ }
9919
+ // ── Failure transition ─────────────────────────────────────────────────
9920
+ /**
9921
+ * A phase fails (error, timeout, or abort).
9922
+ * Target phase → failed; upstream and downstream unchanged.
9923
+ */
9924
+ function phaseFailed(state, phase) {
9925
+ return enforceInvariant({
9926
+ ...state,
9927
+ [phase]: 'failed',
9928
+ });
9929
+ }
9930
+ // ── Surgical retry ─────────────────────────────────────────────────────
9931
+ /**
9932
+ * Retry a failed phase: failed → pending.
9933
+ * Only valid when the phase is currently failed.
9934
+ */
9935
+ function retryPhase(state, phase) {
9936
+ if (state[phase] !== 'failed')
9937
+ return state;
9938
+ return enforceInvariant({
9939
+ ...state,
9940
+ [phase]: 'pending',
9941
+ });
9942
+ }
9943
+ /**
9944
+ * Retry all failed phases: each failed phase → pending.
9945
+ * Used by scheduler ticks and queue reads to auto-promote failed phases.
9946
+ */
9947
+ function retryAllFailed(state) {
9948
+ let result = state;
9949
+ for (const phase of ['architect', 'builder', 'critic']) {
9950
+ if (result[phase] === 'failed') {
9951
+ result = retryPhase(result, phase);
9952
+ }
9953
+ }
9954
+ return result;
9955
+ }
9956
+ // ── Running transition ─────────────────────────────────────────────────
9957
+ /**
9958
+ * Mark a phase as running (scheduler picks it).
9959
+ */
9960
+ function phaseRunning(state, phase) {
9981
9961
  return {
9982
- step,
9983
- code,
9984
- message: err instanceof Error ? err.message : String(err),
9962
+ ...state,
9963
+ [phase]: 'running',
9985
9964
  };
9986
9965
  }
9966
+ // ── Query helpers ──────────────────────────────────────────────────────
9967
+ /**
9968
+ * Get the owed phase: first non-fresh phase in pipeline order, or null.
9969
+ */
9970
+ function getOwedPhase(state) {
9971
+ for (const phase of ['architect', 'builder', 'critic']) {
9972
+ if (state[phase] !== 'fresh')
9973
+ return phase;
9974
+ }
9975
+ return null;
9976
+ }
9977
+ /**
9978
+ * Check if a meta is fully fresh (all phases fresh).
9979
+ */
9980
+ function isFullyFresh(state) {
9981
+ return (state.architect === 'fresh' &&
9982
+ state.builder === 'fresh' &&
9983
+ state.critic === 'fresh');
9984
+ }
9985
+ /**
9986
+ * Get the scheduler priority band for a meta's owed phase.
9987
+ * 1 = critic (highest), 2 = builder, 3 = architect, null = fully fresh.
9988
+ */
9989
+ function getPriorityBand(state) {
9990
+ const owed = getOwedPhase(state);
9991
+ if (!owed)
9992
+ return null;
9993
+ if (owed === 'critic')
9994
+ return 1;
9995
+ if (owed === 'builder')
9996
+ return 2;
9997
+ return 3;
9998
+ }
9999
+
10000
+ /**
10001
+ * Backward-compatible derivation of _phaseState from existing meta fields.
10002
+ *
10003
+ * When a meta is loaded from disk without _phaseState, this reconstructs
10004
+ * the phase state from _content, _builder, _state, _error.step, and
10005
+ * the architect-invalidating inputs.
10006
+ *
10007
+ * @module phaseState/derivePhaseState
10008
+ */
10009
+ /**
10010
+ * Derive _phaseState from existing meta fields.
10011
+ *
10012
+ * If the meta already has _phaseState, returns it as-is.
10013
+ *
10014
+ * Otherwise, reconstructs from available fields:
10015
+ * - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
10016
+ * - Errored meta: the failed phase is mapped from _error.step.
10017
+ * - Mid-cycle meta with cached _builder but no _content: builder pending.
10018
+ * - Fully-fresh meta: all phases fresh.
10019
+ * - Meta with stale architect inputs: architect pending, downstream stale.
10020
+ *
10021
+ * @param meta - The meta.json content.
10022
+ * @param inputs - Optional derivation inputs. If not provided, a simpler
10023
+ * heuristic is used (no architect invalidation check).
10024
+ * @returns The derived PhaseState.
10025
+ */
10026
+ function derivePhaseState(meta, inputs) {
10027
+ // Already has _phaseState — use it
10028
+ if (meta._phaseState)
10029
+ return meta._phaseState;
10030
+ // Check for errors first — _error.step maps directly to failed phase
10031
+ if (meta._error) {
10032
+ const failedPhase = meta._error.step;
10033
+ const state = freshPhaseState();
10034
+ state[failedPhase] = 'failed';
10035
+ // If architect failed and no _builder, downstream is stale
10036
+ if (failedPhase === 'architect') {
10037
+ if (!meta._builder) {
10038
+ state.builder = 'stale';
10039
+ state.critic = 'stale';
10040
+ }
10041
+ }
10042
+ // If builder failed, critic is stale
10043
+ if (failedPhase === 'builder') {
10044
+ state.critic = 'stale';
10045
+ }
10046
+ return state;
10047
+ }
10048
+ // Never synthesized: no _content AND no _builder (and no error)
10049
+ if (!meta._content && !meta._builder) {
10050
+ return initialPhaseState();
10051
+ }
10052
+ // Check architect invalidation (when inputs are provided)
10053
+ if (inputs) {
10054
+ // Progressive metas: structure changes invalidate builder, not architect
10055
+ const structureInvalidatesArchitect = inputs.structureChanged && meta._state === undefined;
10056
+ const architectInvalidated = structureInvalidatesArchitect ||
10057
+ inputs.steerChanged ||
10058
+ inputs.architectChanged ||
10059
+ inputs.crossRefsChanged ||
10060
+ (meta._synthesisCount ?? 0) >= inputs.architectEvery;
10061
+ if (architectInvalidated) {
10062
+ return {
10063
+ architect: 'pending',
10064
+ builder: 'stale',
10065
+ critic: 'stale',
10066
+ };
10067
+ }
10068
+ // Progressive meta with structure change: builder-only invalidation
10069
+ if (inputs.structureChanged && meta._state !== undefined) {
10070
+ return {
10071
+ architect: 'fresh',
10072
+ builder: 'pending',
10073
+ critic: 'stale',
10074
+ };
10075
+ }
10076
+ }
10077
+ // Has _builder but no _content: builder is pending
10078
+ if (meta._builder && !meta._content) {
10079
+ return {
10080
+ architect: 'fresh',
10081
+ builder: 'pending',
10082
+ critic: 'stale',
10083
+ };
10084
+ }
10085
+ // Has _content but no _feedback: critic is pending
10086
+ if (meta._content && !meta._feedback) {
10087
+ return {
10088
+ architect: 'fresh',
10089
+ builder: 'fresh',
10090
+ critic: 'pending',
10091
+ };
10092
+ }
10093
+ // Default: fully fresh
10094
+ return freshPhaseState();
10095
+ }
9987
10096
 
9988
10097
  /**
9989
10098
  * Compute a structure hash from a sorted file listing.
@@ -10006,86 +10115,296 @@ function computeStructureHash(filePaths) {
10006
10115
  }
10007
10116
 
10008
10117
  /**
10009
- * Lock-staged cycle finalization: write to .lock, copy to meta.json, archive, prune.
10010
- *
10011
- * @module orchestrator/finalizeCycle
10012
- */
10013
- /** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
10014
- async function finalizeCycle(opts) {
10015
- const lockPath = join(opts.metaPath, '.lock');
10016
- const metaJsonPath = join(opts.metaPath, 'meta.json');
10017
- // Stage: write merged result to .lock (sequential — ordering matters)
10018
- const updated = await mergeAndWrite({
10019
- metaPath: opts.metaPath,
10020
- current: opts.current,
10021
- architect: opts.architect,
10022
- builder: opts.builder,
10023
- critic: opts.critic,
10024
- builderOutput: opts.builderOutput,
10025
- feedback: opts.feedback,
10026
- structureHash: opts.structureHash,
10027
- synthesisCount: opts.synthesisCount,
10028
- error: opts.error,
10029
- architectTokens: opts.architectTokens,
10030
- builderTokens: opts.builderTokens,
10031
- criticTokens: opts.criticTokens,
10032
- outputPath: lockPath,
10033
- state: opts.state,
10034
- stateOnly: opts.stateOnly,
10035
- });
10036
- // Commit: copy .lock → meta.json
10037
- await copyFile(lockPath, metaJsonPath);
10038
- // Archive + prune from the committed meta.json (sequential)
10039
- await createSnapshot(opts.metaPath, updated);
10040
- await pruneArchive(opts.metaPath, opts.config.maxArchive);
10041
- // .lock is cleaned up by the finally block (releaseLock)
10042
- return updated;
10043
- }
10044
-
10045
- /**
10046
- * Parse subprocess outputs for each synthesis step.
10118
+ * Per-tick invalidation pass.
10047
10119
  *
10048
- * - Architect: returns text \> _builder
10049
- * - Builder: returns JSON \> _content + structured fields
10050
- * - Critic: returns text \> _feedback
10120
+ * Computes architect-invalidating and builder-invalidating inputs for a meta,
10121
+ * then applies the cascade to update _phaseState.
10051
10122
  *
10052
- * @module orchestrator/parseOutput
10123
+ * @module phaseState/invalidate
10053
10124
  */
10054
10125
  /**
10055
- * Parse architect output. The architect returns a task brief as text.
10126
+ * Compute invalidation inputs and apply cascade for a single meta.
10056
10127
  *
10057
- * @param output - Raw subprocess output.
10058
- * @returns The task brief string.
10128
+ * @param meta - Current meta.json content with existing _phaseState.
10129
+ * @param scopeFiles - Sorted file list from scope.
10130
+ * @param config - MetaConfig for architectEvery.
10131
+ * @param node - MetaNode for archive access.
10132
+ * @param crossRefMetas - Map of cross-ref owner paths to their current _content.
10133
+ * @param archiveCrossRefContent - Map of cross-ref owner paths to their archived _content.
10134
+ * @returns Updated phase state and invalidation details.
10059
10135
  */
10060
- function parseArchitectOutput(output) {
10061
- return output.trim();
10136
+ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas, archiveCrossRefContent) {
10137
+ let phaseState = meta._phaseState ?? {
10138
+ architect: 'fresh',
10139
+ builder: 'fresh',
10140
+ critic: 'fresh',
10141
+ };
10142
+ // ── Architect-level inputs ──
10143
+ const structureHash = computeStructureHash(scopeFiles);
10144
+ const structureChanged = structureHash !== meta._structureHash;
10145
+ const latestArchive = await readLatestArchive(node.metaPath);
10146
+ const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10147
+ // _architect change: compare current vs. archive
10148
+ const architectChanged = latestArchive
10149
+ ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
10150
+ : Boolean(meta._architect);
10151
+ // _crossRefs declaration change
10152
+ const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
10153
+ const archiveRefs = (latestArchive?._crossRefs ?? [])
10154
+ .slice()
10155
+ .sort()
10156
+ .join(',');
10157
+ const crossRefsDeclChanged = latestArchive
10158
+ ? currentRefs !== archiveRefs
10159
+ : currentRefs.length > 0;
10160
+ const architectInvalidators = [];
10161
+ if (structureChanged) {
10162
+ if (meta._state !== undefined) {
10163
+ // Progressive entity: new files → builder only (cursor handles incremental)
10164
+ phaseState = invalidateBuilder(phaseState);
10165
+ }
10166
+ else {
10167
+ architectInvalidators.push('structureHash');
10168
+ }
10169
+ }
10170
+ if (steerChanged)
10171
+ architectInvalidators.push('steer');
10172
+ if (architectChanged)
10173
+ architectInvalidators.push('_architect');
10174
+ if (crossRefsDeclChanged)
10175
+ architectInvalidators.push('_crossRefs');
10176
+ if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
10177
+ architectInvalidators.push('architectEvery');
10178
+ }
10179
+ // First-run check: no _builder means architect must run
10180
+ const firstRun = !meta._builder;
10181
+ if (architectInvalidators.length > 0 || firstRun) {
10182
+ phaseState = invalidateArchitect(phaseState);
10183
+ }
10184
+ // ── Builder-level inputs ──
10185
+ // Scope file mtime check — if any file newer than _generatedAt
10186
+ const scopeMtimeMax = null;
10187
+ // Note: actual mtime check is done by the caller or via isStale;
10188
+ // here we just detect cross-ref content changes for the cascade.
10189
+ // Cross-ref _content change (builder-invalidating)
10190
+ let crossRefContentChanged = false;
10191
+ return {
10192
+ phaseState,
10193
+ architectInvalidators,
10194
+ stalenessInputs: {
10195
+ structureHash,
10196
+ steerChanged,
10197
+ architectChanged,
10198
+ crossRefsDeclChanged,
10199
+ scopeMtimeMax,
10200
+ crossRefContentChanged,
10201
+ },
10202
+ structureHash,
10203
+ steerChanged,
10204
+ };
10062
10205
  }
10206
+
10063
10207
  /**
10064
- * Parse builder output. The builder returns JSON with _content and optional fields.
10208
+ * Weighted staleness formula for candidate selection.
10065
10209
  *
10066
- * Attempts JSON parse first. If that fails, treats the entire output as _content.
10210
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10067
10211
  *
10068
- * @param output - Raw subprocess output.
10069
- * @returns Parsed builder output with content and structured fields.
10212
+ * @module scheduling/weightedFormula
10070
10213
  */
10071
- function parseBuilderOutput(output) {
10072
- const trimmed = output.trim();
10073
- // Strategy 1: Try to parse the entire output as JSON directly
10074
- const direct = tryParseJson(trimmed);
10075
- if (direct)
10076
- return direct;
10077
- // Strategy 2: Try all fenced code blocks (last match first models often narrate then output)
10078
- const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
10079
- const fenceMatches = [];
10080
- let match;
10081
- while ((match = fencePattern.exec(trimmed)) !== null) {
10082
- fenceMatches.push(match[1].trim());
10083
- }
10084
- // Try last fence first (most likely to be the actual output)
10085
- for (let i = fenceMatches.length - 1; i >= 0; i--) {
10086
- const result = tryParseJson(fenceMatches[i]);
10087
- if (result)
10088
- return result;
10214
+ /**
10215
+ * Compute effective staleness for a set of candidates.
10216
+ *
10217
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
10218
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10219
+ *
10220
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
10221
+ * metas to tune how much their tree position affects scheduling.
10222
+ *
10223
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
10224
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
10225
+ * @returns Same array with effectiveStaleness computed.
10226
+ */
10227
+ function computeEffectiveStaleness(candidates, depthWeight) {
10228
+ if (candidates.length === 0)
10229
+ return [];
10230
+ // Get depth for each candidate: use _depth override or tree depth
10231
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
10232
+ // Normalize: shift so minimum becomes 0
10233
+ const minDepth = Math.min(...depths);
10234
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
10235
+ return candidates.map((c, i) => {
10236
+ const emphasis = c.meta._emphasis ?? 1;
10237
+ return {
10238
+ ...c,
10239
+ effectiveStaleness: c.actualStaleness *
10240
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
10241
+ };
10242
+ });
10243
+ }
10244
+
10245
+ /**
10246
+ * Corpus-wide phase scheduler.
10247
+ *
10248
+ * Selects the highest-priority ready phase across all metas.
10249
+ * Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
10250
+ * Tiebreak within band: weighted staleness (§3.9).
10251
+ *
10252
+ * @module phaseState/phaseScheduler
10253
+ */
10254
+ /**
10255
+ * Build phase candidates from listMetas entries.
10256
+ *
10257
+ * Derives phase state, auto-retries failed phases, and applies Tier 1
10258
+ * cheap-invalidation (no I/O) for metas with persisted _phaseState.
10259
+ * Used by orchestratePhase, queue route, and status route.
10260
+ */
10261
+ function buildPhaseCandidates(entries, architectEvery) {
10262
+ return entries.map((entry) => {
10263
+ let ps = retryAllFailed(derivePhaseState(entry.meta));
10264
+ // Tier 1 cheap invalidation for metas with persisted _phaseState
10265
+ if (entry.meta._phaseState) {
10266
+ const needsArchitect = !entry.meta._builder ||
10267
+ (entry.meta._synthesisCount ?? 0) >= architectEvery;
10268
+ if (needsArchitect && ps.architect === 'fresh') {
10269
+ ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
10270
+ }
10271
+ }
10272
+ return {
10273
+ node: entry.node,
10274
+ meta: entry.meta,
10275
+ phaseState: ps,
10276
+ actualStaleness: entry.stalenessSeconds,
10277
+ locked: entry.locked,
10278
+ disabled: entry.disabled,
10279
+ };
10280
+ });
10281
+ }
10282
+ /**
10283
+ * Rank all eligible phase candidates by priority.
10284
+ *
10285
+ * Filters to pending phases, computes effective staleness, and sorts by
10286
+ * band (ascending: critic first) then effective staleness (descending).
10287
+ *
10288
+ * Used by selectPhaseCandidate (returns first) and the queue route (returns all).
10289
+ */
10290
+ function rankPhaseCandidates(metas, depthWeight) {
10291
+ // Filter to metas with a pending (scheduler-eligible) phase
10292
+ const eligible = metas.filter((m) => {
10293
+ if (m.locked)
10294
+ return false;
10295
+ if (m.disabled && !m.isOverride)
10296
+ return false;
10297
+ const owed = getOwedPhase(m.phaseState);
10298
+ if (!owed)
10299
+ return false;
10300
+ return m.phaseState[owed] === 'pending';
10301
+ });
10302
+ if (eligible.length === 0)
10303
+ return [];
10304
+ // Compute effective staleness for tiebreaking
10305
+ const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
10306
+ node: m.node,
10307
+ meta: m.meta,
10308
+ actualStaleness: m.actualStaleness,
10309
+ })), depthWeight);
10310
+ // Build candidates with band info
10311
+ const candidates = withStaleness.map((ws, i) => {
10312
+ const m = eligible[i];
10313
+ const owedPhase = getOwedPhase(m.phaseState);
10314
+ return {
10315
+ node: ws.node,
10316
+ meta: ws.meta,
10317
+ phaseState: m.phaseState,
10318
+ owedPhase,
10319
+ band: getPriorityBand(m.phaseState),
10320
+ actualStaleness: ws.actualStaleness,
10321
+ effectiveStaleness: ws.effectiveStaleness,
10322
+ };
10323
+ });
10324
+ // Sort by band (ascending = critic first) then effective staleness (descending)
10325
+ candidates.sort((a, b) => {
10326
+ if (a.band !== b.band)
10327
+ return a.band - b.band;
10328
+ return b.effectiveStaleness - a.effectiveStaleness;
10329
+ });
10330
+ return candidates;
10331
+ }
10332
+ /**
10333
+ * Select the best phase candidate across the corpus.
10334
+ *
10335
+ * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
10336
+ * @param depthWeight - Config depthWeight for staleness tiebreak.
10337
+ * @returns The winning candidate, or null if no phase is ready.
10338
+ */
10339
+ function selectPhaseCandidate(metas, depthWeight) {
10340
+ return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10341
+ }
10342
+
10343
+ /**
10344
+ * Shared error utilities.
10345
+ *
10346
+ * @module errors
10347
+ */
10348
+ /**
10349
+ * Wrap an unknown caught value into a MetaError.
10350
+ *
10351
+ * @param step - Which synthesis step failed.
10352
+ * @param err - The caught error value.
10353
+ * @param code - Error classification code.
10354
+ * @returns A structured MetaError.
10355
+ */
10356
+ function toMetaError(step, err, code = 'FAILED') {
10357
+ return {
10358
+ step,
10359
+ code,
10360
+ message: err instanceof Error ? err.message : String(err),
10361
+ };
10362
+ }
10363
+
10364
+ /**
10365
+ * Parse subprocess outputs for each synthesis step.
10366
+ *
10367
+ * - Architect: returns text \> _builder
10368
+ * - Builder: returns JSON \> _content + structured fields
10369
+ * - Critic: returns text \> _feedback
10370
+ *
10371
+ * @module orchestrator/parseOutput
10372
+ */
10373
+ /**
10374
+ * Parse architect output. The architect returns a task brief as text.
10375
+ *
10376
+ * @param output - Raw subprocess output.
10377
+ * @returns The task brief string.
10378
+ */
10379
+ function parseArchitectOutput(output) {
10380
+ return output.trim();
10381
+ }
10382
+ /**
10383
+ * Parse builder output. The builder returns JSON with _content and optional fields.
10384
+ *
10385
+ * Attempts JSON parse first. If that fails, treats the entire output as _content.
10386
+ *
10387
+ * @param output - Raw subprocess output.
10388
+ * @returns Parsed builder output with content and structured fields.
10389
+ */
10390
+ function parseBuilderOutput(output) {
10391
+ const trimmed = output.trim();
10392
+ // Strategy 1: Try to parse the entire output as JSON directly
10393
+ const direct = tryParseJson(trimmed);
10394
+ if (direct)
10395
+ return direct;
10396
+ // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
10397
+ const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
10398
+ const fenceMatches = [];
10399
+ let match;
10400
+ while ((match = fencePattern.exec(trimmed)) !== null) {
10401
+ fenceMatches.push(match[1].trim());
10402
+ }
10403
+ // Try last fence first (most likely to be the actual output)
10404
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
10405
+ const result = tryParseJson(fenceMatches[i]);
10406
+ if (result)
10407
+ return result;
10089
10408
  }
10090
10409
  // Strategy 3: Find outermost { ... } braces
10091
10410
  const firstBrace = trimmed.indexOf('{');
@@ -10121,730 +10440,22 @@ function tryParseJson(str) {
10121
10440
  for (const [key, value] of Object.entries(parsed)) {
10122
10441
  if (!key.startsWith('_') && key !== 'content') {
10123
10442
  fields[key] = value;
10124
- }
10125
- }
10126
- return { content, fields, ...(state !== undefined ? { state } : {}) };
10127
- }
10128
- catch {
10129
- return null;
10130
- }
10131
- }
10132
- /**
10133
- * Parse critic output. The critic returns evaluation text.
10134
- *
10135
- * @param output - Raw subprocess output.
10136
- * @returns The feedback string.
10137
- */
10138
- function parseCriticOutput(output) {
10139
- return output.trim();
10140
- }
10141
-
10142
- /**
10143
- * Timeout recovery — salvage partial builder state after a SpawnTimeoutError.
10144
- *
10145
- * @module orchestrator/timeoutRecovery
10146
- */
10147
- /**
10148
- * Attempt to recover partial state from a timed-out builder spawn.
10149
- *
10150
- * Returns an {@link OrchestrateResult} if state was salvaged, or `null`
10151
- * if the caller should fall through to a hard failure.
10152
- */
10153
- async function attemptTimeoutRecovery(opts) {
10154
- const { err, currentMeta, metaPath, config, builderBrief, structureHash, synthesisCount, } = opts;
10155
- let partialOutput = null;
10156
- try {
10157
- const raw = await readFile(err.outputPath, 'utf8');
10158
- partialOutput = parseBuilderOutput(raw);
10159
- }
10160
- catch {
10161
- // Could not read partial output — fall through to hard failure
10162
- }
10163
- if (partialOutput?.state !== undefined) {
10164
- const currentState = JSON.stringify(currentMeta._state);
10165
- const newState = JSON.stringify(partialOutput.state);
10166
- if (newState !== currentState) {
10167
- const timeoutError = {
10168
- step: 'builder',
10169
- code: 'TIMEOUT',
10170
- message: err.message,
10171
- };
10172
- await finalizeCycle({
10173
- metaPath,
10174
- current: currentMeta,
10175
- config,
10176
- architect: currentMeta._architect ?? '',
10177
- builder: builderBrief,
10178
- critic: currentMeta._critic ?? '',
10179
- builderOutput: null,
10180
- feedback: null,
10181
- structureHash,
10182
- synthesisCount,
10183
- error: timeoutError,
10184
- state: partialOutput.state,
10185
- stateOnly: true,
10186
- });
10187
- return {
10188
- synthesized: true,
10189
- metaPath,
10190
- error: timeoutError,
10191
- };
10192
- }
10193
- }
10194
- return null;
10195
- }
10196
-
10197
- /**
10198
- * Single-node synthesis pipeline — architect, builder, critic.
10199
- *
10200
- * @module orchestrator/synthesizeNode
10201
- */
10202
- /** Run the architect/builder/critic pipeline on a single node. */
10203
- async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger) {
10204
- // Step 5-6: Steer change detection
10205
- const latestArchive = await readLatestArchive(node.metaPath);
10206
- const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
10207
- // Step 7: Compute context (includes scope files and delta files)
10208
- const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10209
- // Skip empty-scope entities that have no prior content.
10210
- // Without scope files, child metas, or cross-refs there is nothing for
10211
- // the architect/builder to work with and the cycle will either time out
10212
- // or produce empty output.
10213
- const hasScope = ctx.scopeFiles.length > 0 ||
10214
- Object.keys(ctx.childMetas).length > 0 ||
10215
- Object.keys(ctx.crossRefMetas).length > 0;
10216
- if (!hasScope && !currentMeta._content) {
10217
- // Bump _generatedAt so this entity doesn't keep winning the staleness
10218
- // race every cycle. It will be re-evaluated when files appear.
10219
- // Uses lock-staging for atomic write consistency.
10220
- currentMeta._generatedAt = new Date().toISOString();
10221
- const lockPath = join(node.metaPath, '.lock');
10222
- const metaJsonPath = join(node.metaPath, 'meta.json');
10223
- await writeFile(lockPath, JSON.stringify(currentMeta, null, 2));
10224
- await copyFile(lockPath, metaJsonPath);
10225
- logger?.debug({ path: node.ownerPath }, 'Skipping empty-scope entity');
10226
- return { synthesized: false };
10227
- }
10228
- // Step 5 (deferred): Structure hash from context scope files
10229
- const newStructureHash = computeStructureHash(ctx.scopeFiles);
10230
- const structureChanged = newStructureHash !== currentMeta._structureHash;
10231
- // Step 8: Architect (conditional)
10232
- const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
10233
- let builderBrief = currentMeta._builder ?? '';
10234
- let synthesisCount = currentMeta._synthesisCount ?? 0;
10235
- let stepError = null;
10236
- let architectTokens;
10237
- let builderTokens;
10238
- let criticTokens;
10239
- // Shared base options for all finalizeCycle calls.
10240
- // Note: synthesisCount is excluded because it mutates during the pipeline.
10241
- const baseFinalizeOptions = {
10242
- metaPath: node.metaPath,
10243
- current: currentMeta,
10244
- config,
10245
- architect: currentMeta._architect ?? '',
10246
- critic: currentMeta._critic ?? '',
10247
- structureHash: newStructureHash,
10248
- };
10249
- if (architectTriggered) {
10250
- try {
10251
- await onProgress?.({
10252
- type: 'phase_start',
10253
- path: node.ownerPath,
10254
- phase: 'architect',
10255
- });
10256
- const phaseStart = Date.now();
10257
- const architectTask = buildArchitectTask(ctx, currentMeta, config);
10258
- const architectResult = await executor.spawn(architectTask, {
10259
- thinking: config.thinking,
10260
- timeout: config.architectTimeout,
10261
- label: 'meta-architect',
10262
- });
10263
- builderBrief = parseArchitectOutput(architectResult.output);
10264
- architectTokens = architectResult.tokens;
10265
- synthesisCount = 0;
10266
- await onProgress?.({
10267
- type: 'phase_complete',
10268
- path: node.ownerPath,
10269
- phase: 'architect',
10270
- tokens: architectTokens,
10271
- durationMs: Date.now() - phaseStart,
10272
- });
10273
- }
10274
- catch (err) {
10275
- stepError = toMetaError('architect', err);
10276
- if (!currentMeta._builder) {
10277
- // No cached builder — cycle fails
10278
- await finalizeCycle({
10279
- ...baseFinalizeOptions,
10280
- builder: '',
10281
- builderOutput: null,
10282
- feedback: null,
10283
- synthesisCount,
10284
- error: stepError,
10285
- architectTokens,
10286
- });
10287
- return {
10288
- synthesized: true,
10289
- metaPath: node.metaPath,
10290
- error: stepError,
10291
- };
10292
- }
10293
- // Has cached builder — continue with existing
10294
- }
10295
- }
10296
- // Step 9: Builder
10297
- const metaForBuilder = { ...currentMeta, _builder: builderBrief };
10298
- let builderOutput;
10299
- try {
10300
- await onProgress?.({
10301
- type: 'phase_start',
10302
- path: node.ownerPath,
10303
- phase: 'builder',
10304
- });
10305
- const builderStart = Date.now();
10306
- const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
10307
- const builderResult = await executor.spawn(builderTask, {
10308
- thinking: config.thinking,
10309
- timeout: config.builderTimeout,
10310
- label: 'meta-builder',
10311
- });
10312
- builderOutput = parseBuilderOutput(builderResult.output);
10313
- builderTokens = builderResult.tokens;
10314
- synthesisCount++;
10315
- await onProgress?.({
10316
- type: 'phase_complete',
10317
- path: node.ownerPath,
10318
- phase: 'builder',
10319
- tokens: builderTokens,
10320
- durationMs: Date.now() - builderStart,
10321
- });
10322
- }
10323
- catch (err) {
10324
- if (err instanceof SpawnTimeoutError) {
10325
- const recovered = await attemptTimeoutRecovery({
10326
- err,
10327
- currentMeta,
10328
- metaPath: node.metaPath,
10329
- config,
10330
- builderBrief,
10331
- structureHash: newStructureHash,
10332
- synthesisCount,
10333
- });
10334
- if (recovered)
10335
- return recovered;
10336
- }
10337
- stepError = toMetaError('builder', err);
10338
- await finalizeCycle({
10339
- ...baseFinalizeOptions,
10340
- builder: builderBrief,
10341
- builderOutput: null,
10342
- feedback: null,
10343
- synthesisCount,
10344
- error: stepError,
10345
- });
10346
- return { synthesized: true, metaPath: node.metaPath, error: stepError };
10347
- }
10348
- // Step 10: Critic
10349
- const metaForCritic = {
10350
- ...currentMeta,
10351
- _content: builderOutput.content,
10352
- };
10353
- let feedback = null;
10354
- try {
10355
- await onProgress?.({
10356
- type: 'phase_start',
10357
- path: node.ownerPath,
10358
- phase: 'critic',
10359
- });
10360
- const criticStart = Date.now();
10361
- const criticTask = buildCriticTask(ctx, metaForCritic, config);
10362
- const criticResult = await executor.spawn(criticTask, {
10363
- thinking: config.thinking,
10364
- timeout: config.criticTimeout,
10365
- label: 'meta-critic',
10366
- });
10367
- feedback = parseCriticOutput(criticResult.output);
10368
- criticTokens = criticResult.tokens;
10369
- stepError = null; // Clear any architect error on full success
10370
- await onProgress?.({
10371
- type: 'phase_complete',
10372
- path: node.ownerPath,
10373
- phase: 'critic',
10374
- tokens: criticTokens,
10375
- durationMs: Date.now() - criticStart,
10376
- });
10377
- }
10378
- catch (err) {
10379
- stepError = stepError ?? toMetaError('critic', err);
10380
- }
10381
- // Steps 11-12: Merge, archive, prune
10382
- await finalizeCycle({
10383
- ...baseFinalizeOptions,
10384
- builder: builderBrief,
10385
- builderOutput,
10386
- feedback,
10387
- synthesisCount,
10388
- error: stepError,
10389
- architectTokens,
10390
- builderTokens,
10391
- criticTokens,
10392
- state: builderOutput.state,
10393
- });
10394
- return {
10395
- synthesized: true,
10396
- metaPath: node.metaPath,
10397
- error: stepError ?? undefined,
10398
- };
10399
- }
10400
-
10401
- /**
10402
- * Main orchestration entry point — discovery, scheduling, candidate selection.
10403
- *
10404
- * @module orchestrator/orchestrate
10405
- */
10406
- async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
10407
- // When targetPath is provided, skip the expensive full discovery scan.
10408
- // Build a minimal node from the filesystem instead.
10409
- if (targetPath) {
10410
- const normalizedTarget = normalizePath(targetPath);
10411
- const targetMetaJson = join(normalizedTarget, 'meta.json');
10412
- if (!existsSync(targetMetaJson))
10413
- return { synthesized: false };
10414
- const node = await buildMinimalNode(normalizedTarget, watcher);
10415
- if (!acquireLock(node.metaPath))
10416
- return { synthesized: false };
10417
- try {
10418
- const currentMeta = await readMetaJson(normalizedTarget);
10419
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
10420
- }
10421
- finally {
10422
- releaseLock(node.metaPath);
10423
- }
10424
- }
10425
- // Full discovery path (scheduler-driven, no specific target)
10426
- // Step 1: Discover via watcher walk
10427
- const discoveryStart = Date.now();
10428
- const metaPaths = await discoverMetas(watcher);
10429
- logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
10430
- if (metaPaths.length === 0)
10431
- return { synthesized: false };
10432
- // Read meta.json for each discovered meta
10433
- const metas = new Map();
10434
- for (const mp of metaPaths) {
10435
- try {
10436
- metas.set(normalizePath(mp), await readMetaJson(mp));
10437
- }
10438
- catch {
10439
- // Skip metas with unreadable meta.json
10440
- continue;
10441
- }
10442
- }
10443
- // Only build tree from paths with readable meta.json (excludes orphaned/deleted entries)
10444
- const validPaths = metaPaths.filter((mp) => metas.has(normalizePath(mp)));
10445
- if (validPaths.length === 0)
10446
- return { synthesized: false };
10447
- const tree = buildOwnershipTree(validPaths);
10448
- // Steps 3-4: Staleness check + candidate selection
10449
- const candidates = [];
10450
- for (const treeNode of tree.nodes.values()) {
10451
- const meta = metas.get(treeNode.metaPath);
10452
- if (!meta)
10453
- continue;
10454
- const staleness = actualStaleness(meta);
10455
- if (staleness > 0) {
10456
- candidates.push({ node: treeNode, meta, actualStaleness: staleness });
10457
- }
10458
- }
10459
- const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
10460
- // Sort by effective staleness descending
10461
- const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
10462
- if (ranked.length === 0)
10463
- return { synthesized: false };
10464
- // Find the first candidate with actual changes (if skipUnchanged)
10465
- let winner = null;
10466
- for (const candidate of ranked) {
10467
- if (!acquireLock(candidate.node.metaPath))
10468
- continue;
10469
- const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
10470
- if (!verifiedStale && candidate.meta._generatedAt) {
10471
- // Bump _generatedAt so it doesn't win next cycle
10472
- const freshMeta = await readMetaJson(candidate.node.metaPath);
10473
- freshMeta._generatedAt = new Date().toISOString();
10474
- await writeFile(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
10475
- releaseLock(candidate.node.metaPath);
10476
- if (config.skipUnchanged)
10477
- continue;
10478
- return { synthesized: false };
10479
- }
10480
- winner = candidate;
10481
- break;
10482
- }
10483
- if (!winner)
10484
- return { synthesized: false };
10485
- const node = winner.node;
10486
- try {
10487
- const currentMeta = await readMetaJson(node.metaPath);
10488
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
10489
- }
10490
- finally {
10491
- // Step 13: Release lock
10492
- releaseLock(node.metaPath);
10493
- }
10494
- }
10495
- /**
10496
- * Run a single synthesis cycle.
10497
- *
10498
- * Selects the stalest candidate (or a specific target) and runs the
10499
- * full architect/builder/critic pipeline.
10500
- *
10501
- * @param config - Validated synthesis config.
10502
- * @param executor - Pluggable LLM executor.
10503
- * @param watcher - Watcher HTTP client.
10504
- * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
10505
- * @returns Array with a single result.
10506
- */
10507
- async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
10508
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
10509
- return [result];
10510
- }
10511
-
10512
- /**
10513
- * Pure phase-state transition functions.
10514
- *
10515
- * Implements every row of the §8 "Transitions and invalidation cascade" table.
10516
- * No I/O — pure functions over PhaseState and documented inputs.
10517
- *
10518
- * @module phaseState/phaseTransitions
10519
- */
10520
- /**
10521
- * Create a fresh (fully-complete) phase state.
10522
- */
10523
- function freshPhaseState() {
10524
- return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
10525
- }
10526
- /**
10527
- * Create a phase state for a never-synthesized meta (all pending from architect).
10528
- */
10529
- function initialPhaseState() {
10530
- return { architect: 'pending', builder: 'stale', critic: 'stale' };
10531
- }
10532
- /**
10533
- * Enforce the per-meta invariant: at most one phase is pending or running,
10534
- * and it is the first non-fresh phase in pipeline order.
10535
- *
10536
- * Stale phases that become the first non-fresh phase are promoted to pending.
10537
- */
10538
- function enforceInvariant(state) {
10539
- const result = { ...state };
10540
- let foundNonFresh = false;
10541
- for (const phase of ['architect', 'builder', 'critic']) {
10542
- const s = result[phase];
10543
- if (s === 'fresh')
10544
- continue;
10545
- if (!foundNonFresh) {
10546
- foundNonFresh = true;
10547
- // First non-fresh: if stale, promote to pending
10548
- if (s === 'stale') {
10549
- result[phase] = 'pending';
10550
- }
10551
- // pending, running, failed stay as-is
10552
- }
10553
- else {
10554
- // Subsequent non-fresh: must not be pending or running
10555
- if (s === 'pending') {
10556
- result[phase] = 'stale';
10557
- }
10558
- // running in non-first position would be a bug, but don't mask it
10559
- }
10560
- }
10561
- return result;
10562
- }
10563
- // ── Phase success transitions ──────────────────────────────────────────
10564
- /**
10565
- * Architect completes successfully.
10566
- * architect → fresh; builder → pending; critic → stale.
10567
- */
10568
- function architectSuccess(state) {
10569
- return enforceInvariant({
10570
- architect: 'fresh',
10571
- builder: state.builder === 'failed' ? 'failed' : 'pending',
10572
- critic: state.critic === 'fresh' ? 'stale' : state.critic,
10573
- });
10574
- }
10575
- /**
10576
- * Builder completes successfully.
10577
- * builder → fresh; critic → pending.
10578
- */
10579
- function builderSuccess(state) {
10580
- return enforceInvariant({
10581
- ...state,
10582
- builder: 'fresh',
10583
- critic: state.critic === 'failed' ? 'failed' : 'pending',
10584
- });
10585
- }
10586
- /**
10587
- * Critic completes successfully.
10588
- * critic → fresh. Meta becomes fully fresh.
10589
- */
10590
- function criticSuccess(state) {
10591
- return enforceInvariant({
10592
- ...state,
10593
- critic: 'fresh',
10594
- });
10595
- }
10596
- // ── Failure transition ─────────────────────────────────────────────────
10597
- /**
10598
- * A phase fails (error, timeout, or abort).
10599
- * Target phase → failed; upstream and downstream unchanged.
10600
- */
10601
- function phaseFailed(state, phase) {
10602
- return enforceInvariant({
10603
- ...state,
10604
- [phase]: 'failed',
10605
- });
10606
- }
10607
- // ── Surgical retry ─────────────────────────────────────────────────────
10608
- /**
10609
- * Retry a failed phase: failed → pending.
10610
- * Only valid when the phase is currently failed.
10611
- */
10612
- function retryPhase(state, phase) {
10613
- if (state[phase] !== 'failed')
10614
- return state;
10615
- return enforceInvariant({
10616
- ...state,
10617
- [phase]: 'pending',
10618
- });
10619
- }
10620
- /**
10621
- * Retry all failed phases: each failed phase → pending.
10622
- * Used by scheduler ticks and queue reads to auto-promote failed phases.
10623
- */
10624
- function retryAllFailed(state) {
10625
- let result = state;
10626
- for (const phase of ['architect', 'builder', 'critic']) {
10627
- if (result[phase] === 'failed') {
10628
- result = retryPhase(result, phase);
10629
- }
10630
- }
10631
- return result;
10632
- }
10633
- // ── Running transition ─────────────────────────────────────────────────
10634
- /**
10635
- * Mark a phase as running (scheduler picks it).
10636
- */
10637
- function phaseRunning(state, phase) {
10638
- return {
10639
- ...state,
10640
- [phase]: 'running',
10641
- };
10642
- }
10643
- // ── Query helpers ──────────────────────────────────────────────────────
10644
- /**
10645
- * Get the owed phase: first non-fresh phase in pipeline order, or null.
10646
- */
10647
- function getOwedPhase(state) {
10648
- for (const phase of ['architect', 'builder', 'critic']) {
10649
- if (state[phase] !== 'fresh')
10650
- return phase;
10651
- }
10652
- return null;
10653
- }
10654
- /**
10655
- * Check if a meta is fully fresh (all phases fresh).
10656
- */
10657
- function isFullyFresh(state) {
10658
- return (state.architect === 'fresh' &&
10659
- state.builder === 'fresh' &&
10660
- state.critic === 'fresh');
10661
- }
10662
- /**
10663
- * Get the scheduler priority band for a meta's owed phase.
10664
- * 1 = critic (highest), 2 = builder, 3 = architect, null = fully fresh.
10665
- */
10666
- function getPriorityBand(state) {
10667
- const owed = getOwedPhase(state);
10668
- if (!owed)
10669
- return null;
10670
- if (owed === 'critic')
10671
- return 1;
10672
- if (owed === 'builder')
10673
- return 2;
10674
- return 3;
10675
- }
10676
-
10677
- /**
10678
- * Backward-compatible derivation of _phaseState from existing meta fields.
10679
- *
10680
- * When a meta is loaded from disk without _phaseState, this reconstructs
10681
- * the phase state from _content, _builder, _state, _error.step, and
10682
- * the architect-invalidating inputs.
10683
- *
10684
- * @module phaseState/derivePhaseState
10685
- */
10686
- /**
10687
- * Derive _phaseState from existing meta fields.
10688
- *
10689
- * If the meta already has _phaseState, returns it as-is.
10690
- *
10691
- * Otherwise, reconstructs from available fields:
10692
- * - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
10693
- * - Errored meta: the failed phase is mapped from _error.step.
10694
- * - Mid-cycle meta with cached _builder but no _content: builder pending.
10695
- * - Fully-fresh meta: all phases fresh.
10696
- * - Meta with stale architect inputs: architect pending, downstream stale.
10697
- *
10698
- * @param meta - The meta.json content.
10699
- * @param inputs - Optional derivation inputs. If not provided, a simpler
10700
- * heuristic is used (no architect invalidation check).
10701
- * @returns The derived PhaseState.
10702
- */
10703
- function derivePhaseState(meta, inputs) {
10704
- // Already has _phaseState — use it
10705
- if (meta._phaseState)
10706
- return meta._phaseState;
10707
- // Check for errors first — _error.step maps directly to failed phase
10708
- if (meta._error) {
10709
- const failedPhase = meta._error.step;
10710
- const state = freshPhaseState();
10711
- state[failedPhase] = 'failed';
10712
- // If architect failed and no _builder, downstream is stale
10713
- if (failedPhase === 'architect') {
10714
- if (!meta._builder) {
10715
- state.builder = 'stale';
10716
- state.critic = 'stale';
10717
- }
10718
- }
10719
- // If builder failed, critic is stale
10720
- if (failedPhase === 'builder') {
10721
- state.critic = 'stale';
10722
- }
10723
- return state;
10724
- }
10725
- // Never synthesized: no _content AND no _builder (and no error)
10726
- if (!meta._content && !meta._builder) {
10727
- return initialPhaseState();
10728
- }
10729
- // Check architect invalidation (when inputs are provided)
10730
- if (inputs) {
10731
- const architectInvalidated = inputs.structureChanged ||
10732
- inputs.steerChanged ||
10733
- inputs.architectChanged ||
10734
- inputs.crossRefsChanged ||
10735
- (meta._synthesisCount ?? 0) >= inputs.architectEvery;
10736
- if (architectInvalidated) {
10737
- return {
10738
- architect: 'pending',
10739
- builder: 'stale',
10740
- critic: 'stale',
10741
- };
10443
+ }
10742
10444
  }
10445
+ return { content, fields, ...(state !== undefined ? { state } : {}) };
10743
10446
  }
10744
- // Has _builder but no _content: builder is pending
10745
- if (meta._builder && !meta._content) {
10746
- return {
10747
- architect: 'fresh',
10748
- builder: 'pending',
10749
- critic: 'stale',
10750
- };
10751
- }
10752
- // Has _content but no _feedback: critic is pending
10753
- if (meta._content && !meta._feedback) {
10754
- return {
10755
- architect: 'fresh',
10756
- builder: 'fresh',
10757
- critic: 'pending',
10758
- };
10447
+ catch {
10448
+ return null;
10759
10449
  }
10760
- // Default: fully fresh
10761
- return freshPhaseState();
10762
- }
10763
-
10764
- /**
10765
- * Corpus-wide phase scheduler.
10766
- *
10767
- * Selects the highest-priority ready phase across all metas.
10768
- * Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
10769
- * Tiebreak within band: weighted staleness (§3.9).
10770
- *
10771
- * @module phaseState/phaseScheduler
10772
- */
10773
- /**
10774
- * Build phase candidates from listMetas entries.
10775
- *
10776
- * Derives phase state and auto-retries failed phases for each entry.
10777
- * Used by orchestratePhase, queue route, and status route.
10778
- */
10779
- function buildPhaseCandidates(entries) {
10780
- return entries.map((entry) => ({
10781
- node: entry.node,
10782
- meta: entry.meta,
10783
- phaseState: retryAllFailed(derivePhaseState(entry.meta)),
10784
- actualStaleness: entry.stalenessSeconds,
10785
- locked: entry.locked,
10786
- disabled: entry.disabled,
10787
- }));
10788
- }
10789
- /**
10790
- * Rank all eligible phase candidates by priority.
10791
- *
10792
- * Filters to pending phases, computes effective staleness, and sorts by
10793
- * band (ascending: critic first) then effective staleness (descending).
10794
- *
10795
- * Used by selectPhaseCandidate (returns first) and the queue route (returns all).
10796
- */
10797
- function rankPhaseCandidates(metas, depthWeight) {
10798
- // Filter to metas with a pending (scheduler-eligible) phase
10799
- const eligible = metas.filter((m) => {
10800
- if (m.locked)
10801
- return false;
10802
- if (m.disabled && !m.isOverride)
10803
- return false;
10804
- const owed = getOwedPhase(m.phaseState);
10805
- if (!owed)
10806
- return false;
10807
- return m.phaseState[owed] === 'pending';
10808
- });
10809
- if (eligible.length === 0)
10810
- return [];
10811
- // Compute effective staleness for tiebreaking
10812
- const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
10813
- node: m.node,
10814
- meta: m.meta,
10815
- actualStaleness: m.actualStaleness,
10816
- })), depthWeight);
10817
- // Build candidates with band info
10818
- const candidates = withStaleness.map((ws, i) => {
10819
- const m = eligible[i];
10820
- const owedPhase = getOwedPhase(m.phaseState);
10821
- return {
10822
- node: ws.node,
10823
- meta: ws.meta,
10824
- phaseState: m.phaseState,
10825
- owedPhase,
10826
- band: getPriorityBand(m.phaseState),
10827
- actualStaleness: ws.actualStaleness,
10828
- effectiveStaleness: ws.effectiveStaleness,
10829
- };
10830
- });
10831
- // Sort by band (ascending = critic first) then effective staleness (descending)
10832
- candidates.sort((a, b) => {
10833
- if (a.band !== b.band)
10834
- return a.band - b.band;
10835
- return b.effectiveStaleness - a.effectiveStaleness;
10836
- });
10837
- return candidates;
10838
10450
  }
10839
10451
  /**
10840
- * Select the best phase candidate across the corpus.
10452
+ * Parse critic output. The critic returns evaluation text.
10841
10453
  *
10842
- * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
10843
- * @param depthWeight - Config depthWeight for staleness tiebreak.
10844
- * @returns The winning candidate, or null if no phase is ready.
10454
+ * @param output - Raw subprocess output.
10455
+ * @returns The feedback string.
10845
10456
  */
10846
- function selectPhaseCandidate(metas, depthWeight) {
10847
- return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10457
+ function parseCriticOutput(output) {
10458
+ return output.trim();
10848
10459
  }
10849
10460
 
10850
10461
  /**
@@ -11105,7 +10716,7 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
11105
10716
  if (metaResult.entries.length === 0)
11106
10717
  return { executed: false };
11107
10718
  // Build candidates with phase state (including invalidation + auto-retry)
11108
- const candidates = buildPhaseCandidates(metaResult.entries);
10719
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
11109
10720
  // Select best phase candidate
11110
10721
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
11111
10722
  if (!winner) {
@@ -11780,46 +11391,6 @@ function buildMetaRules(config) {
11780
11391
  },
11781
11392
  renderAs: 'md',
11782
11393
  },
11783
- {
11784
- name: 'meta-config',
11785
- description: 'jeeves-meta configuration file',
11786
- match: {
11787
- properties: {
11788
- file: {
11789
- properties: {
11790
- path: {
11791
- type: 'string',
11792
- glob: '**/jeeves-meta{.config.json,/config.json}',
11793
- },
11794
- },
11795
- },
11796
- },
11797
- },
11798
- schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
11799
- render: {
11800
- frontmatter: [
11801
- 'watcherUrl',
11802
- 'gatewayUrl',
11803
- 'architectEvery',
11804
- 'depthWeight',
11805
- 'maxArchive',
11806
- 'maxLines',
11807
- ],
11808
- body: [
11809
- {
11810
- path: 'json.defaultArchitect',
11811
- heading: 2,
11812
- label: 'Default Architect Prompt',
11813
- },
11814
- {
11815
- path: 'json.defaultCritic',
11816
- heading: 2,
11817
- label: 'Default Critic Prompt',
11818
- },
11819
- ],
11820
- },
11821
- renderAs: 'md',
11822
- },
11823
11394
  ];
11824
11395
  }
11825
11396
  /**
@@ -12063,13 +11634,15 @@ class Scheduler {
12063
11634
  queue;
12064
11635
  logger;
12065
11636
  watcher;
11637
+ cache;
12066
11638
  registrar = null;
12067
11639
  currentExpression;
12068
- constructor(config, queue, logger, watcher) {
11640
+ constructor(config, queue, logger, watcher, cache) {
12069
11641
  this.config = config;
12070
11642
  this.queue = queue;
12071
11643
  this.logger = logger;
12072
11644
  this.watcher = watcher;
11645
+ this.cache = cache;
12073
11646
  this.currentExpression = config.schedule;
12074
11647
  }
12075
11648
  /** Set the rule registrar for watcher restart detection. */
@@ -12186,8 +11759,8 @@ class Scheduler {
12186
11759
  */
12187
11760
  async discoverNextPhase() {
12188
11761
  try {
12189
- const result = await listMetas(this.config, this.watcher);
12190
- const candidates = buildPhaseCandidates(result.entries);
11762
+ const result = await this.cache.get(this.config, this.watcher);
11763
+ const candidates = buildPhaseCandidates(result.entries, this.config.architectEvery);
12191
11764
  const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
12192
11765
  if (!winner)
12193
11766
  return null;
@@ -12558,7 +12131,7 @@ function registerMetasUpdateRoute(app, deps) {
12558
12131
  const metaDir = resolveMetaDir(targetPath);
12559
12132
  let meta;
12560
12133
  try {
12561
- meta = (await readMetaJson(metaDir));
12134
+ meta = await readMetaJson(metaDir);
12562
12135
  }
12563
12136
  catch {
12564
12137
  return reply.status(404).send({
@@ -12612,11 +12185,11 @@ function registerMetasUpdateRoute(app, deps) {
12612
12185
  */
12613
12186
  function registerPreviewRoute(app, deps) {
12614
12187
  app.get('/preview', async (request, reply) => {
12615
- const { config, watcher } = deps;
12188
+ const { config, watcher, cache } = deps;
12616
12189
  const query = request.query;
12617
12190
  let result;
12618
12191
  try {
12619
- result = await listMetas(config, watcher);
12192
+ result = await cache.get(config, watcher);
12620
12193
  }
12621
12194
  catch {
12622
12195
  return reply.status(503).send({
@@ -12636,40 +12209,24 @@ function registerPreviewRoute(app, deps) {
12636
12209
  }
12637
12210
  }
12638
12211
  else {
12639
- // Select stalest candidate
12640
- const stale = result.entries
12641
- .filter((e) => e.stalenessSeconds > 0)
12642
- .map((e) => ({
12643
- node: e.node,
12644
- meta: e.meta,
12645
- actualStaleness: e.stalenessSeconds,
12646
- }));
12647
- const stalestPath = discoverStalestPath(stale, config.depthWeight);
12648
- if (!stalestPath) {
12212
+ // Select best phase candidate
12213
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
12214
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12215
+ if (!winner) {
12649
12216
  return { message: 'No stale metas found. Nothing to synthesize.' };
12650
12217
  }
12651
- targetNode = findNode(result.tree, stalestPath);
12218
+ targetNode = findNode(result.tree, winner.node.metaPath);
12652
12219
  }
12653
12220
  const meta = await readMetaJson(targetNode.metaPath);
12654
12221
  // Scope files
12655
12222
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12656
- const structureHash = computeStructureHash(scopeFiles);
12223
+ // Compute invalidation inputs (DRY: reuse phaseState/invalidate logic)
12224
+ const invalidation = await computeInvalidation(meta, scopeFiles, config, targetNode);
12225
+ const { architectInvalidators, stalenessInputs } = invalidation;
12226
+ const { structureHash } = invalidation;
12657
12227
  const structureChanged = structureHash !== meta._structureHash;
12658
- const latestArchive = await readLatestArchive(targetNode.metaPath);
12659
- const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
12660
- // _architect change detection
12661
- const architectChanged = latestArchive
12662
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
12663
- : Boolean(meta._architect);
12664
- // _crossRefs declaration change detection
12665
- const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
12666
- const archiveRefs = (latestArchive?._crossRefs ?? [])
12667
- .slice()
12668
- .sort()
12669
- .join(',');
12670
- const crossRefsDeclChanged = latestArchive
12671
- ? currentRefs !== archiveRefs
12672
- : currentRefs.length > 0;
12228
+ const { steerChanged } = invalidation;
12229
+ const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12673
12230
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12674
12231
  // Delta files
12675
12232
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
@@ -12694,30 +12251,6 @@ function registerPreviewRoute(app, deps) {
12694
12251
  });
12695
12252
  const owedPhase = getOwedPhase(phaseState);
12696
12253
  const priorityBand = getPriorityBand(phaseState);
12697
- // Architect invalidators
12698
- const architectInvalidators = [];
12699
- if (owedPhase === 'architect') {
12700
- if (structureChanged)
12701
- architectInvalidators.push('structureHash');
12702
- if (steerChanged)
12703
- architectInvalidators.push('steer');
12704
- if (architectChanged)
12705
- architectInvalidators.push('_architect');
12706
- if (crossRefsDeclChanged)
12707
- architectInvalidators.push('_crossRefs');
12708
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
12709
- architectInvalidators.push('architectEvery');
12710
- }
12711
- }
12712
- // Staleness inputs
12713
- const stalenessInputs = {
12714
- structureHash,
12715
- steerChanged,
12716
- architectChanged,
12717
- crossRefsDeclChanged,
12718
- scopeMtimeMax: null,
12719
- crossRefContentChanged: false,
12720
- };
12721
12254
  return {
12722
12255
  path: targetNode.metaPath,
12723
12256
  staleness: {
@@ -12791,8 +12324,8 @@ function registerQueueRoutes(app, deps) {
12791
12324
  // ranked by scheduler priority (computed on read, not persisted)
12792
12325
  let automatic = [];
12793
12326
  try {
12794
- const metaResult = await listMetas(deps.config, deps.watcher);
12795
- const candidates = buildPhaseCandidates(metaResult.entries);
12327
+ const metaResult = await deps.cache.get(deps.config, deps.watcher);
12328
+ const candidates = buildPhaseCandidates(metaResult.entries, deps.config.architectEvery);
12796
12329
  const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
12797
12330
  automatic = ranked.map((c) => ({
12798
12331
  path: c.node.metaPath,
@@ -12987,7 +12520,7 @@ function registerStatusRoute(app, deps) {
12987
12520
  name: SERVICE_NAME,
12988
12521
  version: SERVICE_VERSION,
12989
12522
  getHealth: async () => {
12990
- const { config, queue, scheduler, stats, watcher } = deps;
12523
+ const { config, queue, scheduler, stats, watcher, cache } = deps;
12991
12524
  // On-demand dependency checks
12992
12525
  const [watcherHealth, gatewayHealth] = await Promise.all([
12993
12526
  checkWatcher(config.watcherUrl),
@@ -13001,7 +12534,7 @@ function registerStatusRoute(app, deps) {
13001
12534
  };
13002
12535
  let nextPhase = null;
13003
12536
  try {
13004
- const metaResult = await listMetas(config, watcher);
12537
+ const metaResult = await cache.get(config, watcher);
13005
12538
  // Count raw phase states (before retry) for display
13006
12539
  for (const entry of metaResult.entries) {
13007
12540
  const ps = derivePhaseState(entry.meta);
@@ -13010,7 +12543,7 @@ function registerStatusRoute(app, deps) {
13010
12543
  }
13011
12544
  }
13012
12545
  // Build candidates (with auto-retry) for scheduling
13013
- const candidates = buildPhaseCandidates(metaResult.entries);
12546
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
13014
12547
  // Find next phase candidate
13015
12548
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
13016
12549
  if (winner) {
@@ -13073,7 +12606,7 @@ const synthesizeBodySchema = z.object({
13073
12606
  function registerSynthesizeRoute(app, deps) {
13074
12607
  app.post('/synthesize', async (request, reply) => {
13075
12608
  const body = synthesizeBodySchema.parse(request.body);
13076
- const { config, watcher, queue } = deps;
12609
+ const { config, watcher, queue, cache } = deps;
13077
12610
  if (body.path) {
13078
12611
  // Path-targeted trigger: create override entry
13079
12612
  const targetPath = resolveMetaDir(body.path);
@@ -13110,7 +12643,7 @@ function registerSynthesizeRoute(app, deps) {
13110
12643
  // Corpus-wide trigger: discover stalest candidate
13111
12644
  let result;
13112
12645
  try {
13113
- result = await listMetas(config, watcher);
12646
+ result = await cache.get(config, watcher);
13114
12647
  }
13115
12648
  catch {
13116
12649
  return reply.status(503).send({
@@ -13118,20 +12651,15 @@ function registerSynthesizeRoute(app, deps) {
13118
12651
  message: 'Watcher unreachable — cannot discover candidates',
13119
12652
  });
13120
12653
  }
13121
- const stale = result.entries
13122
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
13123
- .map((e) => ({
13124
- node: e.node,
13125
- meta: e.meta,
13126
- actualStaleness: e.stalenessSeconds,
13127
- }));
13128
- const stalest = discoverStalestPath(stale, config.depthWeight);
13129
- if (!stalest) {
12654
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
12655
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12656
+ if (!winner) {
13130
12657
  return reply.code(200).send({
13131
12658
  status: 'skipped',
13132
12659
  message: 'No stale metas found. Nothing to synthesize.',
13133
12660
  });
13134
12661
  }
12662
+ const stalest = winner.node.metaPath;
13135
12663
  const enqueueResult = queue.enqueue(stalest);
13136
12664
  return reply.code(202).send({
13137
12665
  status: 'accepted',
@@ -13232,6 +12760,18 @@ function createServer(options) {
13232
12760
  // Fastify 5 requires `loggerInstance` for external pino loggers
13233
12761
  const app = Fastify({
13234
12762
  loggerInstance: options.logger,
12763
+ requestTimeout: 30_000,
12764
+ });
12765
+ // Readiness gate: return 503 while service is initializing
12766
+ app.addHook('onRequest', async (request, reply) => {
12767
+ if (options.deps.ready)
12768
+ return;
12769
+ const url = request.url;
12770
+ if (url === '/config' || url.startsWith('/config/apply'))
12771
+ return;
12772
+ return reply
12773
+ .status(503)
12774
+ .send({ status: 'starting', message: 'Service initializing' });
13235
12775
  });
13236
12776
  registerRoutes(app, options.deps);
13237
12777
  return app;
@@ -13407,8 +12947,9 @@ async function startService(config, configPath) {
13407
12947
  lastCycleAt: null,
13408
12948
  };
13409
12949
  const queue = new SynthesisQueue(logger);
12950
+ const cache = new MetaCache();
13410
12951
  // Scheduler (needs watcher for discovery)
13411
- const scheduler = new Scheduler(config, queue, logger, watcher);
12952
+ const scheduler = new Scheduler(config, queue, logger, watcher, cache);
13412
12953
  const routeDeps = {
13413
12954
  config,
13414
12955
  logger,
@@ -13416,6 +12957,8 @@ async function startService(config, configPath) {
13416
12957
  watcher,
13417
12958
  scheduler,
13418
12959
  stats,
12960
+ cache,
12961
+ ready: false,
13419
12962
  executor,
13420
12963
  configPath,
13421
12964
  };
@@ -13466,6 +13009,9 @@ async function startService(config, configPath) {
13466
13009
  }
13467
13010
  await progress.report(evt);
13468
13011
  }, logger);
13012
+ // Invalidate cache only when a phase was actually executed
13013
+ if (result.executed)
13014
+ cache.invalidate();
13469
13015
  const durationMs = Date.now() - startMs;
13470
13016
  if (!result.executed) {
13471
13017
  logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
@@ -13529,9 +13075,13 @@ async function startService(config, configPath) {
13529
13075
  scheduler.setRegistrar(registrar);
13530
13076
  routeDeps.registrar = registrar;
13531
13077
  void registrar.register().then(() => {
13078
+ routeDeps.ready = true;
13532
13079
  if (registrar.isRegistered) {
13533
13080
  void verifyRuleApplication(watcher, logger);
13534
13081
  }
13082
+ }, () => {
13083
+ // Registration failed after max retries — mark ready anyway
13084
+ routeDeps.ready = true;
13535
13085
  });
13536
13086
  // Periodic watcher health check (independent of scheduler)
13537
13087
  const healthCheck = new WatcherHealthCheck({
@@ -13578,11 +13128,11 @@ async function startService(config, configPath) {
13578
13128
  * Parsed jeeves-meta component descriptor.
13579
13129
  */
13580
13130
  const metaDescriptor = jeevesComponentDescriptorSchema.parse({
13581
- name: 'meta',
13131
+ name: META_COMPONENT.name,
13582
13132
  version: SERVICE_VERSION,
13583
- servicePackage: '@karmaniverous/jeeves-meta',
13584
- pluginPackage: '@karmaniverous/jeeves-meta-openclaw',
13585
- defaultPort: 1938,
13133
+ servicePackage: META_COMPONENT.servicePackage,
13134
+ pluginPackage: META_COMPONENT.pluginPackage,
13135
+ defaultPort: META_COMPONENT.defaultPort,
13586
13136
  // The runtime Zod custom validator only checks for a .parse() method.
13587
13137
  // Use unknown cast to bridge the Zod v4 (service) → v3 (core SDK) type gap.
13588
13138
  configSchema: serviceConfigSchema,
@@ -13613,4 +13163,121 @@ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
13613
13163
  customCliCommands: registerCustomCliCommands,
13614
13164
  });
13615
13165
 
13616
- export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, MAX_STALENESS_SECONDS, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaDescriptor, metaErrorSchema, metaJsonSchema, migrateConfigPath, normalizePath, orchestrate, orchestratePhase, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, runArchitect, runBuilder, runCritic, selectCandidate, serviceConfigSchema, sleepAsync as sleep, startService, toMetaError, verifyRuleApplication };
13166
+ /**
13167
+ * Exponential moving average helper for token tracking.
13168
+ *
13169
+ * @module ema
13170
+ */
13171
+ const DEFAULT_DECAY = 0.3;
13172
+ /**
13173
+ * Compute exponential moving average.
13174
+ *
13175
+ * @param current - New observation.
13176
+ * @param previous - Previous EMA value, or undefined for first observation.
13177
+ * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
13178
+ * @returns Updated EMA.
13179
+ */
13180
+ function computeEma(current, previous, decay = DEFAULT_DECAY) {
13181
+ if (previous === undefined)
13182
+ return current;
13183
+ return decay * current + (1 - decay) * previous;
13184
+ }
13185
+
13186
+ /**
13187
+ * Zod schema for .meta/meta.json files.
13188
+ *
13189
+ * Reserved properties are underscore-prefixed and engine-managed.
13190
+ * All other keys are open schema (builder output).
13191
+ *
13192
+ * @module schema/meta
13193
+ */
13194
+ /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
13195
+ const metaJsonSchema = z
13196
+ .object({
13197
+ /** Stable identity. Auto-generated on first synthesis if not provided. */
13198
+ _id: z.uuid().optional(),
13199
+ /** Human-provided steering prompt. Optional. */
13200
+ _steer: z.string().optional(),
13201
+ /**
13202
+ * Explicit cross-references to other meta owner paths.
13203
+ * Referenced metas' _content is included as architect/builder context.
13204
+ */
13205
+ _crossRefs: z.array(z.string()).optional(),
13206
+ /** Architect system prompt used this turn. Defaults from config. */
13207
+ _architect: z.string().optional(),
13208
+ /**
13209
+ * Task brief generated by the architect. Cached and reused across cycles;
13210
+ * regenerated only when triggered.
13211
+ */
13212
+ _builder: z.string().optional(),
13213
+ /** Critic system prompt used this turn. Defaults from config. */
13214
+ _critic: z.string().optional(),
13215
+ /** Timestamp of last synthesis. ISO 8601. */
13216
+ _generatedAt: z.iso.datetime().optional(),
13217
+ /** Narrative synthesis output. Rendered by watcher for embedding. */
13218
+ _content: z.string().optional(),
13219
+ /**
13220
+ * Hash of sorted file listing in scope. Detects directory structure
13221
+ * changes that trigger an architect re-run.
13222
+ */
13223
+ _structureHash: z.string().optional(),
13224
+ /**
13225
+ * Cycles since last architect run. Reset to 0 when architect runs.
13226
+ * Used with architectEvery to trigger periodic re-prompting.
13227
+ */
13228
+ _synthesisCount: z.number().int().min(0).optional(),
13229
+ /** Critic evaluation of the last synthesis. */
13230
+ _feedback: z.string().optional(),
13231
+ /**
13232
+ * Present and true on archive snapshots. Distinguishes live vs. archived
13233
+ * metas.
13234
+ */
13235
+ _archived: z.boolean().optional(),
13236
+ /** Timestamp when this snapshot was archived. ISO 8601. */
13237
+ _archivedAt: z.iso.datetime().optional(),
13238
+ /**
13239
+ * Scheduling priority. Higher = updates more often. Negative allowed;
13240
+ * normalized to min 0 at scheduling time.
13241
+ */
13242
+ _depth: z.number().optional(),
13243
+ /**
13244
+ * Emphasis multiplier for depth weighting in scheduling.
13245
+ * Default 1. Higher values increase this meta's scheduling priority
13246
+ * relative to its depth. Set to 0.5 to halve the depth effect,
13247
+ * 2 to double it, 0 to ignore depth entirely for this meta.
13248
+ */
13249
+ _emphasis: z.number().min(0).optional(),
13250
+ /** Token count from last architect subprocess call. */
13251
+ _architectTokens: z.number().int().optional(),
13252
+ /** Token count from last builder subprocess call. */
13253
+ _builderTokens: z.number().int().optional(),
13254
+ /** Token count from last critic subprocess call. */
13255
+ _criticTokens: z.number().int().optional(),
13256
+ /** Exponential moving average of architect token usage (decay 0.3). */
13257
+ _architectTokensAvg: z.number().optional(),
13258
+ /** Exponential moving average of builder token usage (decay 0.3). */
13259
+ _builderTokensAvg: z.number().optional(),
13260
+ /** Exponential moving average of critic token usage (decay 0.3). */
13261
+ _criticTokensAvg: z.number().optional(),
13262
+ /**
13263
+ * Opaque state carried across synthesis cycles for progressive work.
13264
+ * Set by the builder, passed back as context on next cycle.
13265
+ */
13266
+ _state: z.unknown().optional(),
13267
+ /**
13268
+ * Structured error from last cycle. Present when a step failed.
13269
+ * Cleared on successful cycle.
13270
+ */
13271
+ _error: metaErrorSchema.optional(),
13272
+ /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
13273
+ _disabled: z.boolean().optional(),
13274
+ /**
13275
+ * Per-phase state machine record. Engine-managed.
13276
+ * Keyed by phase name (architect, builder, critic) with status values.
13277
+ * Persisted to survive ticks; derived on first load for back-compat.
13278
+ */
13279
+ _phaseState: phaseStateSchema.optional(),
13280
+ })
13281
+ .loose();
13282
+
13283
+ export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, MAX_STALENESS_SECONDS, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, metaConfigSchema, metaDescriptor, metaErrorSchema, metaJsonSchema, migrateConfigPath, normalizePath, orchestratePhase, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, runArchitect, runBuilder, runCritic, serviceConfigSchema, sleepAsync as sleep, startService, toMetaError, verifyRuleApplication };