@kindgi/handler-runtime 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +218 -2
  3. package/dist/build-extensions.d.ts +75 -0
  4. package/dist/build-extensions.d.ts.map +1 -0
  5. package/dist/build-extensions.js +27 -0
  6. package/dist/build-extensions.js.map +1 -0
  7. package/dist/discovery.d.ts +19 -0
  8. package/dist/discovery.d.ts.map +1 -0
  9. package/dist/discovery.js +81 -0
  10. package/dist/discovery.js.map +1 -0
  11. package/dist/entrypoint.d.ts +10 -0
  12. package/dist/entrypoint.d.ts.map +1 -0
  13. package/dist/entrypoint.js +24 -0
  14. package/dist/entrypoint.js.map +1 -0
  15. package/dist/handler-runner.d.ts +127 -0
  16. package/dist/handler-runner.d.ts.map +1 -0
  17. package/dist/handler-runner.js +318 -0
  18. package/dist/handler-runner.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +7 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/kindgi-index-main.d.ts +2 -0
  24. package/dist/kindgi-index-main.d.ts.map +1 -0
  25. package/dist/kindgi-index-main.js +15 -0
  26. package/dist/kindgi-index-main.js.map +1 -0
  27. package/dist/kindgi-index.d.ts +316 -0
  28. package/dist/kindgi-index.d.ts.map +1 -0
  29. package/dist/kindgi-index.js +1201 -0
  30. package/dist/kindgi-index.js.map +1 -0
  31. package/dist/pack-env.d.ts +66 -0
  32. package/dist/pack-env.d.ts.map +1 -0
  33. package/dist/pack-env.js +97 -0
  34. package/dist/pack-env.js.map +1 -0
  35. package/dist/pack-service/index.d.ts +7 -0
  36. package/dist/pack-service/index.d.ts.map +1 -0
  37. package/dist/pack-service/index.js +6 -0
  38. package/dist/pack-service/index.js.map +1 -0
  39. package/dist/pack-service/main.d.ts +47 -0
  40. package/dist/pack-service/main.d.ts.map +1 -0
  41. package/dist/pack-service/main.js +201 -0
  42. package/dist/pack-service/main.js.map +1 -0
  43. package/dist/pack-service/service.d.ts +61 -0
  44. package/dist/pack-service/service.d.ts.map +1 -0
  45. package/dist/pack-service/service.js +341 -0
  46. package/dist/pack-service/service.js.map +1 -0
  47. package/dist/pack-service/supervisor.d.ts +138 -0
  48. package/dist/pack-service/supervisor.d.ts.map +1 -0
  49. package/dist/pack-service/supervisor.js +424 -0
  50. package/dist/pack-service/supervisor.js.map +1 -0
  51. package/dist/protocol.d.ts +104 -0
  52. package/dist/protocol.d.ts.map +1 -0
  53. package/dist/protocol.js +116 -0
  54. package/dist/protocol.js.map +1 -0
  55. package/package.json +87 -4
  56. package/src/build-extensions.ts +100 -0
  57. package/src/discovery.ts +89 -0
  58. package/src/entrypoint.ts +23 -0
  59. package/src/handler-runner.ts +500 -0
  60. package/src/index.ts +66 -0
  61. package/src/kindgi-index-main.ts +17 -0
  62. package/src/kindgi-index.ts +1605 -0
  63. package/src/pack-env.ts +148 -0
  64. package/src/pack-service/index.ts +17 -0
  65. package/src/pack-service/main.ts +246 -0
  66. package/src/pack-service/service.ts +478 -0
  67. package/src/pack-service/supervisor.ts +600 -0
  68. package/src/protocol.ts +214 -0
