@kontourai/flow-agents 0.3.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 (62) hide show
  1. package/.github/workflows/kit-gates-demo.yml +171 -0
  2. package/.github/workflows/release-please.yml +13 -1
  3. package/AGENTS.md +8 -1
  4. package/CHANGELOG.md +53 -0
  5. package/CONTEXT.md +1 -1
  6. package/README.md +13 -2
  7. package/build/src/cli/flow-kit.js +41 -2
  8. package/build/src/flow-kit/validate.js +98 -0
  9. package/build/src/tools/validate-source-tree.js +2 -1
  10. package/context/scripts/hooks/config-protection.js +217 -15
  11. package/docs/fixture-ownership.md +1 -0
  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_fixture_retirement_audit.sh +2 -2
  28. package/evals/integration/test_hook_category_behaviors.sh +51 -0
  29. package/evals/integration/test_kit_conformance_levels.sh +209 -0
  30. package/evals/run.sh +2 -0
  31. package/evals/static/test_universal_bundles.sh +10 -0
  32. package/kits/catalog.json +6 -0
  33. package/kits/knowledge/adapters/default-store/index.js +95 -14
  34. package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
  35. package/kits/knowledge/adapters/flow-runner/index.js +639 -0
  36. package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
  37. package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
  38. package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
  39. package/kits/knowledge/adapters/shared/codec.js +325 -0
  40. package/kits/knowledge/adapters/similarity-vector/index.js +284 -0
  41. package/kits/knowledge/docs/README.md +193 -0
  42. package/kits/knowledge/docs/store-contract.md +196 -0
  43. package/kits/knowledge/evals/contract-suite/suite.test.js +10 -5
  44. package/kits/knowledge/evals/entities/demo-acme.js +125 -0
  45. package/kits/knowledge/evals/entities/suite.test.js +722 -0
  46. package/kits/knowledge/evals/retirement/suite.test.js +1173 -0
  47. package/kits/knowledge/evals/similarity-vector/suite.test.js +685 -0
  48. package/kits/knowledge/evals/synthesis/suite.test.js +10 -3
  49. package/kits/knowledge/flows/retire.flow.json +77 -0
  50. package/kits/knowledge/kit.json +31 -1
  51. package/kits/release-evidence/fixtures/claims/README.md +14 -0
  52. package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
  53. package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
  54. package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
  55. package/kits/release-evidence/kit.json +13 -0
  56. package/package.json +1 -1
  57. package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
  58. package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
  59. package/scripts/hooks/config-protection.js +217 -15
  60. package/src/cli/flow-kit.ts +40 -2
  61. package/src/flow-kit/validate.ts +127 -0
  62. package/src/tools/validate-source-tree.ts +2 -1
