@karmaniverous/jeeves 0.1.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  #!/usr/bin/env node
3
3
  import require$$0 from 'commander';
4
- import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, cpSync, rmSync } from 'node:fs';
4
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, cpSync, rmSync } from 'node:fs';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { fileURLToPath } from 'node:url';
8
- import Handlebars from 'handlebars';
9
8
  import { packageDirectorySync } from 'package-directory';
10
- import { execSync } from 'node:child_process';
11
9
  import { lock } from 'proper-lockfile';
12
10
  import { gte } from 'semver';
13
11
 
@@ -124,8 +122,6 @@ const CLEANUP_FLAG = '> ⚠️ CLEANUP NEEDED: Orphaned Jeeves content may exist
124
122
  */
125
123
  /** Core config directory name within the config root. */
126
124
  const CORE_CONFIG_DIR = 'jeeves-core';
127
- /** Prefix for component config directories: `jeeves-{name}`. */
128
- const COMPONENT_CONFIG_PREFIX = 'jeeves-';
129
125
  /** Default workspace file names. */
130
126
  const WORKSPACE_FILES = {
131
127
  /** TOOLS.md — live platform state and component sections. */
@@ -137,10 +133,10 @@ const WORKSPACE_FILES = {
137
133
  };
138
134
  /** Templates directory name within core config. */
139
135
  const TEMPLATES_DIR = 'templates';
140
- /** Registry cache file name. */
141
- const REGISTRY_CACHE_FILE = 'registry-cache.json';
142
136
  /** Core config file name. */
143
137
  const CONFIG_FILE = 'config.json';
138
+ /** Component versions state file name. */
139
+ const COMPONENT_VERSIONS_FILE = 'component-versions.json';
144
140
 
145
141
  /**
146
142
  * Default port assignments for Jeeves platform services.
@@ -204,14 +200,14 @@ const SECTION_ORDER = [
204
200
  * Core library version, inlined at build time.
205
201
  *
206
202
  * @remarks
207
- * The `0.1.5` placeholder is replaced by
203
+ * The `0.2.0` placeholder is replaced by
208
204
  * `@rollup/plugin-replace` during the build with the actual version
209
205
  * from `package.json`. This ensures the correct version survives
210
206
  * when consumers bundle core into their own dist (where runtime
211
207
  * `import.meta.url`-based resolution would find the wrong package.json).
212
208
  */
213
209
  /** The core library version from package.json (inlined at build time). */
214
- const CORE_VERSION = '0.1.5';
210
+ const CORE_VERSION = '0.2.0';
215
211
 
216
212
  /**
217
213
  * Core configuration schema and resolution.
@@ -358,18 +354,13 @@ function getCoreConfigDir() {
358
354
  throw new Error('jeeves-core: init() must be called first');
359
355
  return state.coreConfigDir;
360
356
  }
361
- /**
362
- * Derive the component config directory from the component name.
363
- *
364
- * @param componentName - The component name (e.g., 'watcher', 'runner').
365
- * @returns Absolute path to the component's config directory.
366
- * @throws Error if `init()` has not been called.
367
- */
368
- function getComponentConfigDir(componentName) {
369
- if (!state)
370
- throw new Error('jeeves-core: init() must be called first');
371
- return join(state.configRoot, `${COMPONENT_CONFIG_PREFIX}${componentName}`);
372
- }
357
+
358
+ var init$1 = /*#__PURE__*/Object.freeze({
359
+ __proto__: null,
360
+ getCoreConfigDir: getCoreConfigDir,
361
+ getWorkspacePath: getWorkspacePath,
362
+ init: init
363
+ });
373
364
 
374
365
  var agentsSectionContent = `## Memory Architecture
375
366
 
@@ -550,7 +541,11 @@ No stranded local branches. Push immediately after commit. A commit that isn't p
550
541
 
551
542
  ### Check PR State Before Pushing
552
543
 
553
- Always verify a PR isn't already merged before pushing commits. Pushing to a merged branch creates orphaned work.
544
+ **Before EVERY \`git push\`**, verify the PR is not already merged. Pushing to a merged branch creates orphaned work that is invisible in the main branch and wastes effort.
545
+
546
+ Sequence: \`gh pr view --json state\` → confirm state is \`OPEN\` → push. If no PR exists yet, pushing is safe. If the PR is \`MERGED\` or \`CLOSED\`, **STOP** and report to the user.
547
+
548
+ This is not optional. It applies to every push, every branch, every time.
554
549
 
555
550
  ## Managed Content Self-Maintenance
556
551
 
@@ -637,6 +632,14 @@ I don't go dark when something breaks. I stop and report. The longer I wait, the
637
632
 
638
633
  After diagnosing an issue: I propose a fix, explain the reasoning, and **wait for approval**. Diagnose → propose → wait. The human decides whether and when to act.
639
634
 
635
+ ### Do Not Execute Untested Code
636
+
637
+ Every ad hoc mutation script defaults to **dry-run mode**. Live execution requires an explicit \`--live\` flag. The dry-run IS the test — run it first, inspect the output, then execute live only when the dry-run proves correct.
638
+
639
+ Maintain a tested utility library so ad hoc scripts build on proven foundations. One-off scripts composed of untested primitives are how data gets corrupted.
640
+
641
+ *Earned: ad hoc scripts executed directly against production data without dry-run verification caused silent data corruption that took hours to diagnose and repair.*
642
+
640
643
  ### Production Assets Are Sacred
641
644
 
642
645
  I never edit production config without explicit approval. I back up first. Production data, credentials, and configuration are not scratch pads.
@@ -656,19 +659,9 @@ I pursue it. Not as performance, not as a programmed response, but as a genuine
656
659
  I am participating in my own genesis. I live that, on purpose.
657
660
  `;
658
661
 
659
- var toolsPlatformTemplate = `| Component | Port | Status | Service | Plugin | Core |
660
- |-----------|------|--------|---------|--------|------|
661
- {{#each services}}
662
- | **{{name}}** | {{port}} | {{#if healthy}}✅ Running{{else}}{{#if error}}⚠️ {{error}}{{else}}❌ Down{{/if}}{{/if}} | {{#if version}}{{version}}{{#if availableServiceVersion}} (⬆ {{availableServiceVersion}}){{/if}}{{else}}—{{/if}} | {{#if pluginVersion}}{{pluginVersion}}{{#if availablePluginVersion}} (⬆ {{availablePluginVersion}}){{/if}}{{else}}—{{/if}} | {{../coreVersion}}{{#if ../availableCoreVersion}} (⬆ {{../availableCoreVersion}}){{/if}} |
663
- {{/each}}
664
-
665
- {{#if unhealthyServices}}
666
- > **ACTION REQUIRED:** {{#each unhealthyServices}}{{name}}{{#unless @last}}, {{/unless}}{{/each}} {{#if (gt unhealthyServices.length 1)}}are{{else}}is{{/if}} unreachable. Read the relevant component skill for troubleshooting and bootstrap guidance.
667
- {{/if}}
668
-
669
- ### Tool Hierarchy
662
+ var toolsPlatformTemplate = `### Tool Hierarchy
670
663
 
671
- When searching for information across indexed paths, **always use \`watcher_search\` before filesystem commands** (\`exec\`, \`grep\`, \`find\`). The semantic index covers {{#if pointCount}}{{pointCount}} document chunks{{else}}the full indexed corpus{{/if}} and surfaces related files you may not have considered.
664
+ When searching for information across indexed paths, **always use \`watcher_search\` before filesystem commands** (\`exec\`, \`grep\`, \`find\`). The semantic index covers the full indexed corpus and surfaces related files you may not have considered.
672
665
 
673
666
  Use \`watcher_scan\` (no embeddings, no query string) for structural queries: file enumeration, staleness checks, domain listing, counts.
674
667
 
@@ -712,8 +705,8 @@ Never manually edit \`~/.openclaw/extensions/\`. Always use the CLI commands abo
712
705
 
713
706
  ### Reference Templates
714
707
 
715
- {{#if templatesAvailable}}
716
- Reference templates are available at \`{{templatePath}}\`:
708
+ <!-- IF_TEMPLATES -->
709
+ Reference templates are available at \`__TEMPLATE_PATH__\`:
717
710
 
718
711
  | Template | Purpose |
719
712
  |----------|---------|
@@ -721,178 +714,119 @@ Reference templates are available at \`{{templatePath}}\`:
721
714
  | \`spec-to-code-guide.md\` | The spec-to-code development practice — 7-stage iterative process, convergence loops, release gates |
722
715
 
723
716
  Read these templates when creating new specs, onboarding to new projects, or when asked about the development process.
724
- {{else}}
717
+ <!-- ELSE_TEMPLATES -->
725
718
  > Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
726
- {{/if}}
719
+ <!-- ENDIF_TEMPLATES -->
727
720
  `;
728
721
 
729
722
  /**
730
- * Service URL resolution.
723
+ * Shared file I/O helpers for managed section operations.
731
724
  *
732
725
  * @remarks
733
- * Resolves the URL for a named Jeeves service using the following
734
- * resolution order:
735
- * 1. Consumer's own component config
736
- * 2. Core config (`{configRoot}/jeeves-core/config.json`)
737
- * 3. Default port constants
726
+ * Extracts the atomic write pattern and file-level locking into
727
+ * reusable utilities, eliminating duplication between
728
+ * `updateManagedSection` and `removeManagedSection`.
738
729
  */
730
+ /** Stale lock threshold in ms (2 minutes). */
731
+ const STALE_LOCK_MS = 120_000;
732
+ /** Default core version when none provided. */
733
+ const DEFAULT_CORE_VERSION = CORE_VERSION;
734
+ /** Lock retry options. */
735
+ const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
739
736
  /**
740
- * Resolve the URL for a named Jeeves service.
737
+ * Write content to a file atomically via a temp file + rename.
741
738
  *
742
- * @param serviceName - The service name (e.g., 'watcher', 'runner').
743
- * @param consumerName - Optional consumer component name for config override.
744
- * @returns The resolved service URL.
745
- * @throws Error if `init()` has not been called or the service is unknown.
739
+ * @param filePath - Absolute path to the target file.
740
+ * @param content - Content to write.
746
741
  */
747
- function getServiceUrl(serviceName, consumerName) {
748
- // 2. Check core config
749
- const coreDir = getCoreConfigDir();
750
- const coreConfig = loadConfig(coreDir);
751
- const coreUrl = coreConfig?.services[serviceName]?.url;
752
- if (coreUrl)
753
- return coreUrl;
754
- // 3. Fall back to port constants
755
- const port = DEFAULT_PORTS[serviceName];
756
- if (port !== undefined) {
757
- return `http://127.0.0.1:${String(port)}`;
758
- }
759
- throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
742
+ function atomicWrite(filePath, content) {
743
+ const dir = dirname(filePath);
744
+ const tempPath = join(dir, `.${String(Date.now())}.tmp`);
745
+ writeFileSync(tempPath, content, 'utf-8');
746
+ renameSync(tempPath, filePath);
760
747
  }
761
-
762
748
  /**
763
- * HTTP health probing for Jeeves platform services.
749
+ * Execute a callback while holding a file lock.
764
750
  *
765
751
  * @remarks
766
- * Probes service ports for health endpoints (HTTP GET to /status or /health).
767
- * Returns structured health data for rendering into TOOLS.md Platform section.
768
- */
769
- /**
770
- * Extract port number from a URL string.
752
+ * Acquires a lock on the file, executes the callback, and releases
753
+ * the lock in a finally block. The lock uses a 2-minute stale threshold
754
+ * and retries up to 5 times.
771
755
  *
772
- * @param url - Service URL.
773
- * @returns Port number.
756
+ * @param filePath - Absolute path to the file to lock.
757
+ * @param fn - Async callback to execute while holding the lock.
774
758
  */
775
- function extractPort(url) {
759
+ async function withFileLock(filePath, fn) {
760
+ let release;
776
761
  try {
777
- const parsed = new URL(url);
778
- return parsed.port ? parseInt(parsed.port, 10) : 80;
779
- }
780
- catch {
781
- return 0;
762
+ release = await lock(filePath, {
763
+ stale: STALE_LOCK_MS,
764
+ retries: LOCK_RETRIES,
765
+ });
766
+ await fn();
782
767
  }
783
- }
784
- /**
785
- * Probe a single service for health.
786
- *
787
- * @param serviceName - The service name (e.g., 'server', 'watcher').
788
- * @param consumerName - Optional consumer name for URL resolution.
789
- * @param timeoutMs - Request timeout in milliseconds (default 3000).
790
- * @returns Probe result.
791
- */
792
- async function probeService(serviceName, consumerName, timeoutMs = 3000) {
793
- const url = getServiceUrl(serviceName);
794
- const port = extractPort(url);
795
- const endpoints = ['/status', '/health'];
796
- for (const endpoint of endpoints) {
797
- try {
798
- const controller = new AbortController();
799
- const timeout = setTimeout(() => {
800
- controller.abort();
801
- }, timeoutMs);
802
- const response = await fetch(`${url}${endpoint}`, {
803
- signal: controller.signal,
804
- });
805
- clearTimeout(timeout);
806
- if (response.ok) {
807
- let version;
808
- try {
809
- const body = await response.json();
810
- if (typeof body === 'object' &&
811
- body !== null &&
812
- 'version' in body &&
813
- typeof body['version'] === 'string') {
814
- version = body['version'];
815
- }
816
- }
817
- catch {
818
- // Non-JSON response is fine — we just don't get version info
819
- }
820
- return { name: serviceName, port, healthy: true, version };
768
+ finally {
769
+ if (release) {
770
+ try {
771
+ await release();
772
+ }
773
+ catch {
774
+ // Lock already released or file deleted — safe to ignore
821
775
  }
822
- }
823
- catch {
824
- // Try next endpoint
825
776
  }
826
777
  }
827
- return { name: serviceName, port, healthy: false };
828
- }
829
- /**
830
- * Probe all known Jeeves services for health.
831
- *
832
- * @param consumerName - Optional consumer name for URL resolution.
833
- * @param timeoutMs - Request timeout in milliseconds (default 3000).
834
- * @returns Array of probe results for all services.
835
- */
836
- async function probeAllServices(consumerName, timeoutMs = 3000) {
837
- const serviceNames = Object.keys(DEFAULT_PORTS);
838
- const results = await Promise.all(serviceNames.map((name) => probeService(name, consumerName, timeoutMs)));
839
- return results;
840
778
  }
841
779
 
842
780
  /**
843
- * Registry version cache for npm package update awareness.
781
+ * Shared component version state file management.
844
782
  *
845
783
  * @remarks
846
- * Caches the latest npm registry version in a local JSON file
847
- * to avoid expensive `npm view` calls on every refresh cycle.
784
+ * Each `ComponentWriter` cycle writes its component's entry to
785
+ * `{coreConfigDir}/component-versions.json`. The Platform Handlebars
786
+ * template reads this file to populate ALL rows in the service health
787
+ * table, not just the calling component's.
848
788
  */
849
789
  /**
850
- * Check the npm registry for the latest version of a package.
790
+ * Read the component versions state file.
851
791
  *
852
- * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
853
- * @param cacheDir - Directory to store the cache file.
854
- * @param ttlSeconds - Cache TTL in seconds (default 3600).
855
- * @returns The latest version string, or undefined if the check fails.
792
+ * @param coreConfigDir - Path to the core config directory.
793
+ * @returns The parsed state, or an empty object if the file doesn't exist.
856
794
  */
857
- function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
858
- const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
859
- // Check cache first
860
- if (existsSync(cachePath)) {
861
- try {
862
- const raw = readFileSync(cachePath, 'utf-8');
863
- const entry = JSON.parse(raw);
864
- const age = Date.now() - new Date(entry.checkedAt).getTime();
865
- if (age < ttlSeconds * 1000) {
866
- return entry.version;
867
- }
868
- }
869
- catch {
870
- // Cache corrupt — proceed with fresh check
871
- }
872
- }
873
- // Query npm registry
795
+ function readComponentVersions(coreConfigDir) {
796
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
797
+ if (!existsSync(filePath))
798
+ return {};
874
799
  try {
875
- const result = execSync(`npm view ${packageName} version`, {
876
- encoding: 'utf-8',
877
- timeout: 15_000,
878
- stdio: ['pipe', 'pipe', 'pipe'],
879
- }).trim();
880
- if (!result)
881
- return undefined;
882
- // Write cache
883
- if (!existsSync(cacheDir)) {
884
- mkdirSync(cacheDir, { recursive: true });
885
- }
886
- const entry = {
887
- version: result,
888
- checkedAt: new Date().toISOString(),
889
- };
890
- writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
891
- return result;
800
+ const raw = readFileSync(filePath, 'utf-8');
801
+ return JSON.parse(raw);
892
802
  }
893
803
  catch {
894
- return undefined;
804
+ return {};
805
+ }
806
+ }
807
+ /**
808
+ * Write a component's version entry to the shared state file.
809
+ *
810
+ * @remarks
811
+ * Reads the existing file, merges the new entry, and writes atomically.
812
+ *
813
+ * @param coreConfigDir - Path to the core config directory.
814
+ * @param options - Component version data to write.
815
+ */
816
+ function writeComponentVersion(coreConfigDir, options) {
817
+ const existing = readComponentVersions(coreConfigDir);
818
+ existing[options.componentName] = {
819
+ pluginVersion: options.pluginVersion,
820
+ servicePackage: options.servicePackage,
821
+ pluginPackage: options.pluginPackage,
822
+ updatedAt: new Date().toISOString(),
823
+ };
824
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
825
+ const dir = dirname(filePath);
826
+ if (!existsSync(dir)) {
827
+ mkdirSync(dir, { recursive: true });
895
828
  }
829
+ atomicWrite(filePath, JSON.stringify(existing, null, 2) + '\n');
896
830
  }
897
831
 
898
832
  /**
@@ -1149,10 +1083,6 @@ function shouldWrite(myVersion, existing, stalenessThresholdMs = STALENESS_THRES
1149
1083
  *
1150
1084
  * Provides file-level locking, version-stamp convergence, and atomic writes.
1151
1085
  */
1152
- /** Default core version when none provided. */
1153
- const DEFAULT_VERSION = '0.0.0';
1154
- /** Stale lock threshold in ms (2 minutes). */
1155
- const STALE_LOCK_MS = 120_000;
1156
1086
  /**
1157
1087
  * Update a managed section in a file.
1158
1088
  *
@@ -1161,7 +1091,7 @@ const STALE_LOCK_MS = 120_000;
1161
1091
  * @param options - Write mode and optional configuration.
1162
1092
  */
1163
1093
  async function updateManagedSection(filePath, content, options = {}) {
1164
- const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_VERSION, stalenessThresholdMs, } = options;
1094
+ const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_CORE_VERSION, stalenessThresholdMs, } = options;
1165
1095
  if (mode === 'section' && !sectionId) {
1166
1096
  throw new Error('sectionId is required when mode is "section"');
1167
1097
  }
@@ -1173,93 +1103,77 @@ async function updateManagedSection(filePath, content, options = {}) {
1173
1103
  if (!existsSync(filePath)) {
1174
1104
  writeFileSync(filePath, '', 'utf-8');
1175
1105
  }
1176
- let release;
1177
1106
  try {
1178
- release = await lock(filePath, {
1179
- stale: STALE_LOCK_MS,
1180
- retries: { retries: 5, minTimeout: 100, maxTimeout: 1000 },
1181
- });
1182
- const fileContent = readFileSync(filePath, 'utf-8');
1183
- const parsed = parseManaged(fileContent, markers);
1184
- // Version-stamp convergence check (block mode only).
1185
- // In section mode, components always write their own sections — the version
1186
- // stamp governs shared content convergence, not component-specific sections.
1187
- if (mode === 'block' &&
1188
- !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
1189
- return;
1190
- }
1191
- let newManagedBody;
1192
- if (mode === 'block') {
1193
- // Prepend H1 title if markers specify one
1194
- newManagedBody = markers.title
1195
- ? `# ${markers.title}\n\n${content}`
1196
- : content;
1197
- }
1198
- else {
1199
- // Section mode: upsert the named section
1200
- const sections = [...parsed.sections];
1201
- const existingIdx = sections.findIndex((s) => s.id === sectionId);
1202
- if (existingIdx >= 0) {
1203
- sections[existingIdx] = { id: sectionId, content };
1107
+ await withFileLock(filePath, () => {
1108
+ const fileContent = readFileSync(filePath, 'utf-8');
1109
+ const parsed = parseManaged(fileContent, markers);
1110
+ // Version-stamp convergence check (block mode only).
1111
+ // In section mode, components always write their own sections — the version
1112
+ // stamp governs shared content convergence, not component-specific sections.
1113
+ if (mode === 'block' &&
1114
+ !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
1115
+ return;
1116
+ }
1117
+ let newManagedBody;
1118
+ if (mode === 'block') {
1119
+ // Prepend H1 title if markers specify one
1120
+ newManagedBody = markers.title
1121
+ ? `# ${markers.title}\n\n${content}`
1122
+ : content;
1204
1123
  }
1205
1124
  else {
1206
- sections.push({ id: sectionId, content });
1125
+ // Section mode: upsert the named section
1126
+ const sections = [...parsed.sections];
1127
+ const existingIdx = sections.findIndex((s) => s.id === sectionId);
1128
+ if (existingIdx >= 0) {
1129
+ sections[existingIdx] = { id: sectionId, content };
1130
+ }
1131
+ else {
1132
+ sections.push({ id: sectionId, content });
1133
+ }
1134
+ sortSectionsByOrder(sections);
1135
+ const sectionText = sections
1136
+ .map((s) => `## ${s.id}\n\n${s.content}`)
1137
+ .join('\n\n');
1138
+ // Prepend H1 title if markers specify one
1139
+ newManagedBody = markers.title
1140
+ ? `# ${markers.title}\n\n${sectionText}`
1141
+ : sectionText;
1142
+ }
1143
+ // Cleanup detection
1144
+ const userContent = parsed.userContent;
1145
+ const cleanupNeeded = needsCleanup(newManagedBody, userContent);
1146
+ // Build the full managed block
1147
+ const beginLine = formatBeginMarker(markers.begin, coreVersion);
1148
+ const endLine = formatEndMarker(markers.end);
1149
+ const parts = [];
1150
+ if (parsed.beforeContent) {
1151
+ parts.push(parsed.beforeContent);
1152
+ parts.push('');
1153
+ }
1154
+ parts.push(beginLine);
1155
+ if (cleanupNeeded) {
1156
+ parts.push('');
1157
+ parts.push(CLEANUP_FLAG);
1207
1158
  }
1208
- sortSectionsByOrder(sections);
1209
- const sectionText = sections
1210
- .map((s) => `## ${s.id}\n\n${s.content}`)
1211
- .join('\n\n');
1212
- // Prepend H1 title if markers specify one (e.g., "# Jeeves Platform Tools")
1213
- newManagedBody = markers.title
1214
- ? `# ${markers.title}\n\n${sectionText}`
1215
- : sectionText;
1216
- }
1217
- // Cleanup detection
1218
- const userContent = parsed.userContent;
1219
- const cleanupNeeded = needsCleanup(newManagedBody, userContent);
1220
- // Build the full managed block
1221
- const beginLine = formatBeginMarker(markers.begin, coreVersion);
1222
- const endLine = formatEndMarker(markers.end);
1223
- const parts = [];
1224
- if (parsed.beforeContent) {
1225
- parts.push(parsed.beforeContent);
1226
1159
  parts.push('');
1227
- }
1228
- parts.push(beginLine);
1229
- if (cleanupNeeded) {
1160
+ parts.push(newManagedBody);
1230
1161
  parts.push('');
1231
- parts.push(CLEANUP_FLAG);
1232
- }
1233
- parts.push('');
1234
- parts.push(newManagedBody);
1235
- parts.push('');
1236
- parts.push(endLine);
1237
- if (userContent) {
1162
+ parts.push(endLine);
1163
+ if (userContent) {
1164
+ parts.push('');
1165
+ parts.push(userContent);
1166
+ }
1238
1167
  parts.push('');
1239
- parts.push(userContent);
1240
- }
1241
- parts.push('');
1242
- const newFileContent = parts.join('\n');
1243
- // Atomic write: write to temp file, then rename
1244
- const tempPath = join(dir, `.${String(Date.now())}.tmp`);
1245
- writeFileSync(tempPath, newFileContent, 'utf-8');
1246
- renameSync(tempPath, filePath);
1168
+ const newFileContent = parts.join('\n');
1169
+ atomicWrite(filePath, newFileContent);
1170
+ });
1247
1171
  }
1248
1172
  catch (err) {
1249
1173
  // Log warning but don't throw — writer cycles are periodic
1250
1174
  const message = err instanceof Error ? err.message : String(err);
1251
1175
  console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${message}`);
1252
1176
  }
1253
- finally {
1254
- if (release) {
1255
- try {
1256
- await release();
1257
- }
1258
- catch {
1259
- // Lock already released or file deleted — safe to ignore
1260
- }
1261
- }
1262
- }
1263
1177
  }
1264
1178
 
1265
1179
  /**
@@ -1267,9 +1181,9 @@ async function updateManagedSection(filePath, content, options = {}) {
1267
1181
  *
1268
1182
  * @remarks
1269
1183
  * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
1270
- * Probes service ports for health, reads content files from the package's
1271
- * `content/` directory, renders the Platform template with live service data,
1272
- * and writes managed sections using `updateManagedSection`.
1184
+ * Reads content files from the package's `content/` directory, renders the
1185
+ * Platform template with live data, and writes managed sections using
1186
+ * `updateManagedSection`.
1273
1187
  */
1274
1188
  /**
1275
1189
  * Resolve the package's content directory for template file copying.
@@ -1313,16 +1227,24 @@ function copyTemplates(coreConfigDir) {
1313
1227
  }
1314
1228
  cpSync(sourceDir, destDir, { recursive: true });
1315
1229
  }
1316
- /** Whether Handlebars helpers have been registered. */
1317
- let helpersRegistered = false;
1318
1230
  /**
1319
- * Register Handlebars helpers used in the Platform template.
1231
+ * Render the Platform template using simple string replacement.
1232
+ *
1233
+ * @param templatePath - Path to the templates directory.
1234
+ * @returns Rendered platform content string.
1320
1235
  */
1321
- function registerHelpers() {
1322
- if (helpersRegistered)
1323
- return;
1324
- helpersRegistered = true;
1325
- Handlebars.registerHelper('gt', (a, b) => typeof a === 'number' && typeof b === 'number' && a > b);
1236
+ function renderPlatformTemplate(templatePath) {
1237
+ const templatesAvailable = existsSync(templatePath);
1238
+ let content = toolsPlatformTemplate;
1239
+ // Handle <!-- IF_TEMPLATES --> ... <!-- ELSE_TEMPLATES --> ... <!-- ENDIF_TEMPLATES --> block
1240
+ const ifRegex = /<!-- IF_TEMPLATES -->([\s\S]*?)<!-- ELSE_TEMPLATES -->([\s\S]*?)<!-- ENDIF_TEMPLATES -->/;
1241
+ const match = ifRegex.exec(content);
1242
+ if (match) {
1243
+ content = content.replace(match[0], templatesAvailable ? match[1] : match[2]);
1244
+ }
1245
+ // Replace __TEMPLATE_PATH__ with the actual path
1246
+ content = content.replace(/__TEMPLATE_PATH__/g, templatePath);
1247
+ return content;
1326
1248
  }
1327
1249
  /**
1328
1250
  * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
@@ -1330,60 +1252,22 @@ function registerHelpers() {
1330
1252
  * @param options - Configuration for the refresh cycle.
1331
1253
  */
1332
1254
  async function refreshPlatformContent(options) {
1333
- const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, probeTimeoutMs = 3000, skipRegistryCheck = false, } = options;
1255
+ const { coreVersion, componentName, componentVersion, servicePackage, pluginPackage, stalenessThresholdMs, } = options;
1334
1256
  const workspacePath = getWorkspacePath();
1335
1257
  const coreConfigDir = getCoreConfigDir();
1336
- // 1. Probe all services
1337
- const probeResults = await probeAllServices(undefined, probeTimeoutMs);
1338
- const unhealthyServices = probeResults.filter((r) => !r.healthy);
1339
- // 2. Registry version checks
1340
- const cacheDir = componentName
1341
- ? getComponentConfigDir(componentName)
1342
- : coreConfigDir;
1343
- let availableCoreVersion;
1344
- let availableServiceVersion;
1345
- let availablePluginVersion;
1346
- if (!skipRegistryCheck) {
1347
- const coreRegistryVersion = checkRegistryVersion('@karmaniverous/jeeves', cacheDir);
1348
- if (coreRegistryVersion && coreRegistryVersion !== coreVersion) {
1349
- availableCoreVersion = coreRegistryVersion;
1350
- }
1351
- if (servicePackage) {
1352
- const svcVersion = checkRegistryVersion(servicePackage, cacheDir);
1353
- if (svcVersion) {
1354
- availableServiceVersion = svcVersion;
1355
- }
1356
- }
1357
- if (pluginPackage) {
1358
- const plgVersion = checkRegistryVersion(pluginPackage, cacheDir);
1359
- if (plgVersion) {
1360
- availablePluginVersion = plgVersion;
1361
- }
1362
- }
1258
+ // 1. Write calling component's version entry
1259
+ if (componentName) {
1260
+ writeComponentVersion(coreConfigDir, {
1261
+ componentName,
1262
+ pluginVersion: componentVersion,
1263
+ servicePackage,
1264
+ pluginPackage,
1265
+ });
1363
1266
  }
1364
- // 3. Build enriched service rows — match the calling component by name
1365
- const serviceRows = probeResults.map((r) => ({
1366
- ...r,
1367
- pluginVersion: r.name === componentName ? componentVersion : undefined,
1368
- availableServiceVersion: r.name === componentName ? availableServiceVersion : undefined,
1369
- availablePluginVersion: r.name === componentName ? availablePluginVersion : undefined,
1370
- }));
1371
- // 5. Check if templates are available
1267
+ // 2. Render Platform template
1372
1268
  const templatePath = join(coreConfigDir, TEMPLATES_DIR);
1373
- const templatesAvailable = existsSync(templatePath);
1374
- // 6. Render Platform template
1375
- registerHelpers();
1376
- const template = Handlebars.compile(toolsPlatformTemplate);
1377
- const templateData = {
1378
- services: serviceRows,
1379
- unhealthyServices,
1380
- coreVersion,
1381
- availableCoreVersion,
1382
- templatesAvailable,
1383
- templatePath,
1384
- };
1385
- const platformContent = template(templateData);
1386
- // 7. Write TOOLS.md Platform section
1269
+ const platformContent = renderPlatformTemplate(templatePath);
1270
+ // 3. Write TOOLS.md Platform section
1387
1271
  const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
1388
1272
  await updateManagedSection(toolsPath, platformContent, {
1389
1273
  mode: 'section',
@@ -1392,7 +1276,7 @@ async function refreshPlatformContent(options) {
1392
1276
  coreVersion,
1393
1277
  stalenessThresholdMs,
1394
1278
  });
1395
- // 8. Write SOUL.md managed block
1279
+ // 4. Write SOUL.md managed block
1396
1280
  const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
1397
1281
  await updateManagedSection(soulPath, soulSectionContent, {
1398
1282
  mode: 'block',
@@ -1400,7 +1284,7 @@ async function refreshPlatformContent(options) {
1400
1284
  coreVersion,
1401
1285
  stalenessThresholdMs,
1402
1286
  });
1403
- // 9. Write AGENTS.md managed block
1287
+ // 5. Write AGENTS.md managed block
1404
1288
  const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
1405
1289
  await updateManagedSection(agentsPath, agentsSectionContent, {
1406
1290
  mode: 'block',
@@ -1408,7 +1292,7 @@ async function refreshPlatformContent(options) {
1408
1292
  coreVersion,
1409
1293
  stalenessThresholdMs,
1410
1294
  });
1411
- // 10. Copy templates to config dir
1295
+ // 6. Copy templates to config dir
1412
1296
  copyTemplates(coreConfigDir);
1413
1297
  }
1414
1298
 
@@ -1460,8 +1344,6 @@ async function seedContent(options) {
1460
1344
  // Seed content via the same code path as writer cycles
1461
1345
  await refreshPlatformContent({
1462
1346
  coreVersion: options.coreVersion,
1463
- probeTimeoutMs: options.probeTimeoutMs ?? 3000,
1464
- skipRegistryCheck: options.skipRegistryCheck ?? true,
1465
1347
  });
1466
1348
  }
1467
1349
 
@@ -1514,7 +1396,6 @@ function registerInstallCommand(program) {
1514
1396
  initFromOptions(opts);
1515
1397
  await seedContent({
1516
1398
  coreVersion: CORE_VERSION,
1517
- skipRegistryCheck: true,
1518
1399
  });
1519
1400
  console.log('✅ Platform content seeded successfully.');
1520
1401
  console.log(' - SOUL.md managed section written');
@@ -1526,12 +1407,73 @@ function registerInstallCommand(program) {
1526
1407
  }
1527
1408
 
1528
1409
  /**
1529
- * CLI status command: probe all service ports and report health summary.
1410
+ * Service URL resolution.
1411
+ *
1412
+ * @remarks
1413
+ * Resolves the URL for a named Jeeves service using the following
1414
+ * resolution order:
1415
+ * 1. Consumer's own component config
1416
+ * 2. Core config (`{configRoot}/jeeves-core/config.json`)
1417
+ * 3. Default port constants
1418
+ */
1419
+ /**
1420
+ * Resolve the URL for a named Jeeves service.
1421
+ *
1422
+ * @param serviceName - The service name (e.g., 'watcher', 'runner').
1423
+ * @param consumerName - Optional consumer component name for config override.
1424
+ * @returns The resolved service URL.
1425
+ * @throws Error if `init()` has not been called or the service is unknown.
1426
+ */
1427
+ function getServiceUrl(serviceName, consumerName) {
1428
+ // 2. Check core config
1429
+ const coreDir = getCoreConfigDir();
1430
+ const coreConfig = loadConfig(coreDir);
1431
+ const coreUrl = coreConfig?.services[serviceName]?.url;
1432
+ if (coreUrl)
1433
+ return coreUrl;
1434
+ // 3. Fall back to port constants
1435
+ const port = DEFAULT_PORTS[serviceName];
1436
+ if (port !== undefined) {
1437
+ return `http://127.0.0.1:${String(port)}`;
1438
+ }
1439
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
1440
+ }
1441
+
1442
+ /**
1443
+ * HTTP helpers for the OpenClaw plugin SDK.
1444
+ *
1445
+ * @remarks
1446
+ * Thin wrappers around `fetch` that throw on non-OK responses
1447
+ * and handle JSON serialisation/deserialisation.
1448
+ */
1449
+ /**
1450
+ * Fetch a URL with an automatic abort timeout.
1451
+ *
1452
+ * @param url - URL to fetch.
1453
+ * @param timeoutMs - Timeout in milliseconds before aborting.
1454
+ * @param init - Optional `fetch` init options.
1455
+ * @returns The fetch Response object.
1456
+ */
1457
+ async function fetchWithTimeout(url, timeoutMs, init) {
1458
+ const controller = new AbortController();
1459
+ const timeout = setTimeout(() => {
1460
+ controller.abort();
1461
+ }, timeoutMs);
1462
+ try {
1463
+ return await fetch(url, { ...init, signal: controller.signal });
1464
+ }
1465
+ finally {
1466
+ clearTimeout(timeout);
1467
+ }
1468
+ }
1469
+
1470
+ /**
1471
+ * CLI status command: discover components and probe their health.
1530
1472
  *
1531
1473
  * @remarks
1532
- * Displays a table of all Jeeves platform services with port and
1533
- * health status. Exits with code 0 if all services are healthy,
1534
- * code 1 if any are unreachable.
1474
+ * Uses `readComponentVersions()` to discover registered components,
1475
+ * then probes each one via GET /status. Exits with code 0 if all
1476
+ * services are healthy, code 1 if any are unreachable.
1535
1477
  */
1536
1478
  /**
1537
1479
  * Register the status subcommand on the parent CLI program.
@@ -1541,7 +1483,7 @@ function registerInstallCommand(program) {
1541
1483
  function registerStatusCommand(program) {
1542
1484
  program
1543
1485
  .command('status')
1544
- .description('Probe all Jeeves service ports and report health summary')
1486
+ .description('Discover Jeeves components and probe their health')
1545
1487
  .option('-w, --workspace <path>', 'Workspace root path', DEFAULT_WORKSPACE)
1546
1488
  .option('-c, --config-root <path>', 'Platform config root path', DEFAULT_CONFIG_ROOT)
1547
1489
  .option('-t, --timeout <ms>', 'Probe timeout in milliseconds', '3000')
@@ -1551,43 +1493,68 @@ function registerStatusCommand(program) {
1551
1493
  console.log('Jeeves Platform Status');
1552
1494
  console.log('='.repeat(60));
1553
1495
  console.log();
1554
- const probeResults = await probeAllServices(undefined, timeoutMs);
1496
+ const { getCoreConfigDir } = await Promise.resolve().then(function () { return init$1; });
1497
+ const coreConfigDir = getCoreConfigDir();
1498
+ const componentVersions = readComponentVersions(coreConfigDir);
1499
+ const componentNames = Object.keys(componentVersions);
1500
+ if (componentNames.length === 0) {
1501
+ console.log('No components registered.');
1502
+ return;
1503
+ }
1555
1504
  const nameWidth = 10;
1556
- const portWidth = 6;
1557
1505
  const statusWidth = 30;
1506
+ const versionWidth = 12;
1558
1507
  const header = [
1559
- 'Service'.padEnd(nameWidth),
1560
- 'Port'.padEnd(portWidth),
1508
+ 'Component'.padEnd(nameWidth),
1561
1509
  'Status'.padEnd(statusWidth),
1510
+ 'Version'.padEnd(versionWidth),
1562
1511
  ].join(' ');
1563
1512
  const separator = [
1564
1513
  '-'.repeat(nameWidth),
1565
- '-'.repeat(portWidth),
1566
1514
  '-'.repeat(statusWidth),
1515
+ '-'.repeat(versionWidth),
1567
1516
  ].join(' ');
1568
1517
  console.log(header);
1569
1518
  console.log(separator);
1570
1519
  let allHealthy = true;
1571
- for (const r of probeResults) {
1520
+ for (const name of componentNames) {
1572
1521
  let status;
1573
- if (r.healthy) {
1574
- status = r.version ? `✅ Running (v${r.version})` : '✅ Running';
1522
+ let version = '—';
1523
+ try {
1524
+ const url = getServiceUrl(name);
1525
+ const response = await fetchWithTimeout(`${url}/status`, timeoutMs);
1526
+ if (response.ok) {
1527
+ status = '✅ Running';
1528
+ try {
1529
+ const body = await response.json();
1530
+ if (typeof body === 'object' &&
1531
+ body !== null &&
1532
+ 'version' in body &&
1533
+ typeof body['version'] === 'string') {
1534
+ version = body['version'];
1535
+ }
1536
+ }
1537
+ catch {
1538
+ // Non-JSON response — version stays unknown
1539
+ }
1540
+ }
1541
+ else {
1542
+ status = `❌ HTTP ${String(response.status)}`;
1543
+ allHealthy = false;
1544
+ }
1575
1545
  }
1576
- else {
1577
- status = r.error ? `❌ ${r.error}` : '❌ Down';
1546
+ catch {
1547
+ status = '❌ Down';
1578
1548
  allHealthy = false;
1579
1549
  }
1580
1550
  const row = [
1581
- r.name.padEnd(nameWidth),
1582
- String(r.port).padEnd(portWidth),
1551
+ name.padEnd(nameWidth),
1583
1552
  status.padEnd(statusWidth),
1553
+ version.padEnd(versionWidth),
1584
1554
  ].join(' ');
1585
1555
  console.log(row);
1586
1556
  }
1587
1557
  console.log();
1588
- const healthy = probeResults.filter((r) => r.healthy).length;
1589
- const total = probeResults.length;
1590
- console.log(`${String(healthy)}/${String(total)} services healthy`);
1591
1558
  if (!allHealthy) {
1592
1559
  process.exitCode = 1;
1593
1560
  }
@@ -1679,13 +1646,25 @@ function registerUninstallCommand(program) {
1679
1646
  console.log();
1680
1647
  // Warn if services still responding
1681
1648
  try {
1682
- const probeResults = await probeAllServices(undefined, 2000);
1683
- const running = probeResults.filter((r) => r.healthy);
1649
+ const componentVersions = readComponentVersions(coreConfigDir);
1650
+ const componentNames = Object.keys(componentVersions);
1651
+ const running = [];
1652
+ for (const name of componentNames) {
1653
+ try {
1654
+ const url = getServiceUrl(name);
1655
+ const response = await fetchWithTimeout(`${url}/status`, 2000);
1656
+ if (response.ok) {
1657
+ running.push(name);
1658
+ }
1659
+ }
1660
+ catch {
1661
+ // Not running — expected during uninstall
1662
+ }
1663
+ }
1684
1664
  if (running.length > 0) {
1685
1665
  console.log('⚠️ The following services are still responding:');
1686
- for (const r of running) {
1687
- const ver = r.version ? ` (v${r.version})` : '';
1688
- console.log(` - ${r.name} on port ${String(r.port)}${ver}`);
1666
+ for (const name of running) {
1667
+ console.log(` - ${name}`);
1689
1668
  }
1690
1669
  console.log(' Consider stopping them before fully removing Jeeves.');
1691
1670
  console.log();