@kontourai/flow-agents 0.4.0 → 1.0.1

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 (58) hide show
  1. package/.github/workflows/kit-gates-demo.yml +171 -0
  2. package/CHANGELOG.md +43 -0
  3. package/CONTEXT.md +1 -1
  4. package/README.md +13 -2
  5. package/build/src/cli/flow-kit.js +175 -6
  6. package/build/src/cli/validate-source-tree.js +19 -2
  7. package/build/src/flow-kit/validate.js +98 -0
  8. package/build/src/runtime-adapters.js +1 -1
  9. package/build/src/tools/validate-source-tree.js +3 -2
  10. package/context/scripts/hooks/config-protection.js +217 -15
  11. package/docs/fixture-ownership.md +2 -1
  12. package/docs/index.md +9 -1
  13. package/docs/kit-authoring-guide.md +126 -0
  14. package/docs/knowledge-kit.md +69 -0
  15. package/docs/vision.md +22 -0
  16. package/evals/fixtures/kit-conformance-levels/k0-flows-only/flows/review.flow.json +26 -0
  17. package/evals/fixtures/kit-conformance-levels/k0-flows-only/kit.json +13 -0
  18. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/docs/README.md +3 -0
  19. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/flows/build.flow.json +26 -0
  20. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/kit.json +20 -0
  21. package/evals/fixtures/kit-conformance-levels/k2-with-evals/docs/README.md +3 -0
  22. package/evals/fixtures/kit-conformance-levels/k2-with-evals/eval-suites/contract-suite/suite.test.js +1 -0
  23. package/evals/fixtures/kit-conformance-levels/k2-with-evals/flows/synthesize.flow.json +26 -0
  24. package/evals/fixtures/kit-conformance-levels/k2-with-evals/kit.json +27 -0
  25. package/evals/fixtures/kit-conformance-levels/third-party-extension/flows/review.flow.json +26 -0
  26. package/evals/fixtures/kit-conformance-levels/third-party-extension/kit.json +19 -0
  27. package/evals/integration/test_activate_npx_context.sh +134 -0
  28. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  29. package/evals/integration/test_flow_kit_install_git.sh +163 -0
  30. package/evals/integration/test_hook_category_behaviors.sh +51 -0
  31. package/evals/integration/test_kit_conformance_levels.sh +209 -0
  32. package/evals/run.sh +2 -0
  33. package/kits/catalog.json +6 -0
  34. package/kits/knowledge/adapters/default-store/index.js +2 -2
  35. package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
  36. package/kits/knowledge/adapters/flow-runner/index.js +349 -0
  37. package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
  38. package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
  39. package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
  40. package/kits/knowledge/adapters/shared/codec.js +325 -0
  41. package/kits/knowledge/docs/store-contract.md +72 -0
  42. package/kits/knowledge/evals/entities/demo-acme.js +125 -0
  43. package/kits/knowledge/evals/entities/suite.test.js +722 -0
  44. package/kits/knowledge/kit.json +10 -0
  45. package/kits/release-evidence/fixtures/claims/README.md +14 -0
  46. package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
  47. package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
  48. package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
  49. package/kits/release-evidence/kit.json +13 -0
  50. package/package.json +1 -1
  51. package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
  52. package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
  53. package/scripts/hooks/config-protection.js +217 -15
  54. package/src/cli/flow-kit.ts +162 -5
  55. package/src/cli/validate-source-tree.ts +7 -1
  56. package/src/flow-kit/validate.ts +127 -0
  57. package/src/runtime-adapters.ts +1 -1
  58. package/src/tools/validate-source-tree.ts +3 -2
