@kontourai/flow-agents 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.github/workflows/kit-gates-demo.yml +171 -0
  2. package/CHANGELOG.md +35 -0
  3. package/CONTEXT.md +1 -1
  4. package/README.md +13 -2
  5. package/build/src/cli/flow-kit.js +41 -2
  6. package/build/src/flow-kit/validate.js +98 -0
  7. package/build/src/tools/validate-source-tree.js +2 -1
  8. package/context/scripts/hooks/config-protection.js +217 -15
  9. package/docs/fixture-ownership.md +1 -0
  10. package/docs/index.md +9 -1
  11. package/docs/kit-authoring-guide.md +126 -0
  12. package/docs/knowledge-kit.md +69 -0
  13. package/docs/vision.md +22 -0
  14. package/evals/fixtures/kit-conformance-levels/k0-flows-only/flows/review.flow.json +26 -0
  15. package/evals/fixtures/kit-conformance-levels/k0-flows-only/kit.json +13 -0
  16. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/docs/README.md +3 -0
  17. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/flows/build.flow.json +26 -0
  18. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/kit.json +20 -0
  19. package/evals/fixtures/kit-conformance-levels/k2-with-evals/docs/README.md +3 -0
  20. package/evals/fixtures/kit-conformance-levels/k2-with-evals/eval-suites/contract-suite/suite.test.js +1 -0
  21. package/evals/fixtures/kit-conformance-levels/k2-with-evals/flows/synthesize.flow.json +26 -0
  22. package/evals/fixtures/kit-conformance-levels/k2-with-evals/kit.json +27 -0
  23. package/evals/fixtures/kit-conformance-levels/third-party-extension/flows/review.flow.json +26 -0
  24. package/evals/fixtures/kit-conformance-levels/third-party-extension/kit.json +19 -0
  25. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  26. package/evals/integration/test_hook_category_behaviors.sh +51 -0
  27. package/evals/integration/test_kit_conformance_levels.sh +209 -0
  28. package/evals/run.sh +2 -0
  29. package/kits/catalog.json +6 -0
  30. package/kits/knowledge/adapters/default-store/index.js +2 -2
  31. package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
  32. package/kits/knowledge/adapters/flow-runner/index.js +349 -0
  33. package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
  34. package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
  35. package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
  36. package/kits/knowledge/adapters/shared/codec.js +325 -0
  37. package/kits/knowledge/docs/store-contract.md +72 -0
  38. package/kits/knowledge/evals/entities/demo-acme.js +125 -0
  39. package/kits/knowledge/evals/entities/suite.test.js +722 -0
  40. package/kits/knowledge/kit.json +10 -0
  41. package/kits/release-evidence/fixtures/claims/README.md +14 -0
  42. package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
  43. package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
  44. package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
  45. package/kits/release-evidence/kit.json +13 -0
  46. package/package.json +1 -1
  47. package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
  48. package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
  49. package/scripts/hooks/config-protection.js +217 -15
  50. package/src/cli/flow-kit.ts +40 -2
  51. package/src/flow-kit/validate.ts +127 -0
  52. package/src/tools/validate-source-tree.ts +2 -1
@@ -61,6 +61,11 @@
61
61
  "id": "knowledge.similarity-vector",
62
62
  "path": "adapters/similarity-vector/index.js",
63
63
  "description": "Vector similarity detector: createVectorSimilarityDetector(options) returns a drop-in SimilarityDetector backed by dense embeddings and cosine similarity. Supports injectable embed fn (tests) or ollama /api/embed (default). Fail-closed: throws EMBED_FAILURE on infrastructure errors rather than silently masking them as empty clusters."
64
+ },
65
+ {
66
+ "id": "knowledge.obsidian-store",
67
+ "path": "adapters/obsidian-store/index.js",
68
+ "description": "Obsidian store adapter: each record is one human-canonical Obsidian markdown note. Frontmatter carries all contract fields; body rendered with callouts (raw), readable prose + Sources/Related wikilink sections (compiled/concept/snapshot). Category dots map to folder hierarchy; title-slugified filenames with collision suffix; superseded records move to archive/ (supersede-not-delete invariant). Backed by shared codec with default-store."
64
69
  }