@@ -0,0 +1,1605 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * `kindgi-index` — the pack indexer, which writes a pack's `index.json`.
6
+ * `kindgi dev` runs it over the dev bundles; a pack image's indexer
7
+ * stage runs it (process entry `kindgi-index-main`, bundled as
8
+ * `dist/kindgi-index.mjs`) over the image's bundles, and `kindgi build`
9
+ * runs the same bundle locally, so the two indexes compare.
10
+ *
11
+ * The indexer:
12
+ *
13
+ * 1. Loads `kindgi.config.ts` (or `.js` / `.mjs`) at the pack root
14
+ * to read `pack.id` / `pack.version` + optional `discovery` glob
15
+ * overrides.
16
+ * 2. Discovers files under the four default folders — `tools/`,
17
+ * `guardrails/`, `agents/`, `flows/` — using the config's
18
+ * discovery patterns; or, indexing a build, takes the files a
19
+ * bundle map lists (`bundleMap`), classified by the same patterns.
20
+ * 3. Dynamically imports each file (from its bundle, given a map).
21
+ * Reads the `default` export
22
+ * (accepting either the raw primitive shape or a `Result`-wrapped
23
+ * envelope from `defineTool` / `defineGuardrail` / etc.).
24
+ * 4. Kind-maps the discovered file by its default folder (primary) or
25
+ * by structural shape of the default export (fallback for
26
+ * custom-pattern files).
27
+ * 5. For every Zod-typed schema (`tool.inputZod`, `tool.outputZod`,
28
+ * `check.configZod`) calls `@kindgi/schema.toJSONSchemaSync` to
29
+ * derive the JSON Schema wire form. When JSON Schema is authored
30
+ * directly, it is emitted verbatim.
31
+ * 6. Assembles a `v: 1` `index.json` envelope and writes it
32
+ * atomically (write-to-tmp, then rename) to the configured output
33
+ * path.
34
+ *
35
+ * ## Fail loud at every boundary
36
+ *
37
+ * Missing config, missing default export, kind mismatch, Zod conversion
38
+ * failure, filesystem write error — each is a distinct typed
39
+ * `IndexerError` variant with the file path attached. No silent skips.
40
+ *
41
+ * ## Determinism
42
+ *
43
+ * The integrity gate (locally verify the server's build) diffs an indexer output computed on the developer's
44
+ * laptop against the server's output. Byte-identical inputs must
45
+ * produce byte-identical outputs. All list fields are sorted
46
+ * lexicographically by id; JSON serialization uses compact stable
47
+ * output; `publishedAt` is accepted as an explicit `opts.publishedAt`
48
+ * for reproducible builds (the pack build passes it) and defaults to
49
+ * `new Date()` only for local dev (documented in the README).
50
+ */
51
+
52
+ import * as fs from 'node:fs/promises';
53
+ import * as path from 'node:path';
54
+ import { fileURLToPath, pathToFileURL } from 'node:url';
55
+
56
+ import { type FlowOutputSpec, loadFlow } from '@kindgi/flow';
57
+ import { isZodSchema, loadZodConverterSync, toJSONSchemaSync } from '@kindgi/schema';
58
+ import type { AnySchema, ZodConverter, ZodLikeSchema } from '@kindgi/schema';
59
+ import type { Result } from '@kindgi/types';
60
+ import { parse as parseToml } from 'smol-toml';
61
+
62
+ import { TEST_FILE_REGEX, globStaticPrefix, globToRegex } from './discovery.js';
63
+ import { type PackEnvConfig, type PackEnvDeclaration, resolvePackEnv } from './pack-env.js';
64
+
65
+ // -----------------------------------------------------------------------
66
+ // Public envelope + shape types
67
+ // -----------------------------------------------------------------------
68
+
69
+ /**
70
+ * Envelope version emitted on `index.json`. Bump on breaking change;
71
+ * additive fields on an existing shape don't bump. Consumers (the
72
+ * controller) MUST refuse an envelope version they
73
+ * don't recognize.
74
+ */
75
+ export const INDEX_ENVELOPE_VERSION = 1 as const;
76
+ export type IndexEnvelopeVersion = typeof INDEX_ENVELOPE_VERSION;
77
+
78
+ export type PrimitiveKind = 'tool' | 'guardrail' | 'agent' | 'flow';
79
+
80
+ /** Discovery glob patterns keyed by primitive kind. */
81
+ export interface DiscoveryConfig {
82
+ readonly tools?: string;
83
+ readonly guardrails?: string;
84
+ readonly agents?: string;
85
+ readonly flows?: string;
86
+ }
87
+
88
+ /**
89
+ * The language a pack's code (tool handlers, guardrail checks) is written
90
+ * in — which indexer reads it and which pack service runs it. Agents and
91
+ * flows are data in either.
92
+ */
93
+ export type PackLanguage = 'node' | 'python';
94
+
95
+ /**
96
+ * The subset of `kindgi.config.ts` this indexer consumes. Other
97
+ * top-level fields (`environments`, `bundle`, etc.) are read by the
98
+ * deploy pipeline elsewhere and are opaque to the indexer.
99
+ */
100
+ export interface KindgiConfig {
101
+ readonly pack: {
102
+ readonly id: string;
103
+ readonly version: string;
104
+ readonly description?: string;
105
+ };
106
+ readonly discovery?: DiscoveryConfig;
107
+ /**
108
+ * The pack's code language. Absent in `kindgi.config.*`: `node`. A
109
+ * `[tool.kindgi]` table in `pyproject.toml` is a Python pack unless it
110
+ * says otherwise.
111
+ */
112
+ readonly language?: PackLanguage;
113
+ /**
114
+ * The process environment the pack's code reads: `required` names must
115
+ * be set for the pack service to be ready; `optional` ones are injected
116
+ * when a deployment has them. Carried in `index.json` (see
117
+ * `pack-env.ts`).
118
+ */
119
+ readonly env?: PackEnvConfig;
120
+ readonly [k: string]: unknown;
121
+ }
122
+
123
+ /** The language of a loaded config's pack code. */
124
+ export function packLanguage(config: KindgiConfig): PackLanguage {
125
+ return config.language ?? 'node';
126
+ }
127
+
128
+ /**
129
+ * Default discovery patterns. Extensions
130
+ * `.ts` / `.js` / `.mjs` are all accepted — a pack build may transpile
131
+ * `.ts` to `.js` before the indexer runs inside the Dockerfile's indexer
132
+ * stage, but the indexer accepts either shape so local development (no
133
+ * bundle) and CI-built images (post-bundle) share the same code path.
134
+ */
135
+ export const DEFAULT_DISCOVERY: Required<DiscoveryConfig> = {
136
+ tools: 'tools/**/*.{ts,js,mjs}',
137
+ guardrails: 'guardrails/**/*.{ts,js,mjs}',
138
+ agents: 'agents/**/*.{ts,js,mjs}',
139
+ flows: 'flows/**/*.{ts,js,mjs}',
140
+ };
141
+
142
+ /**
143
+ * Default discovery patterns of a Python pack (`python -m kindgi.pack index`
144
+ * reads the same keys from `[tool.kindgi.discovery]`).
145
+ */
146
+ export const DEFAULT_PYTHON_DISCOVERY: Required<DiscoveryConfig> = {
147
+ tools: 'tools/**/*.py',
148
+ guardrails: 'guardrails/**/*.py',
149
+ agents: 'agents/**/*.py',
150
+ flows: 'flows/**/*.py',
151
+ };
152
+
153
+ /** A config's discovery patterns with the language's defaults filled in. */
154
+ export function resolveDiscovery(
155
+ discovery: DiscoveryConfig | undefined,
156
+ language: PackLanguage = 'node',
157
+ ): Required<DiscoveryConfig> {
158
+ const defaults = language === 'python' ? DEFAULT_PYTHON_DISCOVERY : DEFAULT_DISCOVERY;
159
+ return { ...defaults, ...(discovery ?? {}) };
160
+ }
161
+
162
+ // -----------------------------------------------------------------------
163
+ // Manifest entry shapes emitted in index.json
164
+ // -----------------------------------------------------------------------
165
+
166
+ export interface IndexedTool {
167
+ readonly id: string;
168
+ readonly description?: string;
169
+ readonly version?: string;
170
+ readonly input: Readonly<Record<string, unknown>>;
171
+ readonly output: Readonly<Record<string, unknown>>;
172
+ readonly effects?: readonly Readonly<Record<string, unknown>>[];
173
+ readonly needs?: readonly Readonly<Record<string, unknown>>[];
174
+ readonly needsSpec?: Readonly<Record<string, unknown>>;
175
+ readonly sandbox?: string;
176
+ readonly limits?: Readonly<Record<string, unknown>>;
177
+ readonly network?: Readonly<Record<string, unknown>>;
178
+ /**
179
+ * A declarative tool's spec (`defineTool({ spec })`, e.g. `kind:
180
+ * 'http'`). The runtime runs a tool that has one itself — and resolves
181
+ * its `secretRef`s — rather than calling the pack service.
182
+ */
183
+ readonly spec?: Readonly<Record<string, unknown>>;
184
+ /**
185
+ * The tool's declared `mutating`. `false` makes it read-only: it runs
186
+ * in a dry run, and per-tool approval gates don't ask before it by
187
+ * default. Absent means it may change something.
188
+ */
189
+ readonly mutating?: boolean;
190
+ readonly modulePath: string;
191
+ }
192
+
193
+ export interface IndexedGuardrail {
194
+ readonly id: string;
195
+ readonly name?: string;
196
+ readonly kind: string;
197
+ readonly action: Readonly<Record<string, unknown>>;
198
+ readonly severity?: string;
199
+ readonly scope?: Readonly<Record<string, unknown>>;
200
+ readonly checkModulePath: string;
201
+ readonly checkId?: string;
202
+ readonly configSchema?: Readonly<Record<string, unknown>>;
203
+ /** The guardrail's `config`: what its check is configured with. */
204
+ readonly config?: Readonly<Record<string, unknown>>;
205
+ readonly sandbox?: string;
206
+ readonly limits?: Readonly<Record<string, unknown>>;
207
+ readonly network?: Readonly<Record<string, unknown>>;
208
+ }
209
+
210
+ export interface IndexedAgent {
211
+ readonly id: string;
212
+ readonly version: string;
213
+ readonly name: string;
214
+ readonly instructions: string;
215
+ readonly capabilities: readonly unknown[];
216
+ /**
217
+ * Typed tool references — `{ id, version }` where `version` is a
218
+ * semver range. Structurally compatible with `@kindgi/agents`
219
+ * `ToolRef`.
220
+ */
221
+ readonly tools: readonly { readonly id: string; readonly version: string }[];
222
+ readonly retrieval?: readonly unknown[];
223
+ readonly guardrails?: readonly string[];
224
+ readonly budget?: Readonly<Record<string, unknown>>;
225
+ readonly parameters?: readonly unknown[];
226
+ readonly preferredProvider?: string;
227
+ readonly preferredModel?: string;
228
+ readonly description?: string;
229
+ readonly tags?: readonly string[];
230
+ readonly conversationPolicy?: Readonly<Record<string, unknown>>;
231
+ /** The agent's typed output (`{ schema, name?, maxRepairs? }`), JSON Schema already. */
232
+ readonly output?: Readonly<Record<string, unknown>>;
233
+ /** How the agent's turns retry failed tool calls (`{ maxRetries?, retryOn? }`). */
234
+ readonly toolErrors?: Readonly<Record<string, unknown>>;
235
+ readonly modulePath: string;
236
+ }
237
+
238
+ /**
239
+ * Envelope version for the flow payload embedded inside `index.json`.
240
+ * Distinct from the top-level `v` envelope so the flow substrate can
241
+ * evolve its own shape independently — matches the `packages/flow`
242
+ * schema-version convention (currently 1.8.0, which added input and
243
+ * output mappings). Additive fields don't bump this.
244
+ */
245
+ export const KERNEL_PAYLOAD_VERSION = 1 as const;
246
+
247
+ export interface IndexedFlow {
248
+ readonly id: string;
249
+ readonly version: string;
250
+ readonly name?: string;
251
+ readonly description?: string;
252
+ readonly nodes: readonly unknown[];
253
+ readonly edges: readonly unknown[];
254
+ readonly maxParallelism?: number;
255
+ readonly metadata?: Readonly<Record<string, unknown>>;
256
+ /** The flow's declared output (schema-version 1.8.0+). */
257
+ readonly output?: FlowOutputSpec;
258
+ readonly kernelPayloadVersion: number;
259
+ readonly modulePath: string;
260
+ }
261
+
262
+ export interface Index {
263
+ readonly v: IndexEnvelopeVersion;
264
+ readonly packId: string;
265
+ readonly packVersion: string;
266
+ readonly artifactVersion: string;
267
+ readonly publishedAt: string;
268
+ readonly tools: readonly IndexedTool[];
269
+ readonly guardrails: readonly IndexedGuardrail[];
270
+ readonly agents: readonly IndexedAgent[];
271
+ readonly flows: readonly IndexedFlow[];
272
+ /** The pack's declared process environment (schema-version 1.4.0+); absent when it declares none. */
273
+ readonly env?: PackEnvDeclaration;
274
+ }
275
+
276
+ // -----------------------------------------------------------------------
277
+ // Error surface
278
+ // -----------------------------------------------------------------------
279
+
280
+ export type IndexerErrorCode =
281
+ | 'config-not-found'
282
+ | 'config-parse-failed'
283
+ | 'config-invalid'
284
+ | 'discovery-empty'
285
+ | 'file-import-failed'
286
+ | 'no-default-export'
287
+ | 'kind-mismatch'
288
+ | 'ambiguous-kind'
289
+ | 'zod-conversion-failed'
290
+ | 'manifest-validation-failed'
291
+ | 'output-write-failed'
292
+ | 'language-mismatch';
293
+
294
+ export interface IndexerError {
295
+ readonly code: IndexerErrorCode;
296
+ readonly message: string;
297
+ readonly filePath?: string;
298
+ readonly field?: string;
299
+ readonly cause?: unknown;
300
+ }
301
+
302
+ // -----------------------------------------------------------------------
303
+ // Report + options
304
+ // -----------------------------------------------------------------------
305
+
306
+ export interface IndexerReport {
307
+ readonly packId: string;
308
+ readonly packVersion: string;
309
+ readonly artifactVersion: string;
310
+ readonly publishedAt: string;
311
+ readonly counts: {
312
+ readonly tools: number;
313
+ readonly guardrails: number;
314
+ readonly agents: number;
315
+ readonly flows: number;
316
+ };
317
+ readonly outputPath: string;
318
+ /**
319
+ * Per-file errors accumulated during indexing — one bad file no
320
+ * longer aborts the whole pass. Registration continues on healthy
321
+ * primitives; the caller surfaces these to the user (dev watch,
322
+ * build log). Empty on a fully-clean pass.
323
+ */
324
+ readonly fileErrors: readonly IndexerError[];
325
+ }
326
+
327
+ export interface RunIndexerOptions {
328
+ /** Root of the pack repo — the folder containing `kindgi.config.ts`. */
329
+ readonly packDir: string;
330
+ /**
331
+ * Explicit config file path. When omitted, the indexer tries
332
+ * `kindgi.config.ts`, `.mjs`, `.js`, and `.cjs` in that order at
333
+ * the pack root.
334
+ */
335
+ readonly configPath?: string;
336
+ /**
337
+ * Explicit output path. When omitted, defaults to
338
+ * `<packDir>/index.json`.
339
+ */
340
+ readonly outputPath?: string;
341
+ /**
342
+ * Explicit artifact version. When omitted, the indexer computes a
343
+ * `YYYYMMDD.N` shape locally — a pack build supplies an explicit
344
+ * value at Dockerfile invocation time so the image build is
345
+ * reproducible.
346
+ */
347
+ readonly artifactVersion?: string;
348
+ /**
349
+ * Explicit publishedAt timestamp. Determinism gate — a pack build
350
+ * passes this as a build arg (SOURCE_DATE_EPOCH-shaped) so byte-identical
351
+ * source produces byte-identical index.json. Default: `new
352
+ * Date().toISOString()` — noted as non-deterministic in the README.
353
+ */
354
+ readonly publishedAt?: string;
355
+ /**
356
+ * Index a build: each primitive's source path (relative to `packDir`,
357
+ * as the index records it) mapped to its bundle (relative to
358
+ * `moduleRoot`). Given, the map is the file list — the source tree
359
+ * needn't be there, as in a pack image — each key classified by the
360
+ * discovery patterns as `discoverFiles` classifies what it finds; each
361
+ * file is imported from its bundle, and the index records the source
362
+ * path.
363
+ */
364
+ readonly bundleMap?: Readonly<Record<string, string>>;
365
+ /** Where `bundleMap`'s bundle paths are relative to. Default `packDir`. */
366
+ readonly moduleRoot?: string;
367
+ /**
368
+ * Test hook — override the default dynamic `import()`. Used by
369
+ * indexer tests to hand in in-memory module maps without a
370
+ * filesystem round-trip.
371
+ */
372
+ readonly importModule?: (fileUrl: string) => Promise<unknown>;
373
+ }
374
+
375
+ // -----------------------------------------------------------------------
376
+ // Entry point
377
+ // -----------------------------------------------------------------------
378
+
379
+ /**
380
+ * Run the indexer stage. Never throws — every failure is captured as a
381
+ * typed `IndexerError`.
382
+ */
383
+ export async function runIndexer(
384
+ opts: RunIndexerOptions,
385
+ ): Promise<Result<IndexerReport, IndexerError>> {
386
+ const packDir = path.resolve(opts.packDir);
387
+ const importModule = opts.importModule ?? defaultImportModule;
388
+
389
+ const configResult = await loadConfig(packDir, opts.configPath, importModule);
390
+ if (configResult.kind === 'err') return configResult;
391
+ const config = configResult.value;
392
+ if (packLanguage(config) !== 'node') {
393
+ return {
394
+ kind: 'err',
395
+ error: {
396
+ code: 'language-mismatch',
397
+ message: `${packDir} is a ${packLanguage(config)} pack; index it with its own indexer (python -m kindgi.pack index)`,
398
+ },
399
+ };
400
+ }
401
+ const env = resolvePackEnv(config.env);
402
+ if (env.kind === 'err') {
403
+ return {
404
+ kind: 'err',
405
+ error: { code: 'config-invalid', field: 'env', message: `kindgi.config: ${env.message}` },
406
+ };
407
+ }
408
+
409
+ const discovery = resolveDiscovery(config.discovery);
410
+
411
+ const bundleMap = opts.bundleMap;
412
+ const moduleRoot = path.resolve(opts.moduleRoot ?? packDir);
413
+ const discovered =
414
+ bundleMap !== undefined
415
+ ? discoverInBundleMap(bundleMap, discovery)
416
+ : await discoverFiles(packDir, discovery);
417
+ if (discovered.length === 0) {
418
+ return {
419
+ kind: 'err',
420
+ error: {
421
+ code: 'discovery-empty',
422
+ message:
423
+ bundleMap !== undefined
424
+ ? 'The bundle map lists no file the discovery patterns match. Check kindgi.config.ts discovery patterns.'
425
+ : `Indexer discovered zero files under ${packDir}. Check kindgi.config.ts discovery patterns.`,
426
+ },
427
+ };
428
+ }
429
+
430
+ const zodConverter = loadZodConverterSync();
431
+
432
+ const tools: IndexedTool[] = [];
433
+ const guardrails: IndexedGuardrail[] = [];
434
+ const agents: IndexedAgent[] = [];
435
+ const flows: IndexedFlow[] = [];
436
+ const fileErrors: IndexerError[] = [];
437
+
438
+ for (const { relPath, expectedKind } of discovered) {
439
+ const bundle = bundleMap?.[relPath];
440
+ const absPath =
441
+ bundle !== undefined ? path.resolve(moduleRoot, bundle) : path.join(packDir, relPath);
442
+ const fileUrl = pathToFileURL(absPath).href;
443
+
444
+ let module_: unknown;
445
+ try {
446
+ module_ = await importModule(fileUrl);
447
+ } catch (cause) {
448
+ fileErrors.push({
449
+ code: 'file-import-failed',
450
+ message: `Failed to import ${relPath}: ${stringifyError(cause)}`,
451
+ filePath: relPath,
452
+ cause: serializeCause(cause),
453
+ });
454
+ continue;
455
+ }
456
+
457
+ const defExport = extractDefaultExport(module_);
458
+ if (defExport === undefined) {
459
+ fileErrors.push({
460
+ code: 'no-default-export',
461
+ message: `File ${relPath} has no default export`,
462
+ filePath: relPath,
463
+ });
464
+ continue;
465
+ }
466
+
467
+ const errResult = detectResultError(defExport);
468
+ if (errResult !== undefined) {
469
+ fileErrors.push({
470
+ code: 'manifest-validation-failed',
471
+ message: `File ${relPath} default export is a Result-wrapped error: ${resultErrorMessage(errResult)}`,
472
+ filePath: relPath,
473
+ cause: errResult,
474
+ });
475
+ continue;
476
+ }
477
+
478
+ const unwrapped = unwrapResult(defExport);
479
+ const detected = detectKind(unwrapped);
480
+ if (detected === undefined) {
481
+ fileErrors.push({
482
+ code: 'ambiguous-kind',
483
+ message: `File ${relPath} default export does not match any primitive shape (tool / guardrail / agent / flow)`,
484
+ filePath: relPath,
485
+ });
486
+ continue;
487
+ }
488
+ if (expectedKind !== undefined && detected !== expectedKind) {
489
+ fileErrors.push({
490
+ code: 'kind-mismatch',
491
+ message: `File ${relPath} is under the default folder for kind '${expectedKind}' but default-exports a '${detected}'`,
492
+ filePath: relPath,
493
+ });
494
+ continue;
495
+ }
496
+
497
+ switch (detected) {
498
+ case 'tool': {
499
+ const built = buildTool(unwrapped, relPath, zodConverter);
500
+ if (built.kind === 'err') {
501
+ fileErrors.push(built.error);
502
+ continue;
503
+ }
504
+ tools.push(built.value);
505
+ break;
506
+ }
507
+ case 'guardrail': {
508
+ const built = buildGuardrail(unwrapped, relPath, zodConverter);
509
+ if (built.kind === 'err') {
510
+ fileErrors.push(built.error);
511
+ continue;
512
+ }
513
+ guardrails.push(built.value);
514
+ break;
515
+ }
516
+ case 'agent': {
517
+ const built = buildAgent(unwrapped, relPath);
518
+ if (built.kind === 'err') {
519
+ fileErrors.push(built.error);
520
+ continue;
521
+ }
522
+ agents.push(built.value);
523
+ break;
524
+ }
525
+ case 'flow': {
526
+ const built = buildGraph(unwrapped, relPath);
527
+ if (built.kind === 'err') {
528
+ fileErrors.push(built.error);
529
+ continue;
530
+ }
531
+ flows.push(built.value);
532
+ break;
533
+ }
534
+ }
535
+ }
536
+
537
+ const outputPath =
538
+ opts.outputPath !== undefined
539
+ ? path.resolve(opts.outputPath)
540
+ : path.join(packDir, 'index.json');
541
+
542
+ const artifactVersion = opts.artifactVersion ?? (await computeAutoArtifactVersion(outputPath));
543
+
544
+ const publishedAt = opts.publishedAt ?? new Date().toISOString();
545
+
546
+ const index: Index = {
547
+ v: INDEX_ENVELOPE_VERSION,
548
+ packId: config.pack.id,
549
+ packVersion: config.pack.version,
550
+ artifactVersion,
551
+ publishedAt,
552
+ tools: sortById(tools),
553
+ guardrails: sortById(guardrails),
554
+ agents: sortById(agents),
555
+ flows: sortById(flows),
556
+ ...(env.value !== undefined && { env: env.value }),
557
+ };
558
+
559
+ const written = await atomicWriteJson(outputPath, index);
560
+ if (written.kind === 'err') return written;
561
+
562
+ return {
563
+ kind: 'ok',
564
+ value: {
565
+ packId: config.pack.id,
566
+ packVersion: config.pack.version,
567
+ artifactVersion,
568
+ publishedAt,
569
+ counts: {
570
+ tools: tools.length,
571
+ guardrails: guardrails.length,
572
+ agents: agents.length,
573
+ flows: flows.length,
574
+ },
575
+ outputPath,
576
+ fileErrors,
577
+ },
578
+ };
579
+ }
580
+
581
+ // -----------------------------------------------------------------------
582
+ // CLI entry
583
+ // -----------------------------------------------------------------------
584
+
585
+ /**
586
+ * The `kindgi-index` command (process entry: `kindgi-index-main`) — a
587
+ * pack image's indexer stage runs it over the image's bundles. Args:
588
+ *
589
+ * --pack-dir <path> root of the pack (required)
590
+ * --config <path> explicit config file (optional)
591
+ * --output <path> explicit output path (optional)
592
+ * --bundle-map <path> index a build: the bundle map (JSON, optional)
593
+ * --module-root <path> where the bundle map's paths resolve (optional)
594
+ * --artifact-version <str> pin artifact version (optional)
595
+ * --published-at <iso> pin publishedAt (optional; enables reproducible builds)
596
+ * --help
597
+ * --version
598
+ */
599
+ export async function main(argv: readonly string[]): Promise<number> {
600
+ const parsed = parseArgs(argv);
601
+ if (parsed.kind === 'help') {
602
+ process.stdout.write(`${HELP_TEXT}\n`);
603
+ return 0;
604
+ }
605
+ if (parsed.kind === 'version') {
606
+ process.stdout.write('kindgi-index v0.0.0\n');
607
+ return 0;
608
+ }
609
+ if (parsed.kind === 'err') {
610
+ process.stderr.write(`kindgi-index: ${parsed.error}\n`);
611
+ return 2;
612
+ }
613
+ let options = parsed.value.options;
614
+ if (parsed.value.bundleMapPath !== undefined) {
615
+ const bundleMap = await readBundleMap(parsed.value.bundleMapPath);
616
+ if (bundleMap.kind === 'err') {
617
+ process.stderr.write(`kindgi-index: ${bundleMap.message}\n`);
618
+ return 2;
619
+ }
620
+ options = { ...options, bundleMap: bundleMap.value };
621
+ }
622
+ const outcome = await runIndexer(options);
623
+ if (outcome.kind === 'err') {
624
+ process.stderr.write(`kindgi-index: ${outcome.error.code}: ${outcome.error.message}\n`);
625
+ return 1;
626
+ }
627
+ process.stdout.write(`${JSON.stringify(outcome.value, null, 2)}\n`);
628
+ return 0;
629
+ }
630
+
631
+ export const HELP_TEXT = `kindgi-index — build-time pack manifest indexer
632
+
633
+ Usage:
634
+ kindgi-index --pack-dir <path> [--config <path>] [--output <path>]
635
+ [--bundle-map <path> [--module-root <path>]]
636
+ [--artifact-version <str>] [--published-at <iso>]
637
+
638
+ Options:
639
+ --pack-dir <path> Root of the pack (required).
640
+ --config <path> Explicit kindgi.config.* path.
641
+ --output <path> Output path for index.json (default: <packDir>/index.json).
642
+ --bundle-map <path> Index a build: JSON mapping each source path to its bundle.
643
+ --module-root <path> Where the bundle map's paths resolve (default: --pack-dir).
644
+ --artifact-version <str> Pin artifact version (default: YYYYMMDD.N auto).
645
+ --published-at <iso> Pin publishedAt for reproducible builds.
646
+ --help Show this help.
647
+ --version Print version and exit.`;
648
+
649
+ type ParsedArgs =
650
+ | {
651
+ readonly kind: 'ok';
652
+ readonly value: { readonly options: RunIndexerOptions; readonly bundleMapPath?: string };
653
+ }
654
+ | { readonly kind: 'help' }
655
+ | { readonly kind: 'version' }
656
+ | { readonly kind: 'err'; readonly error: string };
657
+
658
+ function parseArgs(argv: readonly string[]): ParsedArgs {
659
+ let packDir: string | undefined;
660
+ let configPath: string | undefined;
661
+ let outputPath: string | undefined;
662
+ let artifactVersion: string | undefined;
663
+ let publishedAt: string | undefined;
664
+ let bundleMapPath: string | undefined;
665
+ let moduleRoot: string | undefined;
666
+
667
+ let i = 0;
668
+ while (i < argv.length) {
669
+ const arg = argv[i];
670
+ if (arg === undefined) break;
671
+ if (arg === '--help' || arg === '-h') return { kind: 'help' };
672
+ if (arg === '--version' || arg === '-v') return { kind: 'version' };
673
+
674
+ const takeValue = (): string | { readonly err: string } => {
675
+ const value = argv[i + 1];
676
+ if (value === undefined) return { err: `${arg} requires a value` };
677
+ return value;
678
+ };
679
+
680
+ if (arg === '--pack-dir') {
681
+ const value = takeValue();
682
+ if (typeof value !== 'string') return { kind: 'err', error: value.err };
683
+ packDir = value;
684
+ i += 2;
685
+ continue;
686
+ }
687
+ if (arg === '--config') {
688
+ const value = takeValue();
689
+ if (typeof value !== 'string') return { kind: 'err', error: value.err };
690
+ configPath = value;
691
+ i += 2;
692
+ continue;
693
+ }
694
+ if (arg === '--output') {
695
+ const value = takeValue();
696
+ if (typeof value !== 'string') return { kind: 'err', error: value.err };
697
+ outputPath = value;
698
+ i += 2;
699
+ continue;
700
+ }
701
+ if (arg === '--artifact-version') {
702
+ const value = takeValue();
703
+ if (typeof value !== 'string') return { kind: 'err', error: value.err };
704
+ artifactVersion = value;
705
+ i += 2;
706
+ continue;
707
+ }
708
+ if (arg === '--published-at') {
709
+ const value = takeValue();
710
+ if (typeof value !== 'string') return { kind: 'err', error: value.err };
711
+ publishedAt = value;
712
+ i += 2;
713
+ continue;
714
+ }
715
+ if (arg === '--bundle-map') {
716
+ const value = takeValue();
717
+ if (typeof value !== 'string') return { kind: 'err', error: value.err };
718
+ bundleMapPath = value;
719
+ i += 2;
720
+ continue;
721
+ }
722
+ if (arg === '--module-root') {
723
+ const value = takeValue();
724
+ if (typeof value !== 'string') return { kind: 'err', error: value.err };
725
+ moduleRoot = value;
726
+ i += 2;
727
+ continue;
728
+ }
729
+ return { kind: 'err', error: `Unknown argument: ${arg}` };
730
+ }
731
+ if (packDir === undefined) {
732
+ return { kind: 'err', error: '--pack-dir is required' };
733
+ }
734
+ if (moduleRoot !== undefined && bundleMapPath === undefined) {
735
+ return { kind: 'err', error: '--module-root goes with --bundle-map' };
736
+ }
737
+ const options: RunIndexerOptions = {
738
+ packDir,
739
+ ...(configPath !== undefined && { configPath }),
740
+ ...(outputPath !== undefined && { outputPath }),
741
+ ...(artifactVersion !== undefined && { artifactVersion }),
742
+ ...(publishedAt !== undefined && { publishedAt }),
743
+ ...(moduleRoot !== undefined && { moduleRoot }),
744
+ };
745
+ return {
746
+ kind: 'ok',
747
+ value: { options, ...(bundleMapPath !== undefined && { bundleMapPath }) },
748
+ };
749
+ }
750
+
751
+ /**
752
+ * A bundle map file: a JSON object of source path → bundle path, both
753
+ * strings (`kindgi build` writes `dist/bundle-map.json`).
754
+ */
755
+ export async function readBundleMap(
756
+ file: string,
757
+ ): Promise<
758
+ | { readonly kind: 'ok'; readonly value: Readonly<Record<string, string>> }
759
+ | { readonly kind: 'err'; readonly message: string }
760
+ > {
761
+ let parsed: unknown;
762
+ try {
763
+ parsed = JSON.parse(await fs.readFile(file, 'utf8'));
764
+ } catch (cause) {
765
+ return { kind: 'err', message: `Cannot read the bundle map ${file}: ${stringifyError(cause)}` };
766
+ }
767
+ if (
768
+ parsed === null ||
769
+ typeof parsed !== 'object' ||
770
+ Array.isArray(parsed) ||
771
+ !Object.values(parsed).every((v) => typeof v === 'string' && v !== '')
772
+ ) {
773
+ return {
774
+ kind: 'err',
775
+ message: `The bundle map ${file} must be a JSON object of source path → bundle path.`,
776
+ };
777
+ }
778
+ return { kind: 'ok', value: parsed as Record<string, string> };
779
+ }
780
+
781
+ // -----------------------------------------------------------------------
782
+ // Config loading
783
+ // -----------------------------------------------------------------------
784
+
785
+ /** Config file names looked up at a pack root, in order. */
786
+ export const KINDGI_CONFIG_FILENAMES: readonly string[] = [
787
+ 'kindgi.config.ts',
788
+ // `.mts` is always ESM — the config name for a CommonJS app, where a
789
+ // `.ts` config would be reparsed with a Node warning.
790
+ 'kindgi.config.mts',
791
+ 'kindgi.config.mjs',
792
+ 'kindgi.config.js',
793
+ 'kindgi.config.cjs',
794
+ ];
795
+
796
+ export interface LoadKindgiConfigOptions {
797
+ /** Explicit config path (absolute or cwd-relative); skips the lookup. */
798
+ readonly configPath?: string;
799
+ /** Test hook — replaces the cache-busted dynamic `import()`. */
800
+ readonly importModule?: (fileUrl: string) => Promise<unknown>;
801
+ }
802
+
803
+ /**
804
+ * Load and minimally validate a pack's `kindgi.config.*` — the one
805
+ * loader behind the indexer and any tooling that reads pack config.
806
+ * Never throws:
807
+ * `config-not-found` when no config file exists, `config-parse-failed`
808
+ * (with the underlying cause) when it exists but cannot be imported or
809
+ * lacks `pack.id` / `pack.version`. Imports are cache-busted by mtime so
810
+ * a long-running process sees edits.
811
+ */
812
+ export async function loadKindgiConfig(
813
+ packDir: string,
814
+ options: LoadKindgiConfigOptions = {},
815
+ ): Promise<Result<KindgiConfig, IndexerError>> {
816
+ return loadConfig(
817
+ path.resolve(packDir),
818
+ options.configPath,
819
+ options.importModule ?? defaultImportModule,
820
+ );
821
+ }
822
+
823
+ /** `pyproject.toml` — a Python pack keeps its config in the `[tool.kindgi]` table. */
824
+ export const PYPROJECT_FILENAME = 'pyproject.toml';
825
+
826
+ export interface KindgiConfigFile {
827
+ readonly path: string;
828
+ /** `module`: a `kindgi.config.*`; `pyproject`: the `[tool.kindgi]` table of a `pyproject.toml`. */
829
+ readonly format: 'module' | 'pyproject';
830
+ }
831
+
832
+ /**
833
+ * Where a pack's config lives: the first `kindgi.config.*` at `packDir`
834
+ * (`KINDGI_CONFIG_FILENAMES` order), else a `pyproject.toml` with a
835
+ * `[tool.kindgi]` table. `undefined` when neither — a `pyproject.toml`
836
+ * without the table is a Python project, not a pack.
837
+ */
838
+ export async function findKindgiConfig(packDir: string): Promise<KindgiConfigFile | undefined> {
839
+ for (const name of KINDGI_CONFIG_FILENAMES) {
840
+ const candidate = path.join(packDir, name);
841
+ if (await exists(candidate)) return { path: candidate, format: 'module' };
842
+ }
843
+ const pyproject = path.join(packDir, PYPROJECT_FILENAME);
844
+ let text: string;
845
+ try {
846
+ text = await fs.readFile(pyproject, 'utf8');
847
+ } catch {
848
+ return undefined;
849
+ }
850
+ try {
851
+ const tool = (parseToml(text) as Record<string, unknown>).tool;
852
+ return isObject(tool) && 'kindgi' in tool
853
+ ? { path: pyproject, format: 'pyproject' }
854
+ : undefined;
855
+ } catch {
856
+ // Unparsable: a pack only if it clearly meant to be one (the loader reports the error).
857
+ return /^\s*\[tool\.kindgi[\].]/m.test(text)
858
+ ? { path: pyproject, format: 'pyproject' }
859
+ : undefined;
860
+ }
861
+ }
862
+
863
+ async function exists(file: string): Promise<boolean> {
864
+ try {
865
+ await fs.access(file);
866
+ return true;
867
+ } catch {
868
+ return false;
869
+ }
870
+ }
871
+
872
+ async function loadConfig(
873
+ packDir: string,
874
+ explicitPath: string | undefined,
875
+ importModule: (fileUrl: string) => Promise<unknown>,
876
+ ): Promise<Result<KindgiConfig, IndexerError>> {
877
+ let file: KindgiConfigFile | undefined;
878
+ if (explicitPath !== undefined) {
879
+ const resolved = path.resolve(explicitPath);
880
+ if (await exists(resolved)) {
881
+ const format = path.basename(resolved) === PYPROJECT_FILENAME ? 'pyproject' : 'module';
882
+ file = { path: resolved, format };
883
+ }
884
+ } else {
885
+ file = await findKindgiConfig(packDir);
886
+ }
887
+ if (file === undefined) {
888
+ return {
889
+ kind: 'err',
890
+ error: {
891
+ code: 'config-not-found',
892
+ message: `No kindgi.config.{ts,mts,mjs,js,cjs}, or pyproject.toml with a [tool.kindgi] table, found at pack root ${packDir}`,
893
+ },
894
+ };
895
+ }
896
+ const read =
897
+ file.format === 'pyproject'
898
+ ? await readPyprojectTable(file.path)
899
+ : await importConfigModule(file.path, importModule);
900
+ if (read.kind === 'err') return read;
901
+ const checked = checkConfig(read.value, file.path);
902
+ if (checked.kind === 'err') return checked;
903
+ const record = checked.value;
904
+ return {
905
+ kind: 'ok',
906
+ value:
907
+ file.format === 'pyproject' && record.language === undefined
908
+ ? { ...record, language: 'python' }
909
+ : record,
910
+ };
911
+ }
912
+
913
+ function parseFailed(
914
+ filePath: string,
915
+ message: string,
916
+ cause?: unknown,
917
+ ): Result<never, IndexerError> {
918
+ return {
919
+ kind: 'err',
920
+ error: {
921
+ code: 'config-parse-failed',
922
+ message,
923
+ filePath,
924
+ ...(cause !== undefined && { cause: serializeCause(cause) }),
925
+ },
926
+ };
927
+ }
928
+
929
+ async function importConfigModule(
930
+ filePath: string,
931
+ importModule: (fileUrl: string) => Promise<unknown>,
932
+ ): Promise<Result<Record<string, unknown>, IndexerError>> {
933
+ let module_: unknown;
934
+ try {
935
+ module_ = await importModule(pathToFileURL(filePath).href);
936
+ } catch (cause) {
937
+ return parseFailed(filePath, `Failed to import ${filePath}: ${stringifyError(cause)}`, cause);
938
+ }
939
+ const defExport = extractDefaultExport(module_);
940
+ if (defExport === undefined || typeof defExport !== 'object' || defExport === null) {
941
+ return parseFailed(
942
+ filePath,
943
+ `Config file ${filePath} has no default export (or it is not an object)`,
944
+ );
945
+ }
946
+ return { kind: 'ok', value: defExport as Record<string, unknown> };
947
+ }
948
+
949
+ async function readPyprojectTable(
950
+ filePath: string,
951
+ ): Promise<Result<Record<string, unknown>, IndexerError>> {
952
+ let document: Record<string, unknown>;
953
+ try {
954
+ document = parseToml(await fs.readFile(filePath, 'utf8')) as Record<string, unknown>;
955
+ } catch (cause) {
956
+ return parseFailed(filePath, `Failed to read ${filePath}: ${stringifyError(cause)}`, cause);
957
+ }
958
+ const tool = document.tool;
959
+ const table = isObject(tool) ? tool.kindgi : undefined;
960
+ if (!isObject(table)) {
961
+ return {
962
+ kind: 'err',
963
+ error: {
964
+ code: 'config-not-found',
965
+ message: `${filePath} has no [tool.kindgi] table`,
966
+ filePath,
967
+ },
968
+ };
969
+ }
970
+ return { kind: 'ok', value: table as Record<string, unknown> };
971
+ }
972
+
973
+ /** The checks every config passes, whatever file it came from. */
974
+ function checkConfig(
975
+ record: Record<string, unknown>,
976
+ filePath: string,
977
+ ): Result<KindgiConfig, IndexerError> {
978
+ const pack = record.pack;
979
+ if (typeof pack !== 'object' || pack === null) {
980
+ return parseFailed(
981
+ filePath,
982
+ `Config file ${filePath}: 'pack' field is missing or not an object`,
983
+ );
984
+ }
985
+ const packRec = pack as Record<string, unknown>;
986
+ if (typeof packRec.id !== 'string' || packRec.id.length === 0) {
987
+ return parseFailed(
988
+ filePath,
989
+ `Config file ${filePath}: 'pack.id' is missing or not a non-empty string`,
990
+ );
991
+ }
992
+ if (typeof packRec.version !== 'string' || packRec.version.length === 0) {
993
+ return parseFailed(
994
+ filePath,
995
+ `Config file ${filePath}: 'pack.version' is missing or not a non-empty string`,
996
+ );
997
+ }
998
+ if (record.language !== undefined && record.language !== 'node' && record.language !== 'python') {
999
+ return parseFailed(filePath, `Config file ${filePath}: 'language' must be "node" or "python"`);
1000
+ }
1001
+ return { kind: 'ok', value: record as KindgiConfig };
1002
+ }
1003
+
1004
+ // -----------------------------------------------------------------------
1005
+ // Discovery
1006
+ // -----------------------------------------------------------------------
1007
+
1008
+ interface DiscoveredFile {
1009
+ readonly relPath: string;
1010
+ /**
1011
+ * `undefined` when the matching pattern was custom (not the default
1012
+ * `<folder>/**\/*.{ts,js,mjs}` shape). Structural detection resolves
1013
+ * the kind from the default export's shape.
1014
+ */
1015
+ readonly expectedKind: PrimitiveKind | undefined;
1016
+ }
1017
+
1018
+ const DEFAULT_FOLDERS: Record<PrimitiveKind, string> = {
1019
+ tool: 'tools',
1020
+ guardrail: 'guardrails',
1021
+ agent: 'agents',
1022
+ flow: 'flows',
1023
+ };
1024
+
1025
+ async function discoverFiles(
1026
+ packDir: string,
1027
+ discovery: Required<DiscoveryConfig>,
1028
+ ): Promise<readonly DiscoveredFile[]> {
1029
+ const seen = new Map<string, PrimitiveKind | undefined>();
1030
+ for (const { kind, pattern } of kindPatterns(discovery)) {
1031
+ for (const rel of await globMatch(packDir, pattern)) classifyDiscovered(seen, rel, kind);
1032
+ }
1033
+ return sortedDiscovered(seen);
1034
+ }
1035
+
1036
+ /** A bundle map's source paths, classified as `discoverFiles` classifies what it finds. */
1037
+ function discoverInBundleMap(
1038
+ bundleMap: Readonly<Record<string, string>>,
1039
+ discovery: Required<DiscoveryConfig>,
1040
+ ): readonly DiscoveredFile[] {
1041
+ const sources = Object.keys(bundleMap).sort();
1042
+ const seen = new Map<string, PrimitiveKind | undefined>();
1043
+ for (const { kind, pattern } of kindPatterns(discovery)) {
1044
+ const regex = globToRegex(pattern);
1045
+ for (const rel of sources) if (regex.test(rel)) classifyDiscovered(seen, rel, kind);
1046
+ }
1047
+ return sortedDiscovered(seen);
1048
+ }
1049
+
1050
+ function kindPatterns(
1051
+ discovery: Required<DiscoveryConfig>,
1052
+ ): readonly { readonly kind: PrimitiveKind; readonly pattern: string }[] {
1053
+ return [
1054
+ { kind: 'tool', pattern: discovery.tools },
1055
+ { kind: 'guardrail', pattern: discovery.guardrails },
1056
+ { kind: 'agent', pattern: discovery.agents },
1057
+ { kind: 'flow', pattern: discovery.flows },
1058
+ ];
1059
+ }
1060
+
1061
+ /** Record `rel`, matched by `kind`'s pattern, unless it's a test file or already seen. */
1062
+ function classifyDiscovered(
1063
+ seen: Map<string, PrimitiveKind | undefined>,
1064
+ rel: string,
1065
+ kind: PrimitiveKind,
1066
+ ): void {
1067
+ // Skip test / spec files — they live next to the primitive
1068
+ // (`tools/echo/index.ts` + `tools/echo/index.test.ts`)
1069
+ // and would break the indexer if imported as a primitive
1070
+ // module. Matches conventional test-file suffixes across
1071
+ // vitest / jest / node:test.
1072
+ if (TEST_FILE_REGEX.test(rel)) return;
1073
+ if (seen.has(rel)) return;
1074
+ const firstSegment = rel.split('/')[0];
1075
+ const inDefaultFolder = firstSegment === DEFAULT_FOLDERS[kind];
1076
+ seen.set(rel, inDefaultFolder ? kind : undefined);
1077
+ }
1078
+
1079
+ function sortedDiscovered(
1080
+ seen: ReadonlyMap<string, PrimitiveKind | undefined>,
1081
+ ): readonly DiscoveredFile[] {
1082
+ const out: DiscoveredFile[] = [];
1083
+ for (const [relPath, expectedKind] of seen) out.push({ relPath, expectedKind });
1084
+ out.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
1085
+ return out;
1086
+ }
1087
+
1088
+ /**
1089
+ * Minimal glob matcher supporting `**` (any depth), `*` (single-segment
1090
+ * wildcard), and `{a,b,c}` alternation. Sufficient for the default
1091
+ * discovery patterns and typical custom overrides. Negations and
1092
+ * extglob patterns are not supported.
1093
+ */
1094
+ async function globMatch(root: string, pattern: string): Promise<readonly string[]> {
1095
+ const regex = globToRegex(pattern);
1096
+ const matches: string[] = [];
1097
+ // Walk only the pattern's static prefix — a pack embedded in a large
1098
+ // app (`kindgi/tools/**`) must not crawl the whole host repo.
1099
+ await walk(root, globStaticPrefix(pattern), matches, regex);
1100
+ return matches;
1101
+ }
1102
+
1103
+ async function walk(root: string, rel: string, matches: string[], regex: RegExp): Promise<void> {
1104
+ const abs = rel === '' ? root : path.join(root, rel);
1105
+ let entries: import('node:fs').Dirent[];
1106
+ try {
1107
+ entries = await fs.readdir(abs, { withFileTypes: true });
1108
+ } catch {
1109
+ return;
1110
+ }
1111
+ for (const entry of entries) {
1112
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
1113
+ const childRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
1114
+ if (entry.isDirectory()) {
1115
+ await walk(root, childRel, matches, regex);
1116
+ } else if (entry.isFile()) {
1117
+ if (regex.test(childRel)) matches.push(childRel);
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ // -----------------------------------------------------------------------
1123
+ // Module import + default export extraction
1124
+ // -----------------------------------------------------------------------
1125
+
1126
+ /**
1127
+ * Cache-busted dynamic import. Node's module cache is keyed by URL, so
1128
+ * a plain `import(fileUrl)` inside a long-running process (a dev watch
1129
+ * loop) returns the *originally loaded* module even after the author
1130
+ * edits the file on disk — the indexer would then re-emit stale
1131
+ * manifests every watch tick.
1132
+ *
1133
+ * Fix: append `?v=<mtimeMs>` to the URL. Node treats each distinct URL
1134
+ * as a distinct module entry, so an edited file (with a newer mtime)
1135
+ * triggers a fresh load; an unchanged file keeps the same URL and hits
1136
+ * the cache — no wasted work, no memory churn.
1137
+ *
1138
+ * Best-effort: if `fs.stat` fails (e.g. an unusual URL scheme or a
1139
+ * race), fall back to the plain `import()` so the indexer still
1140
+ * completes — logged behavior remains the same, just without cache
1141
+ * invalidation.
1142
+ */
1143
+ async function defaultImportModule(fileUrl: string): Promise<unknown> {
1144
+ try {
1145
+ const abs = fileURLToPath(fileUrl);
1146
+ const { mtimeMs } = await fs.stat(abs);
1147
+ return await import(`${fileUrl}?v=${mtimeMs}`);
1148
+ } catch {
1149
+ return await import(fileUrl);
1150
+ }
1151
+ }
1152
+
1153
+ function extractDefaultExport(module_: unknown): unknown | undefined {
1154
+ if (module_ === null || typeof module_ !== 'object') return undefined;
1155
+ const record = module_ as Record<string, unknown>;
1156
+ return record.default;
1157
+ }
1158
+
1159
+ // -----------------------------------------------------------------------
1160
+ // Result-wrapper handling
1161
+ // -----------------------------------------------------------------------
1162
+
1163
+ function detectResultError(value: unknown): unknown | undefined {
1164
+ if (value === null || typeof value !== 'object') return undefined;
1165
+ const record = value as Record<string, unknown>;
1166
+ if (record.kind === 'err' && 'error' in record) return record.error;
1167
+ return undefined;
1168
+ }
1169
+
1170
+ function unwrapResult(value: unknown): unknown {
1171
+ if (value === null || typeof value !== 'object') return value;
1172
+ const record = value as Record<string, unknown>;
1173
+ if (record.kind === 'ok' && 'value' in record) return record.value;
1174
+ return value;
1175
+ }
1176
+
1177
+ function resultErrorMessage(err: unknown): string {
1178
+ if (err === null || typeof err !== 'object') return String(err);
1179
+ const rec = err as Record<string, unknown>;
1180
+ if (typeof rec.message === 'string') return rec.message;
1181
+ return JSON.stringify(err);
1182
+ }
1183
+
1184
+ // -----------------------------------------------------------------------
1185
+ // Structural kind detection
1186
+ // -----------------------------------------------------------------------
1187
+
1188
+ function detectKind(value: unknown): PrimitiveKind | undefined {
1189
+ if (value === null || typeof value !== 'object') return undefined;
1190
+ const rec = value as Record<string, unknown>;
1191
+ if (typeof rec.handler === 'function' && ('input' in rec || 'inputZod' in rec)) {
1192
+ return 'tool';
1193
+ }
1194
+ const isGuardrailAction =
1195
+ typeof rec.action === 'object' &&
1196
+ rec.action !== null &&
1197
+ 'on-violation' in (rec.action as Record<string, unknown>);
1198
+ if (isGuardrailAction && 'kind' in rec) return 'guardrail';
1199
+ if (typeof rec.instructions === 'string' && Array.isArray(rec.tools)) return 'agent';
1200
+ if (Array.isArray(rec.nodes) && Array.isArray(rec.edges)) return 'flow';
1201
+ return undefined;
1202
+ }
1203
+
1204
+ // -----------------------------------------------------------------------
1205
+ // Manifest builders
1206
+ // -----------------------------------------------------------------------
1207
+
1208
+ function buildTool(
1209
+ raw: unknown,
1210
+ relPath: string,
1211
+ converter: ZodConverter | undefined,
1212
+ ): Result<IndexedTool, IndexerError> {
1213
+ const rec = raw as Record<string, unknown>;
1214
+ if (typeof rec.id !== 'string') {
1215
+ return manifestErr(relPath, `'id' is missing or not a string`);
1216
+ }
1217
+
1218
+ const inputWire = resolveSchema(rec, 'input', 'inputZod', relPath, converter);
1219
+ if (inputWire.kind === 'err') return inputWire;
1220
+ const outputWire = resolveSchema(rec, 'output', 'outputZod', relPath, converter);
1221
+ if (outputWire.kind === 'err') return outputWire;
1222
+
1223
+ const tool: IndexedTool = {
1224
+ id: rec.id,
1225
+ ...(typeof rec.description === 'string' && { description: rec.description }),
1226
+ ...(typeof rec.version === 'string' && { version: rec.version }),
1227
+ input: inputWire.value,
1228
+ output: outputWire.value,
1229
+ ...(Array.isArray(rec.effects) && {
1230
+ effects: rec.effects as readonly Readonly<Record<string, unknown>>[],
1231
+ }),
1232
+ ...(Array.isArray(rec.needs) && {
1233
+ needs: rec.needs as readonly Readonly<Record<string, unknown>>[],
1234
+ }),
1235
+ ...(isObject(rec.needsSpec) && {
1236
+ needsSpec: rec.needsSpec as Readonly<Record<string, unknown>>,
1237
+ }),
1238
+ ...(typeof rec.sandbox === 'string' && { sandbox: rec.sandbox }),
1239
+ ...(isObject(rec.limits) && {
1240
+ limits: rec.limits as Readonly<Record<string, unknown>>,
1241
+ }),
1242
+ ...(isObject(rec.network) && {
1243
+ network: rec.network as Readonly<Record<string, unknown>>,
1244
+ }),
1245
+ ...(isObject(rec.spec) && { spec: rec.spec as Readonly<Record<string, unknown>> }),
1246
+ ...(typeof rec.mutating === 'boolean' && { mutating: rec.mutating }),
1247
+ modulePath: normalizeModulePath(relPath),
1248
+ };
1249
+ return { kind: 'ok', value: tool };
1250
+ }
1251
+
1252
+ function buildGuardrail(
1253
+ raw: unknown,
1254
+ relPath: string,
1255
+ converter: ZodConverter | undefined,
1256
+ ): Result<IndexedGuardrail, IndexerError> {
1257
+ const rec = raw as Record<string, unknown>;
1258
+ if (typeof rec.id !== 'string') {
1259
+ return manifestErr(relPath, `'id' is missing or not a string`);
1260
+ }
1261
+ if (typeof rec.kind !== 'string') {
1262
+ return manifestErr(relPath, `'kind' is missing or not a string`);
1263
+ }
1264
+ if (!isObject(rec.action)) {
1265
+ return manifestErr(relPath, `'action' is missing or not an object`);
1266
+ }
1267
+
1268
+ // configSchema resolution: look in three places, in priority order:
1269
+ // 1. guardrail.configZod (top-level, DefinedCheck-style)
1270
+ // 2. guardrail.check.configZod (nested inside the check object)
1271
+ // 3. guardrail.configSchema / guardrail.check.configSchema (already JSON Schema)
1272
+ const check = isObject(rec.check) ? (rec.check as Record<string, unknown>) : undefined;
1273
+ const zodConfig =
1274
+ (isZodSchema(rec.configZod) ? (rec.configZod as ZodLikeSchema) : undefined) ??
1275
+ (check && isZodSchema(check.configZod) ? (check.configZod as ZodLikeSchema) : undefined);
1276
+ let configSchema: Readonly<Record<string, unknown>> | undefined;
1277
+ if (zodConfig !== undefined) {
1278
+ const converted = toJSONSchemaSync(zodConfig, converter, 'input');
1279
+ if (converted.kind === 'err') {
1280
+ return {
1281
+ kind: 'err',
1282
+ error: {
1283
+ code: 'zod-conversion-failed',
1284
+ message: `Guardrail ${rec.id} configSchema Zod conversion failed: ${converted.error.message}`,
1285
+ filePath: relPath,
1286
+ field: 'configSchema',
1287
+ cause: converted.error.cause,
1288
+ },
1289
+ };
1290
+ }
1291
+ configSchema = converted.value;
1292
+ } else if (isObject(rec.configSchema)) {
1293
+ configSchema = rec.configSchema as Readonly<Record<string, unknown>>;
1294
+ } else if (check !== undefined && isObject(check.configSchema)) {
1295
+ configSchema = check.configSchema as Readonly<Record<string, unknown>>;
1296
+ } else if (check !== undefined && isObject(check.configJsonSchema)) {
1297
+ // `defineCheck` (guardrails pkg) already stores the converted JSON
1298
+ // Schema on `configJsonSchema` — accept that shape without a second
1299
+ // conversion pass.
1300
+ configSchema = check.configJsonSchema as Readonly<Record<string, unknown>>;
1301
+ }
1302
+
1303
+ const checkId: string | undefined =
1304
+ typeof rec.check === 'string'
1305
+ ? rec.check
1306
+ : check !== undefined && typeof check.id === 'string'
1307
+ ? check.id
1308
+ : undefined;
1309
+
1310
+ const guardrail: IndexedGuardrail = {
1311
+ id: rec.id,
1312
+ ...(typeof rec.name === 'string' && { name: rec.name }),
1313
+ kind: rec.kind,
1314
+ action: rec.action as Readonly<Record<string, unknown>>,
1315
+ ...(typeof rec.severity === 'string' && { severity: rec.severity }),
1316
+ ...(isObject(rec.scope) && {
1317
+ scope: rec.scope as Readonly<Record<string, unknown>>,
1318
+ }),
1319
+ checkModulePath: normalizeModulePath(relPath),
1320
+ ...(checkId !== undefined && { checkId }),
1321
+ ...(configSchema !== undefined && { configSchema }),
1322
+ ...(isObject(rec.config) && { config: rec.config as Readonly<Record<string, unknown>> }),
1323
+ ...(typeof rec.sandbox === 'string' && { sandbox: rec.sandbox }),
1324
+ ...(isObject(rec.limits) && {
1325
+ limits: rec.limits as Readonly<Record<string, unknown>>,
1326
+ }),
1327
+ ...(isObject(rec.network) && {
1328
+ network: rec.network as Readonly<Record<string, unknown>>,
1329
+ }),
1330
+ };
1331
+ return { kind: 'ok', value: guardrail };
1332
+ }
1333
+
1334
+ function buildAgent(raw: unknown, relPath: string): Result<IndexedAgent, IndexerError> {
1335
+ const rec = raw as Record<string, unknown>;
1336
+ if (typeof rec.id !== 'string') {
1337
+ return manifestErr(relPath, `'id' is missing or not a string`);
1338
+ }
1339
+ if (typeof rec.version !== 'string') {
1340
+ return manifestErr(relPath, `'version' is missing or not a string`);
1341
+ }
1342
+ if (typeof rec.name !== 'string') {
1343
+ return manifestErr(relPath, `'name' is missing or not a string`);
1344
+ }
1345
+ if (typeof rec.instructions !== 'string') {
1346
+ return manifestErr(relPath, `'instructions' is missing or not a string`);
1347
+ }
1348
+ if (!Array.isArray(rec.capabilities)) {
1349
+ return manifestErr(relPath, `'capabilities' is missing or not an array`);
1350
+ }
1351
+ if (!Array.isArray(rec.tools)) {
1352
+ return manifestErr(relPath, `'tools' is missing or not an array`);
1353
+ }
1354
+ const agent: IndexedAgent = {
1355
+ id: rec.id,
1356
+ version: rec.version,
1357
+ name: rec.name,
1358
+ instructions: rec.instructions,
1359
+ capabilities: rec.capabilities as readonly unknown[],
1360
+ tools: rec.tools as readonly { readonly id: string; readonly version: string }[],
1361
+ ...optionalAgentFields(rec),
1362
+ modulePath: normalizeModulePath(relPath),
1363
+ };
1364
+ return { kind: 'ok', value: agent };
1365
+ }
1366
+
1367
+ /** The agent's optional fields, carried when present with the right shape. */
1368
+ function optionalAgentFields(rec: Record<string, unknown>): Partial<IndexedAgent> {
1369
+ const out: Record<string, unknown> = {};
1370
+ for (const key of ['retrieval', 'guardrails', 'parameters', 'tags'] as const) {
1371
+ if (Array.isArray(rec[key])) out[key] = rec[key];
1372
+ }
1373
+ for (const key of ['budget', 'conversationPolicy', 'output', 'toolErrors'] as const) {
1374
+ if (isObject(rec[key])) out[key] = rec[key];
1375
+ }
1376
+ for (const key of ['preferredProvider', 'preferredModel', 'description'] as const) {
1377
+ if (typeof rec[key] === 'string') out[key] = rec[key];
1378
+ }
1379
+ return out as Partial<IndexedAgent>;
1380
+ }
1381
+
1382
+ /**
1383
+ * Validate a discovered flow with the same loader the API uses, so a
1384
+ * broken flow fails indexing with the loader's message instead of
1385
+ * failing later at publish or run time, and carry every field it has.
1386
+ */
1387
+ function buildGraph(raw: unknown, relPath: string): Result<IndexedFlow, IndexerError> {
1388
+ const loaded = loadFlow(raw);
1389
+ if (loaded.kind === 'err') return manifestErr(relPath, loaded.error.message);
1390
+ const f = loaded.value;
1391
+ const flow: IndexedFlow = {
1392
+ id: f.id as unknown as string,
1393
+ version: f.version,
1394
+ ...(f.name !== undefined && { name: f.name }),
1395
+ ...(f.description !== undefined && { description: f.description }),
1396
+ nodes: f.nodes,
1397
+ edges: f.edges,
1398
+ ...(f.maxParallelism !== undefined && { maxParallelism: f.maxParallelism }),
1399
+ ...(f.metadata !== undefined && { metadata: f.metadata }),
1400
+ ...(f.output !== undefined && { output: f.output }),
1401
+ kernelPayloadVersion: KERNEL_PAYLOAD_VERSION,
1402
+ modulePath: normalizeModulePath(relPath),
1403
+ };
1404
+ return { kind: 'ok', value: flow };
1405
+ }
1406
+
1407
+ function manifestErr(relPath: string, msg: string): Result<never, IndexerError> {
1408
+ return {
1409
+ kind: 'err',
1410
+ error: {
1411
+ code: 'manifest-validation-failed',
1412
+ message: `${relPath}: ${msg}`,
1413
+ filePath: relPath,
1414
+ },
1415
+ };
1416
+ }
1417
+
1418
+ // -----------------------------------------------------------------------
1419
+ // Schema resolution — Zod → JSON Schema pass
1420
+ // -----------------------------------------------------------------------
1421
+
1422
+ function resolveSchema(
1423
+ rec: Record<string, unknown>,
1424
+ wireField: 'input' | 'output',
1425
+ zodField: string,
1426
+ relPath: string,
1427
+ converter: ZodConverter | undefined,
1428
+ ): Result<Readonly<Record<string, unknown>>, IndexerError> {
1429
+ // A tool's `input` is what a caller sends; its `output`, what it returns.
1430
+ const side = wireField;
1431
+ const zodValue = rec[zodField];
1432
+ if (zodValue !== undefined && isZodSchema(zodValue)) {
1433
+ const converted = toJSONSchemaSync(zodValue as AnySchema, converter, side);
1434
+ if (converted.kind === 'err') {
1435
+ return {
1436
+ kind: 'err',
1437
+ error: {
1438
+ code: 'zod-conversion-failed',
1439
+ message: `${relPath}: Zod conversion for '${zodField}' failed: ${converted.error.message}`,
1440
+ filePath: relPath,
1441
+ field: zodField,
1442
+ cause: converted.error.cause,
1443
+ },
1444
+ };
1445
+ }
1446
+ return { kind: 'ok', value: converted.value };
1447
+ }
1448
+ const wireValue = rec[wireField];
1449
+ // `wireValue` may itself be a Zod schema (author-time authored with Zod
1450
+ // but not yet run through defineTool). Convert lazily in that case.
1451
+ if (isZodSchema(wireValue)) {
1452
+ const converted = toJSONSchemaSync(wireValue as AnySchema, converter, side);
1453
+ if (converted.kind === 'err') {
1454
+ return {
1455
+ kind: 'err',
1456
+ error: {
1457
+ code: 'zod-conversion-failed',
1458
+ message: `${relPath}: Zod conversion for '${wireField}' failed: ${converted.error.message}`,
1459
+ filePath: relPath,
1460
+ field: wireField,
1461
+ cause: converted.error.cause,
1462
+ },
1463
+ };
1464
+ }
1465
+ return { kind: 'ok', value: converted.value };
1466
+ }
1467
+ if (!isObject(wireValue)) {
1468
+ return {
1469
+ kind: 'err',
1470
+ error: {
1471
+ code: 'manifest-validation-failed',
1472
+ message: `${relPath}: '${wireField}' is missing or not an object`,
1473
+ filePath: relPath,
1474
+ field: wireField,
1475
+ },
1476
+ };
1477
+ }
1478
+ return { kind: 'ok', value: wireValue as Readonly<Record<string, unknown>> };
1479
+ }
1480
+
1481
+ // -----------------------------------------------------------------------
1482
+ // Output serialization — atomic write + auto artifact version
1483
+ // -----------------------------------------------------------------------
1484
+
1485
+ async function atomicWriteJson(
1486
+ outputPath: string,
1487
+ index: Index,
1488
+ ): Promise<Result<undefined, IndexerError>> {
1489
+ const tmpPath = `${outputPath}.tmp-${process.pid}-${Date.now()}`;
1490
+ const serialized = `${stableStringify(index)}\n`;
1491
+ try {
1492
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
1493
+ await fs.writeFile(tmpPath, serialized, 'utf8');
1494
+ await fs.rename(tmpPath, outputPath);
1495
+ } catch (cause) {
1496
+ // Best-effort tmp cleanup — swallow errors here (the original write
1497
+ // error is what the caller needs).
1498
+ try {
1499
+ await fs.unlink(tmpPath);
1500
+ } catch {
1501
+ // ignore
1502
+ }
1503
+ return {
1504
+ kind: 'err',
1505
+ error: {
1506
+ code: 'output-write-failed',
1507
+ message: `Failed to write ${outputPath}: ${stringifyError(cause)}`,
1508
+ filePath: outputPath,
1509
+ cause: serializeCause(cause),
1510
+ },
1511
+ };
1512
+ }
1513
+ return { kind: 'ok', value: undefined };
1514
+ }
1515
+
1516
+ /**
1517
+ * Stable JSON serializer — keys sorted at every level. Guarantees
1518
+ * byte-identical output for byte-identical semantic input, which is the
1519
+ * property the local integrity gate depends on.
1520
+ */
1521
+ function stableStringify(value: unknown): string {
1522
+ return JSON.stringify(canonicalize(value), null, 2);
1523
+ }
1524
+
1525
+ function canonicalize(value: unknown): unknown {
1526
+ if (value === null || typeof value !== 'object') return value;
1527
+ if (Array.isArray(value)) return value.map(canonicalize);
1528
+ const rec = value as Record<string, unknown>;
1529
+ const out: Record<string, unknown> = {};
1530
+ for (const key of Object.keys(rec).sort()) {
1531
+ out[key] = canonicalize(rec[key]);
1532
+ }
1533
+ return out;
1534
+ }
1535
+
1536
+ async function computeAutoArtifactVersion(outputPath: string): Promise<string> {
1537
+ const today = new Date();
1538
+ const yyyymmdd =
1539
+ today.getUTCFullYear().toString().padStart(4, '0') +
1540
+ (today.getUTCMonth() + 1).toString().padStart(2, '0') +
1541
+ today.getUTCDate().toString().padStart(2, '0');
1542
+
1543
+ let n = 1;
1544
+ try {
1545
+ const existing = await fs.readFile(outputPath, 'utf8');
1546
+ const parsed = JSON.parse(existing) as { artifactVersion?: unknown };
1547
+ if (typeof parsed.artifactVersion === 'string') {
1548
+ const match = /^(\d{8})\.(\d+)$/.exec(parsed.artifactVersion);
1549
+ const dayPart = match?.[1];
1550
+ const nPart = match?.[2];
1551
+ if (dayPart === yyyymmdd && nPart !== undefined) {
1552
+ const parsedN = Number.parseInt(nPart, 10);
1553
+ if (Number.isFinite(parsedN)) n = parsedN + 1;
1554
+ }
1555
+ }
1556
+ } catch {
1557
+ // No prior index.json — start at .1
1558
+ }
1559
+ return `${yyyymmdd}.${n}`;
1560
+ }
1561
+
1562
+ // -----------------------------------------------------------------------
1563
+ // Utilities
1564
+ // -----------------------------------------------------------------------
1565
+
1566
+ function isObject(v: unknown): v is Readonly<Record<string, unknown>> {
1567
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
1568
+ }
1569
+
1570
+ function sortById<T extends { readonly id: string }>(items: readonly T[]): readonly T[] {
1571
+ return [...items].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1572
+ }
1573
+
1574
+ function stringifyError(err: unknown): string {
1575
+ if (err instanceof Error) return `${err.name}: ${err.message}`;
1576
+ return String(err);
1577
+ }
1578
+
1579
+ function serializeCause(err: unknown): unknown {
1580
+ if (err instanceof Error) {
1581
+ return { name: err.name, message: err.message, stack: err.stack };
1582
+ }
1583
+ if (err === undefined) return null;
1584
+ if (
1585
+ err === null ||
1586
+ typeof err === 'string' ||
1587
+ typeof err === 'number' ||
1588
+ typeof err === 'boolean'
1589
+ ) {
1590
+ return err;
1591
+ }
1592
+ try {
1593
+ JSON.stringify(err);
1594
+ return err;
1595
+ } catch {
1596
+ return String(err);
1597
+ }
1598
+ }
1599
+
1600
+ function normalizeModulePath(relPath: string): string {
1601
+ // Forward slashes only, no leading './'
1602
+ let normalized = relPath.replace(/\\/g, '/');
1603
+ if (normalized.startsWith('./')) normalized = normalized.slice(2);
1604
+ return normalized;
1605
+ }