@kontourai/flow-agents 3.6.0 → 3.7.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.
- package/CHANGELOG.md +7 -0
- package/build/src/builder-flow-run-adapter.d.ts +22 -2
- package/build/src/builder-flow-run-adapter.js +93 -28
- package/build/src/builder-flow-runtime.d.ts +8 -3
- package/build/src/builder-flow-runtime.js +308 -47
- package/build/src/cli/assignment-provider.d.ts +4 -7
- package/build/src/cli/assignment-provider.js +67 -13
- package/build/src/cli/builder-run.js +14 -18
- package/build/src/cli/workflow-sidecar.d.ts +28 -4
- package/build/src/cli/workflow-sidecar.js +801 -97
- package/build/src/cli/workflow.js +290 -42
- package/build/src/flow-kit/validate.d.ts +17 -0
- package/build/src/flow-kit/validate.js +340 -2
- package/build/src/index.js +5 -5
- package/build/src/lib/observed-command.d.ts +7 -0
- package/build/src/lib/observed-command.js +100 -0
- package/build/src/tools/generate-context-map.js +5 -3
- package/context/scripts/hooks/lib/kit-catalog.js +1 -1
- package/context/scripts/hooks/stop-goal-fit.js +78 -9
- package/docs/context-map.md +22 -20
- package/docs/developer-architecture.md +1 -1
- package/docs/public-workflow-cli.md +76 -7
- package/docs/skills-map.md +51 -27
- package/docs/spec/builder-flow-runtime.md +41 -40
- package/docs/workflow-usage-guide.md +109 -42
- package/evals/fixtures/hook-influence/cases.json +2 -2
- package/evals/integration/test_builder_entry_enforcement.sh +52 -41
- package/evals/integration/test_builder_step_producers.sh +297 -6
- package/evals/integration/test_bundle_install.sh +212 -63
- package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
- package/evals/integration/test_current_json_per_actor.sh +12 -0
- package/evals/integration/test_dual_emit_flow_step.sh +62 -43
- package/evals/integration/test_flowdef_session_activation.sh +145 -25
- package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
- package/evals/integration/test_gate_lockdown.sh +3 -5
- package/evals/integration/test_goal_fit_hook.sh +60 -2
- package/evals/integration/test_liveness_verdict.sh +14 -17
- package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
- package/evals/integration/test_public_workflow_cli.sh +325 -11
- package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
- package/evals/integration/test_sidecar_field_preservation.sh +36 -11
- package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
- package/evals/integration/test_workflow_steering_hook.sh +15 -38
- package/evals/run.sh +2 -0
- package/evals/static/test_builder_skill_coherence.sh +247 -0
- package/evals/static/test_library_exports.sh +5 -2
- package/evals/static/test_workflow_skills.sh +13 -325
- package/kits/builder/flows/build.flow.json +22 -0
- package/kits/builder/flows/shape.flow.json +9 -9
- package/kits/builder/kit.json +70 -16
- package/kits/builder/skills/builder-shape/SKILL.md +75 -75
- package/kits/builder/skills/continue-work/SKILL.md +45 -106
- package/kits/builder/skills/deliver/SKILL.md +96 -442
- package/kits/builder/skills/design-probe/SKILL.md +40 -139
- package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
- package/kits/builder/skills/execute-plan/SKILL.md +54 -125
- package/kits/builder/skills/fix-bug/SKILL.md +42 -132
- package/kits/builder/skills/gate-review/SKILL.md +60 -211
- package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
- package/kits/builder/skills/learning-review/SKILL.md +63 -170
- package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
- package/kits/builder/skills/plan-work/SKILL.md +51 -185
- package/kits/builder/skills/pull-work/SKILL.md +136 -485
- package/kits/builder/skills/release-readiness/SKILL.md +66 -107
- package/kits/builder/skills/review-work/SKILL.md +89 -176
- package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
- package/kits/builder/skills/verify-work/SKILL.md +101 -113
- package/package.json +2 -2
- package/scripts/hooks/lib/kit-catalog.js +1 -1
- package/scripts/hooks/stop-goal-fit.js +78 -9
- package/src/builder-flow-run-adapter.ts +118 -32
- package/src/builder-flow-runtime.ts +331 -61
- package/src/cli/assignment-provider-lock.test.mjs +83 -0
- package/src/cli/assignment-provider.ts +62 -13
- package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
- package/src/cli/builder-flow-runtime.test.mjs +194 -8
- package/src/cli/builder-run.ts +3 -9
- package/src/cli/kit-metadata-security.test.mjs +232 -6
- package/src/cli/public-api.test.mjs +15 -0
- package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
- package/src/cli/workflow-sidecar.ts +724 -97
- package/src/cli/workflow.ts +277 -40
- package/src/flow-kit/validate.ts +320 -2
- package/src/index.ts +6 -5
- package/src/lib/observed-command.ts +96 -0
- package/src/tools/generate-context-map.ts +5 -3
package/src/flow-kit/validate.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { readJson } from "../lib/fs.js";
|
|
4
4
|
|
|
@@ -17,7 +17,7 @@ const AGENT_EXTENSION_CLASSES = new Set(["skills", "docs", "adapters", "evals",
|
|
|
17
17
|
// workflow_triggers / hook_influence_expectations declare kit-owned runtime influence,
|
|
18
18
|
// and first_party is legacy catalog/marketplace metadata. It does not grant runtime
|
|
19
19
|
// capability or steering privilege.
|
|
20
|
-
const KNOWN_METADATA_FIELDS = new Set(["dependencies", "workflow_triggers", "hook_influence_expectations", "flow_step_actions", "first_party"]);
|
|
20
|
+
const KNOWN_METADATA_FIELDS = new Set(["dependencies", "workflow_triggers", "hook_influence_expectations", "flow_step_actions", "skill_roles", "first_party"]);
|
|
21
21
|
|
|
22
22
|
export interface KitDependencyEntry {
|
|
23
23
|
kit_id: string;
|
|
@@ -49,6 +49,89 @@ export interface KitFlowStepActionEntry {
|
|
|
49
49
|
step_id: string;
|
|
50
50
|
skills: string[];
|
|
51
51
|
operations: string[];
|
|
52
|
+
/** Gate expectations produced by this action's explicit operation(s). */
|
|
53
|
+
expectation_ids?: string[];
|
|
54
|
+
/** Durable artifacts produced by an operation-only action. */
|
|
55
|
+
artifacts: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type KitSkillRole = "entrypoint" | "profile" | "step" | "shared-primitive" | "extension";
|
|
59
|
+
|
|
60
|
+
export interface KitSkillRoleEntry {
|
|
61
|
+
skill_id: string;
|
|
62
|
+
role: KitSkillRole;
|
|
63
|
+
flow_id?: string;
|
|
64
|
+
step_ids: string[];
|
|
65
|
+
artifacts: string[];
|
|
66
|
+
expectation_ids: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const MAX_FLOW_DEFINITION_BYTES = 1024 * 1024;
|
|
70
|
+
|
|
71
|
+
function sameFileIdentity(left: fs.Stats, right: fs.Stats): boolean {
|
|
72
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lstatSafePath(root: string, relativePath: string): { file?: string; stat?: fs.Stats; error?: string } {
|
|
76
|
+
const parts = relativePath.split(path.sep);
|
|
77
|
+
let current = root;
|
|
78
|
+
for (const [index, part] of parts.entries()) {
|
|
79
|
+
if (!part || part === "." || part === "..") return { error: "path must stay inside the kit directory" };
|
|
80
|
+
current = path.join(current, part);
|
|
81
|
+
const stat = fs.lstatSync(current);
|
|
82
|
+
if (stat.isSymbolicLink()) return { error: "path must not traverse a symbolic link" };
|
|
83
|
+
if (index < parts.length - 1 && !stat.isDirectory()) return { error: "path component must be a directory" };
|
|
84
|
+
if (index === parts.length - 1) return { file: current, stat };
|
|
85
|
+
}
|
|
86
|
+
return { error: "path must reference a regular file" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readSafeFlowDefinition(kitDir: string, relativePath: string): { definition?: Record<string, unknown>; error?: string } {
|
|
90
|
+
const root = path.resolve(kitDir);
|
|
91
|
+
if (path.isAbsolute(relativePath)) return { error: "path must be relative" };
|
|
92
|
+
const lexicalFile = path.resolve(root, relativePath);
|
|
93
|
+
if (lexicalFile === root || !lexicalFile.startsWith(`${root}${path.sep}`)) return { error: "path must stay inside the kit directory" };
|
|
94
|
+
let rootDescriptor: number | undefined;
|
|
95
|
+
let descriptor: number | undefined;
|
|
96
|
+
try {
|
|
97
|
+
const realRoot = fs.realpathSync.native(root);
|
|
98
|
+
rootDescriptor = fs.openSync(realRoot, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
|
99
|
+
const rootIdentity = fs.fstatSync(rootDescriptor);
|
|
100
|
+
if (!rootIdentity.isDirectory() || !sameFileIdentity(rootIdentity, fs.lstatSync(realRoot))) return { error: "kit directory identity changed while opening flow definition" };
|
|
101
|
+
|
|
102
|
+
const initialPath = lstatSafePath(realRoot, relativePath);
|
|
103
|
+
if (!initialPath.file || !initialPath.stat) return { error: initialPath.error };
|
|
104
|
+
if (!initialPath.stat.isFile()) return { error: "path must reference a regular file" };
|
|
105
|
+
if (initialPath.stat.size > MAX_FLOW_DEFINITION_BYTES) return { error: `file exceeds ${MAX_FLOW_DEFINITION_BYTES} bytes` };
|
|
106
|
+
|
|
107
|
+
// O_NOFOLLOW protects the terminal component. Identity checks before and after
|
|
108
|
+
// opening make a race on any intermediate component fail closed on macOS/Node,
|
|
109
|
+
// where openat-style traversal from a directory descriptor is not exposed.
|
|
110
|
+
descriptor = fs.openSync(initialPath.file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
111
|
+
const stat = fs.fstatSync(descriptor);
|
|
112
|
+
if (!stat.isFile()) return { error: "path must reference a regular file" };
|
|
113
|
+
if (!sameFileIdentity(initialPath.stat, stat)) return { error: "flow definition identity changed while opening" };
|
|
114
|
+
if (stat.size > MAX_FLOW_DEFINITION_BYTES) return { error: `file exceeds ${MAX_FLOW_DEFINITION_BYTES} bytes` };
|
|
115
|
+
const verifiedPath = lstatSafePath(realRoot, relativePath);
|
|
116
|
+
if (!verifiedPath.stat || !sameFileIdentity(stat, verifiedPath.stat)) return { error: "flow definition identity changed while opening" };
|
|
117
|
+
|
|
118
|
+
const buffer = Buffer.alloc(stat.size);
|
|
119
|
+
let offset = 0;
|
|
120
|
+
while (offset < buffer.length) {
|
|
121
|
+
const read = fs.readSync(descriptor, buffer, offset, buffer.length - offset, offset);
|
|
122
|
+
if (read === 0) break;
|
|
123
|
+
offset += read;
|
|
124
|
+
}
|
|
125
|
+
const finalStat = fs.fstatSync(descriptor);
|
|
126
|
+
if (offset !== stat.size || finalStat.size !== stat.size || !sameFileIdentity(stat, finalStat)) return { error: "file changed while being read" };
|
|
127
|
+
return { definition: JSON.parse(buffer.toString("utf8")) as Record<string, unknown> };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if ((error as NodeJS.ErrnoException).code === "ELOOP") return { error: "path must not reference a symbolic link" };
|
|
130
|
+
return { error: `path is not readable: ${(error as Error).message}` };
|
|
131
|
+
} finally {
|
|
132
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
133
|
+
if (rootDescriptor !== undefined) fs.closeSync(rootDescriptor);
|
|
134
|
+
}
|
|
52
135
|
}
|
|
53
136
|
|
|
54
137
|
/**
|
|
@@ -290,6 +373,8 @@ export function parseKitFlowStepActions(manifest: Record<string, unknown>, manif
|
|
|
290
373
|
}
|
|
291
374
|
const skills = record.skills;
|
|
292
375
|
const operations = record.operations ?? [];
|
|
376
|
+
const expectationIds = record.expectation_ids;
|
|
377
|
+
const artifacts = record.artifacts ?? [];
|
|
293
378
|
if (!Array.isArray(skills) || !skills.every(workflowTriggerIdentifier)) {
|
|
294
379
|
errors.push(`${manifestPath}: flow_step_actions[${index}].skills must be an identifier list`);
|
|
295
380
|
return;
|
|
@@ -306,6 +391,14 @@ export function parseKitFlowStepActions(manifest: Record<string, unknown>, manif
|
|
|
306
391
|
errors.push(`${manifestPath}: flow_step_actions[${index}].operations must not contain duplicates`);
|
|
307
392
|
return;
|
|
308
393
|
}
|
|
394
|
+
if (expectationIds !== undefined && (!Array.isArray(expectationIds) || !expectationIds.every(workflowTriggerIdentifier) || new Set(expectationIds).size !== expectationIds.length)) {
|
|
395
|
+
errors.push(`${manifestPath}: flow_step_actions[${index}].expectation_ids must be a unique identifier list when present`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!Array.isArray(artifacts) || !artifacts.every((artifact) => typeof artifact === "string" && artifact.trim().length > 0) || new Set(artifacts).size !== artifacts.length) {
|
|
399
|
+
errors.push(`${manifestPath}: flow_step_actions[${index}].artifacts must be a unique non-empty string list when present`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
309
402
|
const key = `${record.flow_id}/${record.step_id}`;
|
|
310
403
|
if (seen.has(key)) {
|
|
311
404
|
errors.push(`${manifestPath}: flow_step_actions[${index}] duplicates '${key}'`);
|
|
@@ -317,11 +410,77 @@ export function parseKitFlowStepActions(manifest: Record<string, unknown>, manif
|
|
|
317
410
|
step_id: record.step_id,
|
|
318
411
|
skills: skills as string[],
|
|
319
412
|
operations: operations as string[],
|
|
413
|
+
artifacts: artifacts as string[],
|
|
414
|
+
...(Array.isArray(expectationIds) ? { expectation_ids: expectationIds as string[] } : {}),
|
|
320
415
|
});
|
|
321
416
|
});
|
|
322
417
|
return { entries, errors };
|
|
323
418
|
}
|
|
324
419
|
|
|
420
|
+
export function parseKitSkillRoles(manifest: Record<string, unknown>, manifestPath: string): { entries: KitSkillRoleEntry[]; errors: string[] } {
|
|
421
|
+
const entries: KitSkillRoleEntry[] = [];
|
|
422
|
+
const errors: string[] = [];
|
|
423
|
+
const raw = manifest.skill_roles;
|
|
424
|
+
if (raw === undefined) return { entries, errors };
|
|
425
|
+
if (!Array.isArray(raw)) {
|
|
426
|
+
errors.push(`${manifestPath}: .skill_roles must be a list`);
|
|
427
|
+
return { entries, errors };
|
|
428
|
+
}
|
|
429
|
+
const roles = new Set<KitSkillRole>(["entrypoint", "profile", "step", "shared-primitive", "extension"]);
|
|
430
|
+
const fields = new Set(["skill_id", "role", "flow_id", "step_ids", "artifacts", "expectation_ids"]);
|
|
431
|
+
const seen = new Set<string>();
|
|
432
|
+
raw.forEach((entry, index) => {
|
|
433
|
+
if (typeof entry !== "object" || entry === null) {
|
|
434
|
+
errors.push(`${manifestPath}: skill_roles[${index}] must be an object`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const record = entry as Record<string, unknown>;
|
|
438
|
+
const unknown = Object.keys(record).filter((field) => !fields.has(field));
|
|
439
|
+
if (unknown.length > 0) errors.push(`${manifestPath}: skill_roles[${index}] contains unsupported field(s): ${unknown.join(", ")}`);
|
|
440
|
+
if (!workflowTriggerIdentifier(record.skill_id)) {
|
|
441
|
+
errors.push(`${manifestPath}: skill_roles[${index}].skill_id must be an identifier`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (seen.has(record.skill_id)) errors.push(`${manifestPath}: skill_roles[${index}].skill_id duplicates '${record.skill_id}'`);
|
|
445
|
+
seen.add(record.skill_id);
|
|
446
|
+
if (typeof record.role !== "string" || !roles.has(record.role as KitSkillRole)) {
|
|
447
|
+
errors.push(`${manifestPath}: skill_roles[${index}].role must be entrypoint, profile, step, shared-primitive, or extension`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const role = record.role as KitSkillRole;
|
|
451
|
+
if (record.flow_id !== undefined && !workflowTriggerIdentifier(record.flow_id)) {
|
|
452
|
+
errors.push(`${manifestPath}: skill_roles[${index}].flow_id must be a Flow identifier when present`);
|
|
453
|
+
}
|
|
454
|
+
for (const field of ["step_ids", "expectation_ids"] as const) {
|
|
455
|
+
const value = record[field];
|
|
456
|
+
if (!Array.isArray(value) || !value.every(workflowTriggerIdentifier) || new Set(value).size !== value.length) {
|
|
457
|
+
errors.push(`${manifestPath}: skill_roles[${index}].${field} must be a unique identifier list`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!Array.isArray(record.artifacts) || !record.artifacts.every((artifact) => typeof artifact === "string" && artifact.trim().length > 0) || new Set(record.artifacts).size !== record.artifacts.length) {
|
|
461
|
+
errors.push(`${manifestPath}: skill_roles[${index}].artifacts must be a unique non-empty string list`);
|
|
462
|
+
}
|
|
463
|
+
const stepIds = Array.isArray(record.step_ids) ? record.step_ids.filter(workflowTriggerIdentifier) : [];
|
|
464
|
+
const expectationIds = Array.isArray(record.expectation_ids) ? record.expectation_ids.filter(workflowTriggerIdentifier) : [];
|
|
465
|
+
const artifacts = Array.isArray(record.artifacts) ? record.artifacts.filter((artifact): artifact is string => typeof artifact === "string" && artifact.trim().length > 0) : [];
|
|
466
|
+
const flowId = typeof record.flow_id === "string" && workflowTriggerIdentifier(record.flow_id) ? record.flow_id : undefined;
|
|
467
|
+
if ((role === "entrypoint" || role === "profile") && (!flowId || stepIds.length > 0 || expectationIds.length > 0 || artifacts.length > 0)) {
|
|
468
|
+
errors.push(`${manifestPath}: skill_roles[${index}] ${role} must select one flow and own no steps, artifacts, or expectations`);
|
|
469
|
+
}
|
|
470
|
+
if (role === "step" && (!flowId || stepIds.length === 0 || artifacts.length === 0)) {
|
|
471
|
+
errors.push(`${manifestPath}: skill_roles[${index}] step must bind one flow, at least one step, and at least one artifact`);
|
|
472
|
+
}
|
|
473
|
+
if (role === "shared-primitive" && (flowId || stepIds.length > 0 || expectationIds.length > 0 || artifacts.length === 0)) {
|
|
474
|
+
errors.push(`${manifestPath}: skill_roles[${index}] ${role} must own artifacts but no Builder flow, steps, or expectations`);
|
|
475
|
+
}
|
|
476
|
+
if (role === "extension" && (flowId || stepIds.length > 0 || expectationIds.length > 0)) {
|
|
477
|
+
errors.push(`${manifestPath}: skill_roles[${index}] extension must own no Builder flow, steps, or expectations`);
|
|
478
|
+
}
|
|
479
|
+
entries.push({ skill_id: record.skill_id, role, ...(flowId ? { flow_id: flowId } : {}), step_ids: stepIds, artifacts, expectation_ids: expectationIds });
|
|
480
|
+
});
|
|
481
|
+
return { entries, errors };
|
|
482
|
+
}
|
|
483
|
+
|
|
325
484
|
export type KitTargetConsumer =
|
|
326
485
|
| "flow"
|
|
327
486
|
| "flow-agents"
|
|
@@ -555,6 +714,165 @@ export async function validateKitRepository(kitDir: string): Promise<string[]> {
|
|
|
555
714
|
for (const err of hookExpectationResult.errors) errors.push(err);
|
|
556
715
|
const flowStepActionResult = parseKitFlowStepActions(manifest, manifestPath);
|
|
557
716
|
for (const err of flowStepActionResult.errors) errors.push(err);
|
|
717
|
+
const skillRoleResult = parseKitSkillRoles(manifest, manifestPath);
|
|
718
|
+
for (const err of skillRoleResult.errors) errors.push(err);
|
|
719
|
+
if (manifest.skill_roles !== undefined && skillRoleResult.errors.length === 0) {
|
|
720
|
+
const declaredSkillIds = new Set(
|
|
721
|
+
Array.isArray(manifest.skills)
|
|
722
|
+
? manifest.skills.flatMap((entry) => typeof entry === "object" && entry !== null && typeof (entry as Record<string, unknown>).id === "string" ? [(entry as Record<string, unknown>).id as string] : [])
|
|
723
|
+
: []
|
|
724
|
+
);
|
|
725
|
+
const roleSkillIds = new Set(skillRoleResult.entries.map((entry) => entry.skill_id));
|
|
726
|
+
for (const id of declaredSkillIds) if (!roleSkillIds.has(id)) errors.push(`${manifestPath}: skill_roles is missing declared skill '${id}'`);
|
|
727
|
+
for (const id of roleSkillIds) if (!declaredSkillIds.has(id)) errors.push(`${manifestPath}: skill_roles references undeclared skill '${id}'`);
|
|
728
|
+
|
|
729
|
+
type FlowExpectation = { id: string; exportKeys: Set<string> };
|
|
730
|
+
type FlowMetadata = {
|
|
731
|
+
steps: Set<string>;
|
|
732
|
+
expectationsByStep: Map<string, Map<string, FlowExpectation>>;
|
|
733
|
+
usesFlowByStep: Map<string, string>;
|
|
734
|
+
exports: Set<string>;
|
|
735
|
+
};
|
|
736
|
+
type EffectiveFlowStep = { sourceFlowId: string; stepId: string; expectations: Map<string, FlowExpectation>; flowIds: Set<string> };
|
|
737
|
+
const flows = new Map<string, FlowMetadata>();
|
|
738
|
+
if (Array.isArray(manifest.flows)) {
|
|
739
|
+
for (const entry of manifest.flows) {
|
|
740
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
741
|
+
const flow = entry as Record<string, unknown>;
|
|
742
|
+
if (typeof flow.id !== "string" || typeof flow.path !== "string") continue;
|
|
743
|
+
const safeDefinition = readSafeFlowDefinition(kitDir, flow.path);
|
|
744
|
+
if (!safeDefinition.definition) {
|
|
745
|
+
errors.push(`${manifestPath}: flows '${flow.id}' ${safeDefinition.error}`);
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
const definition = safeDefinition.definition;
|
|
750
|
+
const steps = new Set(Array.isArray(definition.steps) ? definition.steps.flatMap((step) => typeof step === "object" && step !== null && typeof (step as Record<string, unknown>).id === "string" ? [(step as Record<string, unknown>).id as string] : []) : []);
|
|
751
|
+
const usesFlowByStep = new Map<string, string>();
|
|
752
|
+
if (Array.isArray(definition.steps)) {
|
|
753
|
+
for (const step of definition.steps) {
|
|
754
|
+
if (typeof step !== "object" || step === null) continue;
|
|
755
|
+
const stepRecord = step as Record<string, unknown>;
|
|
756
|
+
if (typeof stepRecord.id === "string" && typeof stepRecord.uses_flow === "string") usesFlowByStep.set(stepRecord.id, stepRecord.uses_flow);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const expectationsByStep = new Map<string, Map<string, FlowExpectation>>();
|
|
760
|
+
if (typeof definition.gates === "object" && definition.gates !== null) {
|
|
761
|
+
for (const gate of Object.values(definition.gates as Record<string, unknown>)) {
|
|
762
|
+
if (typeof gate !== "object" || gate === null) continue;
|
|
763
|
+
const gateRecord = gate as Record<string, unknown>;
|
|
764
|
+
if (typeof gateRecord.step !== "string" || !Array.isArray(gateRecord.expects)) continue;
|
|
765
|
+
const expectations = expectationsByStep.get(gateRecord.step) ?? new Map<string, FlowExpectation>();
|
|
766
|
+
for (const expectation of gateRecord.expects) {
|
|
767
|
+
if (typeof expectation !== "object" || expectation === null) continue;
|
|
768
|
+
const expectationRecord = expectation as Record<string, unknown>;
|
|
769
|
+
if (typeof expectationRecord.id !== "string") continue;
|
|
770
|
+
const exportKeys = new Set([expectationRecord.id]);
|
|
771
|
+
const claimType = (expectationRecord.bundle_claim as Record<string, unknown> | undefined)?.claimType;
|
|
772
|
+
if (typeof claimType === "string") exportKeys.add(claimType);
|
|
773
|
+
expectations.set(expectationRecord.id, { id: expectationRecord.id, exportKeys });
|
|
774
|
+
}
|
|
775
|
+
expectationsByStep.set(gateRecord.step, expectations);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
flows.set(flow.id, {
|
|
779
|
+
steps,
|
|
780
|
+
expectationsByStep,
|
|
781
|
+
usesFlowByStep,
|
|
782
|
+
exports: new Set(Array.isArray(definition.exports) ? definition.exports.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) : []),
|
|
783
|
+
});
|
|
784
|
+
} catch {
|
|
785
|
+
// Core Flow validation reports missing or malformed definitions.
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const resolveEffectiveFlowStep = (flowId: string, stepId: string, seen = new Set<string>()): EffectiveFlowStep | undefined => {
|
|
790
|
+
const cycleKey = `${flowId}\u0000${stepId}`;
|
|
791
|
+
if (seen.has(cycleKey)) return undefined;
|
|
792
|
+
seen.add(cycleKey);
|
|
793
|
+
const flow = flows.get(flowId);
|
|
794
|
+
if (!flow) return undefined;
|
|
795
|
+
const direct = flow.expectationsByStep.get(stepId);
|
|
796
|
+
if (direct) return { sourceFlowId: flowId, stepId, expectations: direct, flowIds: new Set([flowId]) };
|
|
797
|
+
const childFlowId = flow.usesFlowByStep.get(stepId);
|
|
798
|
+
if (!childFlowId) return { sourceFlowId: flowId, stepId, expectations: new Map(), flowIds: new Set([flowId]) };
|
|
799
|
+
const child = resolveEffectiveFlowStep(childFlowId, stepId, seen);
|
|
800
|
+
const childFlow = flows.get(childFlowId);
|
|
801
|
+
if (!child || !childFlow || [...child.expectations.values()].some((expectation) => ![...expectation.exportKeys].some((key) => childFlow.exports.has(key)))) return undefined;
|
|
802
|
+
child.flowIds.add(flowId);
|
|
803
|
+
return child;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
for (const row of skillRoleResult.entries) {
|
|
807
|
+
if (!row.flow_id) continue;
|
|
808
|
+
const flow = flows.get(row.flow_id);
|
|
809
|
+
if (!flow) {
|
|
810
|
+
errors.push(`${manifestPath}: skill_roles '${row.skill_id}' references unknown flow '${row.flow_id}'`);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
for (const stepId of row.step_ids) if (!flow.steps.has(stepId)) errors.push(`${manifestPath}: skill_roles '${row.skill_id}' references unknown step '${row.flow_id}/${stepId}'`);
|
|
814
|
+
const allowedExpectations = new Set(row.step_ids.flatMap((stepId) => [...(resolveEffectiveFlowStep(row.flow_id!, stepId)?.expectations.keys() ?? [])]));
|
|
815
|
+
for (const expectationId of row.expectation_ids) if (!allowedExpectations.has(expectationId)) errors.push(`${manifestPath}: skill_roles '${row.skill_id}' expectation '${expectationId}' is not owned by its bound step(s)`);
|
|
816
|
+
}
|
|
817
|
+
const roleByShortId = new Map(skillRoleResult.entries.map((entry) => [entry.skill_id.replace(`${String(manifest.id)}.`, ""), entry]));
|
|
818
|
+
const producerOwners = new Map<string, string[]>();
|
|
819
|
+
for (const row of skillRoleResult.entries) {
|
|
820
|
+
if (row.role !== "step" || !row.flow_id) continue;
|
|
821
|
+
for (const stepId of row.step_ids) {
|
|
822
|
+
const effectiveStep = resolveEffectiveFlowStep(row.flow_id, stepId);
|
|
823
|
+
if (!effectiveStep) continue;
|
|
824
|
+
for (const expectationId of row.expectation_ids) {
|
|
825
|
+
if (!effectiveStep.expectations.has(expectationId)) continue;
|
|
826
|
+
const key = `${effectiveStep.sourceFlowId}\u0000${effectiveStep.stepId}\u0000${expectationId}`;
|
|
827
|
+
producerOwners.set(key, [...(producerOwners.get(key) ?? []), `skill:${row.skill_id}`]);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
for (const action of flowStepActionResult.entries) {
|
|
832
|
+
const containingFlow = flows.get(action.flow_id);
|
|
833
|
+
if (!containingFlow || !containingFlow.steps.has(action.step_id)) {
|
|
834
|
+
errors.push(`${manifestPath}: flow_step_actions '${action.flow_id}/${action.step_id}' references an unknown flow step`);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const effectiveStep = resolveEffectiveFlowStep(action.flow_id, action.step_id);
|
|
838
|
+
if (!effectiveStep) {
|
|
839
|
+
errors.push(`${manifestPath}: flow_step_actions '${action.flow_id}/${action.step_id}' cannot resolve its composed Flow step`);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
const expectedAtStep = effectiveStep.expectations;
|
|
843
|
+
for (const expectationId of action.expectation_ids ?? []) {
|
|
844
|
+
if (!expectedAtStep.has(expectationId)) errors.push(`${manifestPath}: flow_step_actions '${action.flow_id}/${action.step_id}' operation expectation '${expectationId}' is not owned by its resolved Flow step`);
|
|
845
|
+
}
|
|
846
|
+
if (action.skills.length === 0 && action.operations.length > 0 && expectedAtStep.size > 0 && !(action.expectation_ids?.length)) {
|
|
847
|
+
errors.push(`${manifestPath}: flow_step_actions '${action.flow_id}/${action.step_id}' operation-only action must explicitly declare expectation_ids`);
|
|
848
|
+
}
|
|
849
|
+
const actionRows: KitSkillRoleEntry[] = [];
|
|
850
|
+
for (const skill of action.skills) {
|
|
851
|
+
const row = roleByShortId.get(skill);
|
|
852
|
+
if (!row || row.role !== "step" || !row.flow_id || !effectiveStep.flowIds.has(row.flow_id) || !row.step_ids.includes(action.step_id)) {
|
|
853
|
+
errors.push(`${manifestPath}: flow_step_actions '${action.flow_id}/${action.step_id}' skill '${skill}' must match one step-role binding`);
|
|
854
|
+
} else {
|
|
855
|
+
actionRows.push(row);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
for (const expectationId of expectedAtStep.keys()) {
|
|
859
|
+
const key = `${effectiveStep.sourceFlowId}\u0000${effectiveStep.stepId}\u0000${expectationId}`;
|
|
860
|
+
const owners = producerOwners.get(key) ?? [];
|
|
861
|
+
if (action.operations.length > 0 && action.expectation_ids?.includes(expectationId)) owners.push(`operation:${action.flow_id}/${action.step_id}`);
|
|
862
|
+
producerOwners.set(key, owners);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
for (const [flowId, flow] of flows) {
|
|
866
|
+
for (const [stepId, expectations] of flow.expectationsByStep) {
|
|
867
|
+
for (const expectationId of expectations.keys()) {
|
|
868
|
+
const owners = producerOwners.get(`${flowId}\u0000${stepId}\u0000${expectationId}`) ?? [];
|
|
869
|
+
if (owners.length !== 1) {
|
|
870
|
+
errors.push(`${manifestPath}: flow expectation '${flowId}/${stepId}/${expectationId}' must have exactly one producer owner; found ${owners.length}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
558
876
|
if (manifest.first_party !== undefined && typeof manifest.first_party !== "boolean") {
|
|
559
877
|
errors.push(`${manifestPath}: .first_party must be a boolean when present`);
|
|
560
878
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public library surface for `@kontourai/flow-agents`.
|
|
3
3
|
*
|
|
4
|
-
* Native orchestration hosts can import the canonical workflow
|
|
5
|
-
* writer/validator
|
|
6
|
-
* `flow-agents
|
|
7
|
-
* read / merge / write of workflow evidence. This is the same code the CLI
|
|
8
|
-
* runs — importing it does not execute the CLI.
|
|
4
|
+
* Native orchestration hosts can import the canonical workflow runtime and
|
|
5
|
+
* sidecar writer/validator instead of reimplementing validated state changes.
|
|
6
|
+
* Agent-facing Kit guidance uses the public `flow-agents workflow` CLI.
|
|
9
7
|
*
|
|
10
8
|
* The sidecar JSON Schemas ship under `schemas/` and can be validated against
|
|
11
9
|
* directly; the helpers below are the canonical writer/validator that produce
|
|
@@ -46,6 +44,9 @@ export {
|
|
|
46
44
|
syncBuilderFlowSession,
|
|
47
45
|
} from "./builder-flow-runtime.js";
|
|
48
46
|
export type { BuilderFlowAgentLifecycleInput, BuilderFlowAuthorizedLifecycleInput, BuilderFlowSessionInput, BuilderFlowSessionResult } from "./builder-flow-runtime.js";
|
|
47
|
+
|
|
48
|
+
// Pure serialization contract used by external lifecycle authorities when
|
|
49
|
+
// signing requests. This does not load, create, or mutate a Flow run.
|
|
49
50
|
export { builderLifecycleAuthorizationPayload, loadBuilderLifecycleAuthorization } from "./builder-lifecycle-authority.js";
|
|
50
51
|
export type { BuilderLifecycleAuthorization } from "./builder-lifecycle-authority.js";
|
|
51
52
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export type ObservedProcessResult = {
|
|
5
|
+
command: string;
|
|
6
|
+
exit_code: number | null;
|
|
7
|
+
output_sha256: string;
|
|
8
|
+
output: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function configuredTimeout(variable: string, fallback: number): number {
|
|
12
|
+
const value = Number(process.env[variable] ?? fallback);
|
|
13
|
+
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runObservedCommand(command: string, projectRoot: string): Promise<ObservedProcessResult> {
|
|
17
|
+
const timeoutMs = configuredTimeout("FLOW_AGENTS_EVIDENCE_COMMAND_TIMEOUT_MS", 600000);
|
|
18
|
+
const killGraceMs = configuredTimeout("FLOW_AGENTS_EVIDENCE_COMMAND_KILL_GRACE_MS", 5000);
|
|
19
|
+
const result = await new Promise<{ code: number | null; outputSha256: string; output: string }>((resolve, reject) => {
|
|
20
|
+
const child = spawn("bash", ["-lc", command], {
|
|
21
|
+
cwd: projectRoot,
|
|
22
|
+
detached: process.platform !== "win32",
|
|
23
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
24
|
+
});
|
|
25
|
+
const stdoutHash = createHash("sha256");
|
|
26
|
+
const stderrHash = createHash("sha256");
|
|
27
|
+
let settled = false;
|
|
28
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
29
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
30
|
+
let cleanupStarted = false;
|
|
31
|
+
let cleanupComplete = false;
|
|
32
|
+
let streamsClosed = false;
|
|
33
|
+
let closedCode: number | null = null;
|
|
34
|
+
let output = "";
|
|
35
|
+
const captureOutput = (chunk: Buffer): void => {
|
|
36
|
+
if (output.length >= 64 * 1024) return;
|
|
37
|
+
output += chunk.toString("utf8").slice(0, 64 * 1024 - output.length);
|
|
38
|
+
};
|
|
39
|
+
const terminateProcessGroup = (signal: NodeJS.Signals): boolean => {
|
|
40
|
+
try {
|
|
41
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
|
|
42
|
+
else child.kill(signal);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const fail = (error: Error): void => {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
if (timeout) clearTimeout(timeout);
|
|
53
|
+
if (killTimer) clearTimeout(killTimer);
|
|
54
|
+
reject(error);
|
|
55
|
+
};
|
|
56
|
+
const complete = (): void => {
|
|
57
|
+
if (settled || !cleanupComplete || !streamsClosed) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
if (timeout) clearTimeout(timeout);
|
|
60
|
+
const outputHash = createHash("sha256")
|
|
61
|
+
.update("stdout\0").update(stdoutHash.digest())
|
|
62
|
+
.update("stderr\0").update(stderrHash.digest());
|
|
63
|
+
resolve({ code: closedCode, outputSha256: outputHash.digest("hex"), output });
|
|
64
|
+
};
|
|
65
|
+
const beginCleanup = (): void => {
|
|
66
|
+
if (settled || cleanupStarted) return;
|
|
67
|
+
cleanupStarted = true;
|
|
68
|
+
try {
|
|
69
|
+
if (!terminateProcessGroup("SIGTERM")) {
|
|
70
|
+
cleanupComplete = true;
|
|
71
|
+
complete();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
killTimer = setTimeout(() => {
|
|
75
|
+
killTimer = undefined;
|
|
76
|
+
try {
|
|
77
|
+
terminateProcessGroup("SIGKILL");
|
|
78
|
+
cleanupComplete = true;
|
|
79
|
+
complete();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
fail(error as Error);
|
|
82
|
+
}
|
|
83
|
+
}, killGraceMs);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
fail(error as Error);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
timeout = setTimeout(beginCleanup, timeoutMs);
|
|
89
|
+
child.stdout.on("data", (chunk: Buffer) => { stdoutHash.update(chunk); captureOutput(chunk); });
|
|
90
|
+
child.stderr.on("data", (chunk: Buffer) => { stderrHash.update(chunk); captureOutput(chunk); });
|
|
91
|
+
child.once("error", fail);
|
|
92
|
+
child.once("exit", (code) => { closedCode = code; beginCleanup(); });
|
|
93
|
+
child.once("close", () => { streamsClosed = true; complete(); });
|
|
94
|
+
});
|
|
95
|
+
return { command, exit_code: result.code, output_sha256: result.outputSha256, output: result.output };
|
|
96
|
+
}
|
|
@@ -24,8 +24,10 @@ const commands = [
|
|
|
24
24
|
["Static suite", "bash evals/run.sh static"],
|
|
25
25
|
["Integration suite", "bash evals/run.sh integration"],
|
|
26
26
|
["Workflow artifacts", `npm run workflow:validate-artifacts -- --require-sidecars --require-critique ${FLOW_AGENTS_RUNTIME_DIR}/<slug>`],
|
|
27
|
-
["Workflow
|
|
28
|
-
["
|
|
27
|
+
["Workflow control", "flow-agents workflow --help"],
|
|
28
|
+
["Workflow status", "flow-agents workflow status --session-dir .kontourai/flow-agents/<slug> --json"],
|
|
29
|
+
["Workflow critique", "flow-agents workflow critique --session-dir .kontourai/flow-agents/<slug> --verdict <pass|fail|not_verified> --summary <text>"],
|
|
30
|
+
["Workflow verification", "flow-agents workflow evidence --session-dir .kontourai/flow-agents/<slug> --expectation tests-evidence --status <pass|fail|not_verified> --command \"bash evals/static/test_builder_skill_coherence.sh\" --criterion-json '{\"id\":\"AC-1\",\"status\":\"pass\",\"evidence_refs\":[{\"kind\":\"command\",\"excerpt\":\"bash evals/static/test_builder_skill_coherence.sh\",\"summary\":\"Exact substantive command run for AC-1.\"}]}' --evidence-ref-json '{\"kind\":\"artifact\",\"file\":\".kontourai/flow-agents/<slug>/<slug>--plan-work.md\",\"summary\":\"Reviewable criterion mapping.\"}'"],
|
|
29
31
|
["Context map drift", "npm run context-map:check"],
|
|
30
32
|
["Bundle build", "npm run build:bundles"],
|
|
31
33
|
["Skill drift check", "flow-agents skill-drift-check"],
|
|
@@ -180,7 +182,7 @@ function render(includeRuntime: boolean): string {
|
|
|
180
182
|
"## Workflow Sidecars", "",
|
|
181
183
|
`Machine-readable workflow state lives beside Markdown artifacts in \`${FLOW_AGENTS_RUNTIME_DIR}/<slug>/\`.`, "",
|
|
182
184
|
...markdownTable(["Schema", "Title", "ID"], schemas()), "",
|
|
183
|
-
"Primary tools: `
|
|
185
|
+
"Primary tools: public `flow-agents workflow`, `trust.bundle`, artifact validation, `scripts/hooks/stop-goal-fit.js`, and `scripts/hooks/workflow-steering.js`. The package-internal writer is not an agent or consumer interface.", "",
|
|
184
186
|
"## Workflow Skills", "", ...markdownTable(["Skill", "Source", "When To Load"], workflowRows), "",
|
|
185
187
|
"## Support Skills", "", ...markdownTable(["Skill", "Source", "When To Load"], supportRows), "",
|
|
186
188
|
"## Agents", "", ...markdownTable(["Agent", "Model", "Tools", "Role"], agents()), "",
|