65
70
  ],
66
71
  "evals": [
@@ -93,6 +98,11 @@
93
98
  "id": "knowledge.retirement-suite",
94
99
  "path": "evals/retirement/suite.test.js",
95
100
  "description": "Eval cases for retire: AC1 (retirement only via approved proposal with rationale/ref, transition table enforcement); AC2 (retired excluded from listByType/listByCategory/similarity defaults, returned with includeRetired flag, provenance intact via get()); AC3 (consolidation after retirement reflects pruned working set; retired record still reachable from snapshot provenance history); rejection leaves status byte-identical; gate telemetry."
101
+ },
102
+ {
103
+ "id": "knowledge.entity-cards-suite",
104
+ "path": "evals/entities/suite.test.js",
105
+ "description": "Eval cases for person/entity cards (issue #48): AC1-AC4 \u2014 entity extraction from Attendees lines, exact-match resolution, possible-duplicate detection, merge via propose/apply/reject (union aliases+backlinks, supersede duplicate), Obsidian people/ folder rendering, and extended contract suite (person type validity) on both adapters."
96
106
  }
97
107
  ]
98
108
  }
@@ -0,0 +1,14 @@
1
+ # Claim Fixtures
2
+
3
+ These trust-report JSON files are used by `.github/workflows/kit-gates-demo.yml` to prove
4
+ agentless gate evaluation against the `release-evidence` Flow Kit.
5
+
6
+ | File | Purpose |
7
+ | --- | --- |
8
+ | `pass-trusted-release.trust.json` | Passing case: `release.evidence` claim with status `trusted`. Gate verdict: `pass`. |
9
+ | `fail-rejected-release.trust.json` | Failing case: `release.evidence` claim with status `rejected`. Gate verdict: `route-back` or `block`. The workflow asserts the failure is detected (non-zero exit) rather than letting it silently pass. |
10
+
11
+ ## Format
12
+
13
+ Each file is a Surface trust-report artifact (`artifact_type: "trust-report"`).
14
+ The Flow CLI normalizes these via `flow attach-evidence --trust-artifact` before gate evaluation.
@@ -0,0 +1,22 @@
1
+ {
2
+ "schema_version": "0.1",
3
+ "artifact_type": "trust-report",
4
+ "subject": "ci/release-evidence-gate",
5
+ "producer": "ci/release-evidence-probe",
6
+ "status": "rejected",
7
+ "issued_at": "2026-06-12T00:00:00.000Z",
8
+ "authority_traces": [
9
+ "github:main"
10
+ ],
11
+ "claims": [
12
+ {
13
+ "type": "release.evidence",
14
+ "subject": "ci/release-evidence-gate",
15
+ "status": "rejected",
16
+ "issued_at": "2026-06-12T00:00:00.000Z"
17
+ }
18
+ ],
19
+ "integrity": {
20
+ "verified": true
21
+ }
22
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "schema_version": "0.1",
3
+ "artifact_type": "trust-report",
4
+ "subject": "ci/release-evidence-gate",
5
+ "producer": "ci/release-evidence-probe",
6
+ "status": "trusted",
7
+ "issued_at": "2026-06-12T00:00:00.000Z",
8
+ "authority_traces": [
9
+ "github:main"
10
+ ],
11
+ "claims": [
12
+ {
13
+ "type": "release.evidence",
14
+ "subject": "ci/release-evidence-gate",
15
+ "status": "trusted",
16
+ "issued_at": "2026-06-12T00:00:00.000Z"
17
+ }
18
+ ],
19
+ "integrity": {
20
+ "verified": true
21
+ }
22
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "id": "release-evidence",
3
+ "version": "1.0",
4
+ "steps": [
5
+ {
6
+ "id": "gate-check",
7
+ "next": null
8
+ }
9
+ ],
10
+ "gates": {
11
+ "release-evidence-gate": {
12
+ "step": "gate-check",
13
+ "expects": [
14
+ {
15
+ "id": "release-claim-present",
16
+ "kind": "surface.claim",
17
+ "required": true,
18
+ "description": "A trusted release.evidence claim must be attached before this gate can pass.",
19
+ "explore_hint": "Attach a trust-report JSON file with claim type release.evidence and status trusted.",
20
+ "claim": {
21
+ "type": "release.evidence",
22
+ "accepted_statuses": [
23
+ "trusted"
24
+ ]
25
+ }
26
+ }
27
+ ],
28
+ "on_route_back": {
29
+ "missing_evidence": "gate-check",
30
+ "default": "gate-check"
31
+ },
32
+ "route_back_policy": {
33
+ "max_attempts": 3,
34
+ "on_exceeded": "block"
35
+ }
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "schema_version": "1.0",
3
+ "id": "release-evidence",
4
+ "name": "Release Evidence Kit",
5
+ "description": "A minimal flows-only Flow Kit for proving agentless gate evaluation over surface claims in CI. One gate expects a trusted release.evidence claim; CI attaches a claim file and calls flow evaluate.",
6
+ "flows": [
7
+ {
8
+ "id": "release-evidence",
9
+ "path": "flows/release-evidence.flow.json",
10
+ "description": "Single-gate flow that requires a trusted release.evidence claim before the gate-check step can pass."
11
+ }
12
+ ]
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/flow-agents",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
4
4
  "description": "Flow Agents — a Kontour product that applies Flow and Veritas discipline as a portable process layer inside the agent tools you already use: Claude Code, Codex, Kiro, opencode, pi, and GitHub Actions — with framework adapters (AWS Strands preview) on the same policy-engine contract.",
5
5
  "keywords": [
6
6
  "agents",
@@ -0,0 +1,20 @@
1
+ {
2
+ "description": "config-protection allows a command whose string content merely mentions the bypass flag",
3
+ "policy_class": "config-protection",
4
+ "canonical_event": "preToolUse",
5
+ "conformance_level": "L2",
6
+ "hook_id": "config-protection",
7
+ "hook_script": "config-protection.js",
8
+ "payload": {
9
+ "hook_event_name": "PreToolUse",
10
+ "tool_name": "Bash",
11
+ "tool_input": {
12
+ "command": "gh issue create --body \"git commit --no-verify is blocked by the hook\""
13
+ }
14
+ },
15
+ "expected": {
16
+ "exit_code": 0,
17
+ "stderr_is_empty": true,
18
+ "stdout_echoes_input": true
19
+ }
20
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "description": "config-protection blocks a Bash command carrying the bypass flag in actual flag position",
3
+ "policy_class": "config-protection",
4
+ "canonical_event": "preToolUse",
5
+ "conformance_level": "L2",
6
+ "hook_id": "config-protection",
7
+ "hook_script": "config-protection.js",
8
+ "payload": {
9
+ "hook_event_name": "PreToolUse",
10
+ "tool_name": "Bash",
11
+ "tool_input": {
12
+ "command": "git commit --no-verify -m fix"
13
+ }
14
+ },
15
+ "expected": {
16
+ "exit_code": 2,
17
+ "stderr_contains": [
18
+ "BLOCKED",
19
+ "verification hooks"
20
+ ],
21
+ "stdout_is_empty": true
22
+ }
23
+ }
@@ -5,6 +5,9 @@
5
5
  * Blocks modifications to linter/formatter config files.
6
6
  * Steers the agent to fix source code instead of weakening configs.
7
7
  *
8
+ * Also blocks git verification-bypass flags in actual flag positions only.
9
+ * Text that merely mentions the flag inside quoted strings or prose is allowed.
10
+ *
8
11
  * Exit codes: 0 = allow, 2 = block
9
12
  */
10
13
 
@@ -25,6 +28,195 @@ const PROTECTED_FILES = new Set([
25
28
  '.markdownlint.json', '.markdownlint.yaml', '.markdownlintrc',
26
29
  ]);
27
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Shell-aware tokenizer
33
+ //
34
+ // Splits a shell command string into tokens, respecting single/double quotes
35
+ // and backslash escapes. Quoted content stays inside its parent token so
36
+ // flag text inside a -m argument string is never matched as a flag.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * tokenize(cmd) -- shell-aware tokenizer for a single command segment.
41
+ * Returns an array of unquoted token strings.
42
+ */
43
+ function tokenize(cmd) {
44
+ const tokens = [];
45
+ let i = 0;
46
+ const len = cmd.length;
47
+
48
+ while (i < len) {
49
+ // Skip whitespace between tokens.
50
+ while (i < len && /\s/.test(cmd[i])) i++;
51
+ if (i >= len) break;
52
+
53
+ let token = '';
54
+
55
+ // Consume one token -- stop at unquoted whitespace.
56
+ while (i < len) {
57
+ const ch = cmd[i];
58
+
59
+ if (ch === '\\') {
60
+ // Backslash escape outside of quotes.
61
+ i++;
62
+ if (i < len) token += cmd[i++];
63
+ } else if (ch === "'") {
64
+ // Single-quoted string: no escape processing, read until closing quote.
65
+ i++;
66
+ while (i < len && cmd[i] !== "'") token += cmd[i++];
67
+ i++; // consume closing quote
68
+ } else if (ch === '"') {
69
+ // Double-quoted string: honour \" and \\ escape sequences.
70
+ i++;
71
+ while (i < len && cmd[i] !== '"') {
72
+ if (cmd[i] === '\\' && i + 1 < len && (cmd[i + 1] === '"' || cmd[i + 1] === '\\')) {
73
+ i++; // skip the backslash
74
+ token += cmd[i++];
75
+ } else {
76
+ token += cmd[i++];
77
+ }
78
+ }
79
+ i++; // consume closing quote
80
+ } else if (/\s/.test(ch)) {
81
+ break; // end of token
82
+ } else {
83
+ token += ch;
84
+ i++;
85
+ }
86
+ }
87
+
88
+ if (token.length > 0) tokens.push(token);
89
+ }
90
+
91
+ return tokens;
92
+ }
93
+
94
+ /**
95
+ * splitSegments(cmd) -- split on shell connectors && || ; | outside of quotes.
96
+ */
97
+ function splitSegments(cmd) {
98
+ const segments = [];
99
+ let i = 0;
100
+ const len = cmd.length;
101
+ let segStart = 0;
102
+
103
+ while (i < len) {
104
+ const ch = cmd[i];
105
+
106
+ if (ch === '\\') {
107
+ i += 2; // skip escaped character
108
+ } else if (ch === "'") {
109
+ i++;
110
+ while (i < len && cmd[i] !== "'") i++;
111
+ i++; // skip closing quote
112
+ } else if (ch === '"') {
113
+ i++;
114
+ while (i < len) {
115
+ if (cmd[i] === '\\' && i + 1 < len) { i += 2; continue; }
116
+ if (cmd[i] === '"') { i++; break; }
117
+ i++;
118
+ }
119
+ } else if (ch === '&' && i + 1 < len && cmd[i + 1] === '&') {
120
+ segments.push(cmd.slice(segStart, i).trim());
121
+ i += 2; segStart = i;
122
+ } else if (ch === '|' && i + 1 < len && cmd[i + 1] === '|') {
123
+ segments.push(cmd.slice(segStart, i).trim());
124
+ i += 2; segStart = i;
125
+ } else if (ch === ';') {
126
+ segments.push(cmd.slice(segStart, i).trim());
127
+ i++; segStart = i;
128
+ } else if (ch === '|') {
129
+ // single pipe
130
+ segments.push(cmd.slice(segStart, i).trim());
131
+ i++; segStart = i;
132
+ } else {
133
+ i++;
134
+ }
135
+ }
136
+
137
+ const tail = cmd.slice(segStart).trim();
138
+ if (tail.length > 0) segments.push(tail);
139
+ return segments.filter(s => s.length > 0);
140
+ }
141
+
142
+ // Git global flags that consume a following argument value.
143
+ const GIT_GLOBAL_FLAGS_WITH_ARG = new Set(['-C', '-c', '--exec-path', '--git-dir', '--work-tree', '--namespace']);
144
+ // Git global flags that are standalone (no following argument).
145
+ const GIT_GLOBAL_FLAGS_STANDALONE = new Set([
146
+ '--version', '--help', '--html-path', '--man-path', '--info-path',
147
+ '-p', '--paginate', '-P', '--no-pager', '--no-replace-objects',
148
+ '--bare', '--literal-pathspecs', '--glob-pathspecs', '--noglob-pathspecs',
149
+ '--icase-pathspecs', '--no-optional-locks', '--list-cmds',
150
+ ]);
151
+
152
+ /**
153
+ * resolveGitSubcommand(tokens) -- walk past global git flags and return the
154
+ * subcommand token, or null if not determinable.
155
+ */
156
+ function resolveGitSubcommand(tokens) {
157
+ let i = 1;
158
+ while (i < tokens.length) {
159
+ const t = tokens[i];
160
+ if (GIT_GLOBAL_FLAGS_WITH_ARG.has(t)) {
161
+ i += 2;
162
+ } else if (GIT_GLOBAL_FLAGS_STANDALONE.has(t)) {
163
+ i += 1;
164
+ } else if (t.startsWith('-')) {
165
+ i += 1; // unknown global flag -- skip conservatively
166
+ } else {
167
+ return { subcommand: t, flagsStart: i + 1 };
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+
173
+ // Flags for git commit that consume the immediately following token as a value.
174
+ const COMMIT_FLAGS_WITH_VALUE = new Set([
175
+ '-m', '--message', '-F', '--file', '-C', '-c',
176
+ '--author', '--date', '--fixup', '--squash', '--pathspec-from-file',
177
+ ]);
178
+
179
+ // Flags for git push that consume the following token as a value.
180
+ const PUSH_FLAGS_WITH_VALUE = new Set([
181
+ '--receive-pack', '--repo', '--push-option', '-o', '--recurse-submodules',
182
+ ]);
183
+
184
+ const BYPASS_NV = '--no-verify';
185
+ const BYPASS_N = '-n'; // short alias (commit only; on push -n means --dry-run)
186
+
187
+ function checkSegmentForBypass(tokens) {
188
+ if (tokens.length === 0 || tokens[0] !== 'git') return null;
189
+ const resolved = resolveGitSubcommand(tokens);
190
+ if (!resolved) return null;
191
+ const { subcommand, flagsStart } = resolved;
192
+ if (subcommand === 'commit') {
193
+ for (let i = flagsStart; i < tokens.length; i++) {
194
+ const t = tokens[i];
195
+ if (t === BYPASS_NV || t === BYPASS_N) return `git ${subcommand} ${t}`;
196
+ if (COMMIT_FLAGS_WITH_VALUE.has(t)) i++;
197
+ }
198
+ } else if (subcommand === 'push') {
199
+ for (let i = flagsStart; i < tokens.length; i++) {
200
+ const t = tokens[i];
201
+ if (t === BYPASS_NV) return `git ${subcommand} ${t}`;
202
+ if (PUSH_FLAGS_WITH_VALUE.has(t)) i++;
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+
208
+ function checkCommandForBypass(command) {
209
+ if (typeof command !== 'string' || !command) return null;
210
+ if (!command.includes('git')) return null;
211
+ const segments = splitSegments(command);
212
+ for (const seg of segments) {
213
+ const tokens = tokenize(seg);
214
+ const result = checkSegmentForBypass(tokens);
215
+ if (result) return result;
216
+ }
217
+ return null;
218
+ }
219
+
28
220
  function run(inputOrRaw, options = {}) {
29
221
  if (options.truncated) {
30
222
  return {
@@ -33,30 +225,40 @@ function run(inputOrRaw, options = {}) {
33
225
  'Refusing to bypass config-protection on a truncated payload.',
34
226
  };
35
227
  }
36
-
37
228
  let input;
38
229
  try {
39
230
  input = typeof inputOrRaw === 'string' ? JSON.parse(inputOrRaw) : inputOrRaw;
40
231
  } catch { return { exitCode: 0 }; }
41
-
42
232
  const filePath = input?.tool_input?.path || input?.tool_input?.file_path || '';
43
- if (!filePath) return { exitCode: 0 };
44
-
45
- const basename = path.basename(filePath);
46
- if (PROTECTED_FILES.has(basename)) {
47
- return {
48
- exitCode: 2,
49
- stderr: `BLOCKED: Modifying ${basename} is not allowed. ` +
50
- 'Fix the source code to satisfy linter/formatter rules instead of ' +
51
- 'weakening the config. If this is a legitimate config change, ' +
52
- 'disable the config-protection hook temporarily.',
53
- };
233
+ if (filePath) {
234
+ const basename = path.basename(filePath);
235
+ if (PROTECTED_FILES.has(basename)) {
236
+ return {
237
+ exitCode: 2,
238
+ stderr: `BLOCKED: Modifying ${basename} is not allowed. ` +
239
+ 'Fix the source code to satisfy linter/formatter rules instead of ' +
240
+ 'weakening the config. If this is a legitimate config change, ' +
241
+ 'disable the config-protection hook temporarily.',
242
+ };
243
+ }
244
+ }
245
+ const command = input?.tool_input?.command || '';
246
+ if (command) {
247
+ const bypass = checkCommandForBypass(command);
248
+ if (bypass) {
249
+ return {
250
+ exitCode: 2,
251
+ stderr: `BLOCKED: "${bypass}" bypasses git verification hooks. ` +
252
+ 'Verification hooks enforce project quality gates and must not be opted out. ' +
253
+ 'Fix the failing check instead of skipping it. ' +
254
+ 'If the hook is genuinely misconfigured, correct the hook configuration directly.',
255
+ };
256
+ }
54
257
  }
55
-
56
258
  return { exitCode: 0 };
57
259
  }
58
260
 
59
- module.exports = { run };
261
+ module.exports = { run, tokenize, splitSegments, checkCommandForBypass };
60
262
 
61
263
  // Stdin fallback for spawnSync execution
62
264
  if (require.main === module) {
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { parseArgs, flagBool, flagString } from "../lib/args.js";
5
5
  import { assertPathContained, copyDir, isoNow, readJson, walkFiles, writeJson } from "../lib/fs.js";
6
- import { assertKitRepository } from "../flow-kit/validate.js";
6
+ import { assertKitRepository, deriveKitTargets } from "../flow-kit/validate.js";
7
7
  import { activateCodexLocal, activateStrandsLocal } from "../runtime-adapters.js";
8
8
 
9
9
  const REGISTRY_REL = path.join("kits", "local", "installed-kits.json");
@@ -132,13 +132,51 @@ function activate(argv: string[]): number {
132
132
  return Array.isArray(result.errors) && result.errors.length ? 1 : 0;
133
133
  }
134
134
 
135
+ /**
136
+ * inspect <kit-dir> [--json]
137
+ *
138
+ * Derives conformance level (K0/K1/K2) and consumer targets from a kit's
139
+ * observable asset classes. Exits 1 if the kit fails core container validation.
140
+ * Outputs stable JSON suitable for use by catalog tooling and CI.
141
+ *
142
+ * K-levels (issue #52):
143
+ * K0 valid core Flow Kit container — gates evaluable agentlessly by any Flow consumer.
144
+ * K1 K0 + Flow Agents extension assets present (skills/docs/adapters/evals/assets).
145
+ * K2 K1 + evals present (live evidence layer).
146
+ *
147
+ * Consumer targets derived from observable asset classes:
148
+ * flow always present at K0 (any Flow consumer: gates/definition-of-done)
149
+ * flow-agents present at K1+ (Flow Agents extension activated)
150
+ * <namespace> unknown top-level keys list verbatim as third-party consumer targets
151
+ */
152
+ function inspect(argv: string[]): number {
153
+ const args = parseArgs(argv);
154
+ const kitDir = path.resolve(args.positionals[0] ?? ".");
155
+ const manifestPath = path.join(kitDir, "kit.json");
156
+ if (!fs.existsSync(manifestPath)) {
157
+ console.error(`inspect: kit.json not found at ${manifestPath}`);
158
+ return 1;
159
+ }
160
+ let manifest: Record<string, unknown>;
161
+ try {
162
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
163
+ } catch (err) {
164
+ console.error(`inspect: invalid JSON in ${manifestPath}: ${(err as Error).message}`);
165
+ return 1;
166
+ }
167
+ const result = deriveKitTargets(manifest);
168
+ console.log(JSON.stringify(result, null, 2));
169
+ return result.conformance.k0 ? 0 : 1;
170
+ }
171
+
135
172
  export function main(argv = process.argv.slice(2)): number {
136
173
  const [command, ...rest] = argv;
137
174
  if (command === "install-local") return installLocal(rest);
138
175
  if (command === "list") return list(rest);
139
176
  if (command === "status") return status(rest);
140
177
  if (command === "activate") return activate(rest);
141
- console.error("usage: flow-kit <install-local|list|status|activate> ...");
178
+ if (command === "inspect") return inspect(rest);
179
+ console.error("usage: flow-kit <install-local|list|status|activate|inspect> ...");
142
180
  return 2;
143
181
  }
144
182
 
@@ -4,6 +4,121 @@ import { readJson } from "../lib/fs.js";
4
4
 
5
5
  const ASSET_CLASSES = ["flows", "skills", "docs", "adapters", "evals", "assets"] as const;
6
6
 
7
+ // Core container fields owned by kontourai/flow (flow-kit-container.schema.json).
8
+ // agent-extension fields are skills, docs, adapters, evals, assets.
9
+ const CORE_CONTAINER_FIELDS = new Set(["schema_version", "id", "name", "description", "product_name", "flows"]);
10
+ const AGENT_EXTENSION_CLASSES = new Set(["skills", "docs", "adapters", "evals", "assets"]);
11
+
12
+ export type KitTargetConsumer =
13
+ | "flow"
14
+ | "flow-agents"
15
+ | string; // third-party extension namespaces listed verbatim
16
+
17
+ export interface KitConformanceLevel {
18
+ /** K0: valid core Flow Kit container with at least one flow (gates evaluable agentlessly). */
19
+ k0: boolean;
20
+ /** K1: K0 + at least one Flow Agents extension asset class present (skills/docs/adapters/evals/assets). */
21
+ k1: boolean;
22
+ /** K2: K1 + evals present (live evidence layer). */
23
+ k2: boolean;
24
+ }
25
+
26
+ export interface KitTargetsResult {
27
+ kit_id: string;
28
+ kit_name: string;
29
+ conformance: KitConformanceLevel;
30
+ /** Derived consumer targets based on observable asset classes. */
31
+ targets: KitTargetConsumer[];
32
+ /** Extension field namespaces that are not Flow or Flow Agents-owned. */
33
+ third_party_extensions: string[];
34
+ }
35
+
36
+ /**
37
+ * Validates that the manifest satisfies the core Flow Kit container contract
38
+ * (as specified by kontourai/flow PR #67) with all agent-extension fields stripped.
39
+ * Returns a list of violation messages (empty = valid).
40
+ *
41
+ * The degradation invariant: every Flow Agents Kit MUST remain a valid core
42
+ * Flow Kit container when agent-extension fields are ignored.
43
+ */
44
+ export function validateCoreContainer(manifest: Record<string, unknown>, label: string): string[] {
45
+ const errors: string[] = [];
46
+ if (manifest.schema_version !== "1.0") {
47
+ errors.push(`${label}: .schema_version must be "1.0"`);
48
+ }
49
+ if (typeof manifest.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(manifest.id)) {
50
+ errors.push(`${label}: .id must be a stable kebab-case string`);
51
+ }
52
+ if (typeof manifest.name !== "string" || !manifest.name.trim()) {
53
+ errors.push(`${label}: .name must be a non-empty string`);
54
+ }
55
+ if (!Array.isArray(manifest.flows) || manifest.flows.length === 0) {
56
+ errors.push(`${label}: .flows must be a non-empty list`);
57
+ } else {
58
+ manifest.flows.forEach((entry: unknown, index: number) => {
59
+ if (typeof entry !== "object" || entry === null) {
60
+ errors.push(`${label}: flows[${index}] must be an object`);
61
+ return;
62
+ }
63
+ const flow = entry as Record<string, unknown>;
64
+ if (typeof flow.id !== "string" || !flow.id) {
65
+ errors.push(`${label}: flows[${index}].id must be a string`);
66
+ }
67
+ if (typeof flow.path !== "string" || !flow.path) {
68
+ errors.push(`${label}: flows[${index}].path must be a string`);
69
+ }
70
+ });
71
+ }
72
+ return errors;
73
+ }
74
+
75
+ /**
76
+ * Derives the consumer-target level (K0/K1/K2) and target audience list from
77
+ * observable asset classes in the kit manifest. Does not require file I/O.
78
+ *
79
+ * Derivation rules (from kontourai/flow-agents#52 and Brian's layering review):
80
+ * - K0: valid core container (schema_version, id, name, flows non-empty).
81
+ * - K1: K0 + any Flow Agents extension field present (skills/docs/adapters/evals/assets).
82
+ * - K2: K1 + evals present.
83
+ * - targets.flow: always present when K0 (any Flow consumer can evaluate gates).
84
+ * - targets.flow-agents: present when K1 (agent extension assets activate in >=1 harness).
85
+ * - third-party: any top-level keys that are not core fields and not Flow Agents extension classes.
86
+ */
87
+ export function deriveKitTargets(manifest: Record<string, unknown>): KitTargetsResult {
88
+ const kitId = typeof manifest.id === "string" ? manifest.id : "<unknown>";
89
+ const kitName = typeof manifest.name === "string" ? manifest.name : "<unknown>";
90
+
91
+ const coreErrors = validateCoreContainer(manifest, "kit.json");
92
+ const k0 = coreErrors.length === 0;
93
+
94
+ const hasAgentExtension = AGENT_EXTENSION_CLASSES.size > 0 &&
95
+ [...AGENT_EXTENSION_CLASSES].some((cls) => Array.isArray(manifest[cls]) && (manifest[cls] as unknown[]).length > 0);
96
+
97
+ const hasEvals = Array.isArray(manifest["evals"]) && (manifest["evals"] as unknown[]).length > 0;
98
+
99
+ const k1 = k0 && hasAgentExtension;
100
+ const k2 = k1 && hasEvals;
101
+
102
+ // Detect third-party extension namespaces: top-level keys that are neither
103
+ // core fields nor Flow Agents extension classes.
104
+ const thirdPartyExtensions: string[] = Object.keys(manifest)
105
+ .filter((key) => !CORE_CONTAINER_FIELDS.has(key) && !AGENT_EXTENSION_CLASSES.has(key))
106
+ .sort();
107
+
108
+ const targets: KitTargetConsumer[] = [];
109
+ if (k0) targets.push("flow");
110
+ if (k1) targets.push("flow-agents");
111
+ for (const ns of thirdPartyExtensions) targets.push(ns);
112
+
113
+ return {
114
+ kit_id: kitId,
115
+ kit_name: kitName,
116
+ conformance: { k0, k1, k2 },
117
+ targets,
118
+ third_party_extensions: thirdPartyExtensions,
119
+ };
120
+ }
121
+
7
122
  export function validateKitRepository(kitDir: string): string[] {
8
123
  const errors: string[] = [];
9
124
  const manifestPath = path.join(kitDir, "kit.json");
@@ -20,6 +135,18 @@ export function validateKitRepository(kitDir: string): string[] {
20
135
  }
21
136
  if (typeof manifest.name !== "string" || !manifest.name.trim()) errors.push(`${manifestPath}: .name must be a non-empty string`);
22
137
 
138
+ // Degradation invariant: every Flow Agents Kit must remain a valid core Flow Kit container
139
+ // when agent-extension fields are stripped. Strip extensions and re-validate core contract.
140
+ const coreManifest: Record<string, unknown> = {};
141
+ for (const key of Object.keys(manifest)) {
142
+ if (CORE_CONTAINER_FIELDS.has(key)) coreManifest[key] = manifest[key];
143
+ }
144
+ const coreErrors = validateCoreContainer(coreManifest, manifestPath);
145
+ for (const err of coreErrors) {
146
+ // Deduplicate: only add if not already covered by top-level checks above.
147
+ if (!errors.some((existing) => existing === err)) errors.push(err);
148
+ }
149
+
23
150
  for (const section of ASSET_CLASSES) {
24
151
  const entries = manifest[section];
25
152
  if (entries === undefined) continue;