@@ -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.1",
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) {
@@ -1,9 +1,12 @@
1
+ import * as child_process from "node:child_process";
1
2
  import * as crypto from "node:crypto";
2
3
  import * as fs from "node:fs";
4
+ import * as os from "node:os";
3
5
  import * as path from "node:path";
6
+ import { fileURLToPath } from "node:url";
4
7
  import { parseArgs, flagBool, flagString } from "../lib/args.js";
5
8
  import { assertPathContained, copyDir, isoNow, readJson, walkFiles, writeJson } from "../lib/fs.js";
6
- import { assertKitRepository } from "../flow-kit/validate.js";
9
+ import { assertKitRepository, deriveKitTargets } from "../flow-kit/validate.js";
7
10
  import { activateCodexLocal, activateStrandsLocal } from "../runtime-adapters.js";
8
11
 
9
12
  const REGISTRY_REL = path.join("kits", "local", "installed-kits.json");
@@ -31,6 +34,22 @@ function contentHash(root: string): string {
31
34
  return `sha256:${hash.digest("hex")}`;
32
35
  }
33
36
 
37
+ /** Content hash that excludes .git and other VCS/cache directories (for install-git clones). */
38
+ function kitContentHash(root: string): string {
39
+ const EXCLUDE_DIRS = new Set([".git", "__pycache__", ".pytest_cache"]);
40
+ const hash = crypto.createHash("sha256");
41
+ for (const file of walkFiles(root)) {
42
+ const parts = path.relative(root, file).split(path.sep);
43
+ if (parts.some((p) => EXCLUDE_DIRS.has(p))) continue;
44
+ const rel = parts.join("/");
45
+ hash.update(rel);
46
+ hash.update("\0");
47
+ hash.update(fs.readFileSync(file));
48
+ hash.update("\0");
49
+ }
50
+ return `sha256:${hash.digest("hex")}`;
51
+ }
52
+
34
53
  function installLocal(argv: string[]): number {
35
54
  const args = parseArgs(argv);
36
55
  const source = path.resolve(args.positionals[0] ?? "");
@@ -39,12 +58,10 @@ function installLocal(argv: string[]): number {
39
58
  try {
40
59
  manifest = assertKitRepository(source);
41
60
  } catch (error) {
42
- console.log("warning: Flow validation surface unavailable; local kit check uses the minimal Flow Definition fallback");
43
61
  console.log("Flow Kit repository validation failed:");
44
62
  for (const diagnostic of ((error as Error & { diagnostics?: string[] }).diagnostics ?? [(error as Error).message])) console.log(` - ${diagnostic}`);
45
63
  return 1;
46
64
  }
47
- console.log("warning: Flow validation surface unavailable; local kit check uses the minimal Flow Definition fallback");
48
65
  const kitId = String(manifest.id);
49
66
  const hash = contentHash(source);
50
67
  const registry = loadRegistry(dest);
@@ -132,14 +149,154 @@ function activate(argv: string[]): number {
132
149
  return Array.isArray(result.errors) && result.errors.length ? 1 : 0;
133
150
  }
134
151
 
152
+ /**
153
+ * inspect <kit-dir> [--json]
154
+ *
155
+ * Derives conformance level (K0/K1/K2) and consumer targets from a kit's
156
+ * observable asset classes. Exits 1 if the kit fails core container validation.
157
+ * Outputs stable JSON suitable for use by catalog tooling and CI.
158
+ *
159
+ * K-levels (issue #52):
160
+ * K0 valid core Flow Kit container — gates evaluable agentlessly by any Flow consumer.
161
+ * K1 K0 + Flow Agents extension assets present (skills/docs/adapters/evals/assets).
162
+ * K2 K1 + evals present (live evidence layer).
163
+ *
164
+ * Consumer targets derived from observable asset classes:
165
+ * flow always present at K0 (any Flow consumer: gates/definition-of-done)
166
+ * flow-agents present at K1+ (Flow Agents extension activated)
167
+ * <namespace> unknown top-level keys list verbatim as third-party consumer targets
168
+ */
169
+ function inspect(argv: string[]): number {
170
+ const args = parseArgs(argv);
171
+ const kitDir = path.resolve(args.positionals[0] ?? ".");
172
+ const manifestPath = path.join(kitDir, "kit.json");
173
+ if (!fs.existsSync(manifestPath)) {
174
+ console.error(`inspect: kit.json not found at ${manifestPath}`);
175
+ return 1;
176
+ }
177
+ let manifest: Record<string, unknown>;
178
+ try {
179
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
180
+ } catch (err) {
181
+ console.error(`inspect: invalid JSON in ${manifestPath}: ${(err as Error).message}`);
182
+ return 1;
183
+ }
184
+ const result = deriveKitTargets(manifest);
185
+ console.log(JSON.stringify(result, null, 2));
186
+ return result.conformance.k0 ? 0 : 1;
187
+ }
188
+
189
+
190
+ /**
191
+ * install-git <repo-url>[#ref] [--ref <branch|tag|sha>] [--dest <path>] [--force] [--update]
192
+ *
193
+ * Shallow-clones a remote git repository to a temporary directory, validates the kit
194
+ * container with the same logic used by install-local, then delegates to the existing
195
+ * install path. Supports an optional #ref fragment in the URL or a separate --ref flag.
196
+ *
197
+ * Implements kontourai/flow-agents#56 (git-ref install surface).
198
+ */
199
+ function installGit(argv: string[]): number {
200
+ const args = parseArgs(argv);
201
+ const rawUrl = args.positionals[0] ?? "";
202
+ if (!rawUrl) {
203
+ console.error("install-git: missing <repo-url> argument");
204
+ console.error("usage: flow-kit install-git <repo-url>[#ref] [--ref <branch|tag|sha>] [--dest <path>]");
205
+ return 2;
206
+ }
207
+
208
+ // Parse ref: #fragment in URL takes precedence over --ref flag.
209
+ let repoUrl = rawUrl;
210
+ let ref: string | null = null;
211
+ const hashIdx = rawUrl.indexOf("#");
212
+ if (hashIdx !== -1) {
213
+ repoUrl = rawUrl.slice(0, hashIdx);
214
+ ref = rawUrl.slice(hashIdx + 1) || null;
215
+ }
216
+ if (!ref) ref = flagString(args.flags, "ref") ?? null;
217
+
218
+ const dest = path.resolve(flagString(args.flags, "dest", ".") ?? ".");
219
+ const force = flagBool(args.flags, "force") ?? false;
220
+ const update = flagBool(args.flags, "update") ?? false;
221
+
222
+ // Shallow-clone into a temporary directory.
223
+ const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "flow-kit-git-"));
224
+ try {
225
+ const cloneArgs = ["clone", "--depth", "1"];
226
+ if (ref) cloneArgs.push("--branch", ref);
227
+ cloneArgs.push("--", repoUrl, tmpBase);
228
+ try {
229
+ child_process.execFileSync("git", cloneArgs, { stdio: ["ignore", "pipe", "pipe"] });
230
+ } catch (err) {
231
+ const msg = err instanceof Error && (err as NodeJS.ErrnoException & { stderr?: Buffer }).stderr
232
+ ? ((err as NodeJS.ErrnoException & { stderr?: Buffer }).stderr as Buffer).toString().trim()
233
+ : String(err);
234
+ console.error(`install-git: git clone failed: ${msg}`);
235
+ return 1;
236
+ }
237
+
238
+ // Validate the cloned kit using the same logic as install-local.
239
+ let manifest: Record<string, unknown>;
240
+ try {
241
+ manifest = assertKitRepository(tmpBase);
242
+ } catch (error) {
243
+ console.log("Flow Kit repository validation failed:");
244
+ for (const diagnostic of ((error as Error & { diagnostics?: string[] }).diagnostics ?? [(error as Error).message])) {
245
+ console.log(` - ${diagnostic}`);
246
+ }
247
+ return 1;
248
+ }
249
+
250
+ // Delegate to the shared install logic (copy + registry update).
251
+ const kitId = String(manifest.id);
252
+ const hash = kitContentHash(tmpBase);
253
+ const registry = loadRegistry(dest);
254
+ const existing = registry.kits.find((entry) => entry.id === kitId);
255
+ const target = installedPath(dest, kitId);
256
+ assertPathContained(dest, target);
257
+ const sourceText = repoUrl + (ref ? `#${ref}` : "");
258
+ if (existing && existing.source !== sourceText && !update) {
259
+ console.log(`conflict: kit '${kitId}' is already installed from ${existing.source}; rerun with --update to replace it`);
260
+ return 2;
261
+ }
262
+ if (existing && existing.source === sourceText && existing.hash === hash && fs.existsSync(target) && !force) {
263
+ console.log(`kit '${kitId}' is already installed from ${sourceText}`);
264
+ return 0;
265
+ }
266
+ copyDir(tmpBase, target);
267
+ const entry: Record<string, unknown> = {
268
+ id: kitId,
269
+ source: sourceText,
270
+ hash,
271
+ installed_at: existing && existing.source === sourceText && !update ? existing.installed_at : isoNow(),
272
+ installed_path: target,
273
+ state: "installed",
274
+ };
275
+ if (typeof manifest.version === "string" && manifest.version) entry.version = manifest.version;
276
+ registry.kits = existing ? registry.kits.map((item) => item.id === kitId ? entry : item) : [...registry.kits, entry];
277
+ writeJson(registryPath(dest), registry);
278
+ console.log(`${existing ? "updated" : "installed"} git kit '${kitId}' from ${sourceText} at ${target}`);
279
+ return 0;
280
+ } finally {
281
+ fs.rmSync(tmpBase, { recursive: true, force: true });
282
+ }
283
+ }
284
+
135
285
  export function main(argv = process.argv.slice(2)): number {
136
286
  const [command, ...rest] = argv;
137
287
  if (command === "install-local") return installLocal(rest);
288
+ if (command === "install-git") return installGit(rest);
138
289
  if (command === "list") return list(rest);
139
290
  if (command === "status") return status(rest);
140
291
  if (command === "activate") return activate(rest);
141
- console.error("usage: flow-kit <install-local|list|status|activate> ...");
292
+ if (command === "inspect") return inspect(rest);
293
+ console.error("usage: flow-kit <install-local|install-git|list|status|activate|inspect> ...");
142
294
  return 2;
143
295
  }
144
296
 
145
- if (import.meta.url === `file://${process.argv[1]}`) process.exit(main());
297
+ // Use process.exitCode (not process.exit) to allow stdout to be flushed before exit.
298
+ // Resolve real paths to handle symlinks (e.g. /tmp -> /private/tmp on macOS) so the
299
+ // entry-point guard fires correctly when the module is loaded directly as a script.
300
+ const _selfRealPath = (() => { try { return fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return fileURLToPath(import.meta.url); } })();
301
+ const _argv1RealPath = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
302
+ if (_selfRealPath === _argv1RealPath) { process.exitCode = main(); }
@@ -27,4 +27,10 @@ export function main(argv = process.argv.slice(2)): number {
27
27
  return 0;
28
28
  }
29
29
 
30
- if (import.meta.url === `file://${process.argv[1]}`) process.exit(main());
30
+ // Use process.exitCode (not process.exit) to allow stdout to be flushed before exit.
31
+ // Resolve real paths to handle symlinks (e.g. /tmp -> /private/tmp on macOS).
32
+ import * as _fsVST from "node:fs";
33
+ import { fileURLToPath as _ftpVST } from "node:url";
34
+ const _selfVST = (() => { try { return _fsVST.realpathSync(_ftpVST(import.meta.url)); } catch { return _ftpVST(import.meta.url); } })();
35
+ const _argv1VST = (() => { try { return _fsVST.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
36
+ if (_selfVST === _argv1VST) { process.exitCode = main(); }