@@ -252,7 +252,11 @@ describe("AC2 — rejection leaves concept BYTE-IDENTICAL", () => {
252
252
  "AC2: concept body is byte-identical after rejection"
253
253
  );
254
254
 
255
- // Also verify the body string in the raw file bytes has not changed
255
+ // Also verify the body section in the markdown file is unchanged.
256
+ // Note: the mutation log now correctly serializes evidence (including the
257
+ // proposal text in the propose log entry). We verify the BODY field is
258
+ // unchanged, not the entire file — the mutation log is allowed to contain
259
+ // the proposal text as historical evidence.
256
260
  const fileContent = fs.readFileSync(
257
261
  path.join(dir, "records", `${conceptId}.md`),
258
262
  "utf8"
@@ -261,9 +265,12 @@ describe("AC2 — rejection leaves concept BYTE-IDENTICAL", () => {
261
265
  fileContent.includes("Initial definition of API design principles."),
262
266
  "AC2: original body text is present in the backing file after rejection"
263
267
  );
268
+ // The body section (after the final ---) must not contain the rejected proposal.
269
+ // Split on the closing frontmatter delimiter to get just the body portion.
270
+ const bodySection = fileContent.split("\n---\n").slice(-1)[0] || "";
264
271
  assert.ok(
265
- !fileContent.includes("Rejected body — should never appear in concept."),
266
- "AC2: proposed body text is NOT present in the backing file after rejection"
272
+ !bodySection.includes("Rejected body — should never appear in concept."),
273
+ "AC2: proposed body text is NOT in the markdown body section after rejection"
267
274
  );
268
275
  });
269
276
 
@@ -0,0 +1,77 @@
1
+ {
2
+ "id": "knowledge.retire",
3
+ "version": "1.0",
4
+ "steps": [
5
+ { "id": "identify", "next": "propose-retirement" },
6
+ { "id": "propose-retirement", "next": "evidence-gate" },
7
+ { "id": "evidence-gate", "next": "apply-or-reject" },
8
+ { "id": "apply-or-reject", "next": "done" },
9
+ { "id": "done", "next": null }
10
+ ],
11
+ "gates": {
12
+ "identify-gate": {
13
+ "step": "identify",
14
+ "expects": [
15
+ {
16
+ "id": "target-record-found",
17
+ "kind": "surface.claim",
18
+ "required": true,
19
+ "description": "The record to be retired has been identified and is in a retirable status (active or implemented). The record ID and current status are surfaced for the proposal step.",
20
+ "claim": {
21
+ "type": "knowledge.retire.identify",
22
+ "subject": "artifact",
23
+ "accepted_statuses": ["trusted", "accepted"]
24
+ }
25
+ }
26
+ ]
27
+ },
28
+ "propose-retirement-gate": {
29
+ "step": "propose-retirement",
30
+ "expects": [
31
+ {
32
+ "id": "retirement-proposal-recorded",
33
+ "kind": "surface.claim",
34
+ "required": true,
35
+ "description": "A retirement proposal has been recorded. The proposal carries: the target status (implemented or retired), the retirement rationale, and — when targetStatus is 'implemented' — an implementedByRef pointing to the implementing artifact (commit SHA, PR URL, issue number, etc.). The record is NOT mutated at this step — only a retirement proposal record and proposes link are created.",
36
+ "claim": {
37
+ "type": "knowledge.retire.proposal",
38
+ "subject": "artifact",
39
+ "accepted_statuses": ["trusted", "accepted"]
40
+ }
41
+ }
42
+ ]
43
+ },
44
+ "evidence-gate": {
45
+ "step": "evidence-gate",
46
+ "expects": [
47
+ {
48
+ "id": "retirement-evidence-valid",
49
+ "kind": "surface.claim",
50
+ "required": true,
51
+ "description": "The retirement proposal evidence is valid: rationale is non-empty; implementedByRef is non-empty when targetStatus is 'implemented'; the target record exists and is in a state that allows the requested transition (active→implemented, active→retired, implemented→retired). Gate rejects if any required evidence field is missing or the transition is invalid.",
52
+ "claim": {
53
+ "type": "knowledge.retire.evidence",
54
+ "subject": "artifact",
55
+ "accepted_statuses": ["trusted", "accepted"]
56
+ }
57
+ }
58
+ ]
59
+ },
60
+ "apply-gate": {
61
+ "step": "apply-or-reject",
62
+ "expects": [
63
+ {
64
+ "id": "retirement-gate-decision",
65
+ "kind": "surface.claim",
66
+ "required": true,
67
+ "description": "A gate decision (apply or reject) has been recorded. If applied: the record status is updated to the target status via the store retire op; the retirement evidence (rationale, implementedByRef, supersededByRef) is appended to the mutation log; the record body, links, and provenance remain intact; the record is excluded from default working-set queries (listByType, listByCategory, similarity detection). If rejected: the record status is byte-identical to its pre-proposal state.",
68
+ "claim": {
69
+ "type": "knowledge.retire.gate-decision",
70
+ "subject": "artifact",
71
+ "accepted_statuses": ["trusted", "accepted"]
72
+ }
73
+ }
74
+ ]
75
+ }
76
+ }
77
+ }
@@ -29,6 +29,11 @@
29
29
  "id": "knowledge.consolidate",
30
30
  "path": "flows/consolidate.flow.json",
31
31
  "description": "Consolidate related compiled records into a living decision snapshot: related-event trigger -> consolidation proposal -> evidence gate -> apply-or-reject. Creates or updates a snapshot record; supersedes prior snapshots via supersede op (never deletes). Reuses S3 mutation-gate machinery."
32
+ },
33
+ {
34
+ "id": "knowledge.retire",
35
+ "path": "flows/retire.flow.json",
36
+ "description": "Retire implemented or obsolete records from the working set via gated lifecycle: identify \u2192 propose-retirement \u2192 evidence-gate \u2192 apply-or-reject. Evidence required: retirement rationale + implementedByRef (when targeting 'implemented' status) or supersededByRef (optional, for 'retired'). Rejection leaves record status byte-identical. Retired records remain fully queryable with provenance via includeRetired flag."
32
37
  }
33
38
  ],
34
39
  "docs": [
@@ -50,7 +55,17 @@
50
55
  {
51
56
  "id": "knowledge.flow-runner",
52
57
  "path": "adapters/flow-runner/index.js",
53
- "description": "Executable flow logic: capture(rawText, meta) \u2192 classified raw record; compile(rawIds[]) \u2192 compiled record with provenance links; synthesize(conceptId | topicSelector, options) \u2192 concept summary proposal with mutation gate; consolidate(snapshotId | topicSelector, options) \u2192 decision snapshot consolidation with supersede-not-delete. Emits canonical telemetry events at gate points."
58
+ "description": "Executable flow logic: capture(rawText, meta) \u2192 classified raw record; compile(rawIds[]) \u2192 compiled record with provenance links; synthesize(conceptId | topicSelector, options) \u2192 concept summary proposal with mutation gate; consolidate(snapshotId | topicSelector, options) \u2192 decision snapshot consolidation with supersede-not-delete; retire(recordId, options) \u2192 gated status lifecycle transition (active\u2192implemented\u2192retired) with working-set exclusion. Emits canonical telemetry events at gate points."
59
+ },
60
+ {
61
+ "id": "knowledge.similarity-vector",
62
+ "path": "adapters/similarity-vector/index.js",
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."
54
69
  }
55
70
  ],
56
71
  "evals": [
@@ -73,6 +88,21 @@
73
88
  "id": "knowledge.consolidation-suite",
74
89
  "path": "evals/consolidation/suite.test.js",
75
90
  "description": "Eval cases for consolidate: AC1 (related event -> proposal, not mutation); AC2 (apply updates exactly ONE snapshot, links supersedes refs, superseded sources still queryable); AC3 fixture (decision changed across 3 events -> snapshot reflects ONLY latest decision, provenance to all 3); supersede-not-delete invariant; gate telemetry; contract suite extensions for snapshot semantics."
91
+ },
92
+ {
93
+ "id": "knowledge.similarity-vector-suite",
94
+ "path": "evals/similarity-vector/suite.test.js",
95
+ "description": "Eval cases for the vector similarity adapter: unit tests (cosineSimilarity math, injectable embed, threshold, fail-closed), drop-in proof (runner.synthesize with injected embed produces valid proposals), and live-gated tests (real ollama nomic-embed-text round-trip \u2014 skipped when ollama unavailable)."
96
+ },
97
+ {
98
+ "id": "knowledge.retirement-suite",
99
+ "path": "evals/retirement/suite.test.js",
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."
76
106
  }
77
107
  ]
78
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.3.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