@kognai/orchestrator-core 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export * from './lib/trust-score-updater';
23
23
  export * from './lib/citizenship';
24
24
  export * from './lib/agent-registry';
25
25
  export * from './lib/sprint-state';
26
+ export * from './lib/build-triage';
26
27
  export * as ksl from './lib/ksl';
27
28
  export * from './lib/wallet-state';
28
29
  export * from './lib/ceo-wallet';
package/dist/index.js CHANGED
@@ -82,6 +82,9 @@ __exportStar(require("./lib/citizenship"), exports);
82
82
  __exportStar(require("./lib/agent-registry"), exports);
83
83
  // TICKET-098 sprint runtime-state split (committed defs vs gitignored status).
84
84
  __exportStar(require("./lib/sprint-state"), exports);
85
+ // TICKET-234 build triage seam — classifyBuildPath (ceremony-depth axis) +
86
+ // logBuildTriage. Consumed by the orchestrator run loop AND by kognai-build.
87
+ __exportStar(require("./lib/build-triage"), exports);
85
88
  // Phase 3b-3 Wave B: KSL capture cluster (session records + error log + tap).
86
89
  // Namespaced ('ksl.tapAttempt', 'ksl.writeRecord', 'ksl.record', …) to keep the
87
90
  // generic 'record' export off the package's flat public surface.
@@ -0,0 +1,27 @@
1
+ export type BuildPath = 'regulated' | 'fast';
2
+ export interface BuildTriageResult {
3
+ path: BuildPath;
4
+ reason: string;
5
+ triggers: string[];
6
+ sensitive: boolean;
7
+ sovereign: boolean;
8
+ complexity: 'low' | 'medium' | 'high';
9
+ taskCount: number;
10
+ codeFileCount: number;
11
+ }
12
+ /**
13
+ * Classify a build into a ceremony-depth path. Pure + synchronous: it only reads
14
+ * the sprint JSON + task list, so it adds negligible cost relative to the
15
+ * ceremony it gates. Default-to-regulated on uncertainty.
16
+ *
17
+ * @param sprint the raw sprint object (may carry regulated/strict/fast_track/sovereign flags)
18
+ * @param tasks the loaded task list (each with deliverables/context/type)
19
+ */
20
+ export declare function classifyBuildPath(sprint: any, tasks: any[]): BuildTriageResult;
21
+ /**
22
+ * Append the triage decision to logs/routing/triage.jsonl (location-independent
23
+ * via engine-paths). Best-effort: never throws into the run loop. This is the
24
+ * audit trail required by the ticket — evidence that triage routes correctly and
25
+ * the means to audit any fast-tracked build later.
26
+ */
27
+ export declare function logBuildTriage(sprintId: string, result: BuildTriageResult): void;
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyBuildPath = classifyBuildPath;
4
+ exports.logBuildTriage = logBuildTriage;
5
+ /**
6
+ * build-triage.ts — TICKET-234: pre-orchestrator build triage (ceremony-depth axis).
7
+ *
8
+ * The orchestrator historically ran the SAME full ceremony for every build
9
+ * regardless of complexity or sensitivity (CEO assessment → execution →
10
+ * dual-supervisor review → CTO data analysis → CEO proposal cycle → post-sprint
11
+ * + agent minting). A 3-line `addTwo` cost 171s + the entire ceremony.
12
+ *
13
+ * This module adds a cheap classifier that runs BEFORE the orchestrator hands
14
+ * off to the swarm and routes the build to one of two execution paths:
15
+ *
16
+ * - REGULATED (full ceremony) — CEO · CTO governance gate · dual-supervisor
17
+ * review · reconciliation · the full CTO/CEO proposal cycle. Templates apply
18
+ * on this path only. For sensitive / sovereign / complex builds.
19
+ *
20
+ * - FAST — coder → single-supervisor review → the minimum safety/security/
21
+ * quality gate (QA gate: typecheck/compile/no-secrets, always on). No
22
+ * dual-supervisor, no CTO/CEO post-sprint ceremony, no templates. For
23
+ * simple, low-stakes, single-file, non-sensitive builds.
24
+ *
25
+ * It is the CEREMONY-DEPTH axis. It composes WITH `assessTaskComplexity`
26
+ * (engine-helpers), which scores the MODEL-ROUTING axis — they are orthogonal:
27
+ * a fast-tracked build can still route to a cloud model, and a regulated build
28
+ * can still run local. This stays a PURE HEURISTIC (no LLM call) so the triage
29
+ * never reintroduces the overhead it removes.
30
+ *
31
+ * Safety invariant: DEFAULT TO REGULATED on any uncertainty. `fast` is returned
32
+ * only when the build is provably simple AND non-sensitive AND non-sovereign.
33
+ * A `fast_track` opt-in never lowers the safety floor — sensitivity/complexity
34
+ * still force REGULATED.
35
+ */
36
+ const fs_1 = require("fs");
37
+ const path_1 = require("path");
38
+ const engine_paths_1 = require("./engine-paths");
39
+ // Sensitivity keywords — PHI/PII, payments/financial, auth/secrets/credentials,
40
+ // crypto/on-chain/contracts, destructive or privileged operations. A match in a
41
+ // deliverable path, task context, or sprint goal forces REGULATED.
42
+ const SENSITIVE_KEYWORDS = [
43
+ // auth / secrets / credentials
44
+ 'auth', 'secret', 'credential', 'password', 'passwd', 'api[_-]?key', 'apikey',
45
+ 'access[_-]?token', 'private[_-]?key', 'privatekey', 'seed[_-]?phrase', 'mnemonic',
46
+ 'oauth', 'jwt', 'session[_-]?token', 'vault',
47
+ // payments / financial
48
+ 'payment', 'billing', 'invoice', 'payout', 'stripe', 'paypal', 'charge', 'refund',
49
+ 'kyc', 'ledger', 'wallet', 'pricing',
50
+ // crypto / on-chain / contracts
51
+ 'x402', 'crypto', 'on[_-]?chain', 'onchain', 'blockchain', 'smart[_-]?contract',
52
+ 'erc20', 'erc-20', 'usdc', '\\$kog', 'viem', 'web3',
53
+ // PHI / PII / regulated data
54
+ '\\bphi\\b', '\\bpii\\b', 'gdpr', 'hipaa', 'personal[_-]?data', 'health[_-]?record',
55
+ // schema / data migrations (high-blast-radius, hard to roll back)
56
+ 'migration', 'prisma/migrations', 'drop[_-]?table', 'alter[_-]?table',
57
+ ];
58
+ // Destructive / privileged operations — context-level matches force REGULATED.
59
+ const DESTRUCTIVE_KEYWORDS = [
60
+ 'rm\\s+-rf', 'drop\\s+table', 'delete\\s+from', 'truncate\\s+table',
61
+ 'force[_-]?push', '--force', 'chmod\\s+777', 'sudo\\b', 'kill\\s+-9',
62
+ ];
63
+ // Sensitive directories — a deliverable under one of these forces REGULATED.
64
+ const SENSITIVE_DIRS = [
65
+ 'banks/', 'wallet', 'ceo-wallet', 'gates/', 'policy/', 'contracts/', 'omel/',
66
+ 'x402-base/', 'prisma/', 'credential', '.env', 'auth/', 'secrets/',
67
+ ];
68
+ // Architectural / cross-cutting keywords — a match means the change is not a
69
+ // simple single-concern build → REGULATED.
70
+ const ARCHITECTURAL_KEYWORDS = [
71
+ 'architect', 'refactor', 'migrate', 'cross[_-]?cutting', 'system[_-]?wide',
72
+ 'redesign', 'database', 'schema', 'infrastructure', 'orchestrat', 'pipeline',
73
+ 'framework', 'breaking[_-]?change', 'rewrite',
74
+ ];
75
+ function compile(words) {
76
+ return new RegExp(`(${words.join('|')})`, 'i');
77
+ }
78
+ const SENSITIVE_RE = compile(SENSITIVE_KEYWORDS);
79
+ const DESTRUCTIVE_RE = compile(DESTRUCTIVE_KEYWORDS);
80
+ const ARCH_RE = compile(ARCHITECTURAL_KEYWORDS);
81
+ function asBool(v) {
82
+ return v === true || v === 'true' || v === 1 || v === '1';
83
+ }
84
+ /**
85
+ * Classify a build into a ceremony-depth path. Pure + synchronous: it only reads
86
+ * the sprint JSON + task list, so it adds negligible cost relative to the
87
+ * ceremony it gates. Default-to-regulated on uncertainty.
88
+ *
89
+ * @param sprint the raw sprint object (may carry regulated/strict/fast_track/sovereign flags)
90
+ * @param tasks the loaded task list (each with deliverables/context/type)
91
+ */
92
+ function classifyBuildPath(sprint, tasks) {
93
+ const triggers = [];
94
+ const taskList = Array.isArray(tasks) ? tasks : [];
95
+ const taskCount = taskList.length;
96
+ // Gather all the text we scan for sensitivity/architecture signals.
97
+ const deliverablePaths = [];
98
+ const codeFiles = [];
99
+ for (const t of taskList) {
100
+ const d = (t && t.deliverables) || {};
101
+ const code = Array.isArray(d.code) ? d.code : [];
102
+ const tests = Array.isArray(d.tests) ? d.tests : [];
103
+ const docs = Array.isArray(d.docs) ? d.docs : [];
104
+ codeFiles.push(...code);
105
+ deliverablePaths.push(...code, ...tests, ...docs);
106
+ }
107
+ const codeFileCount = new Set(codeFiles).size;
108
+ const contextText = taskList
109
+ .map((t) => `${(t && (t.context || t.title || t.id)) || ''} ${(t && (t.type || t.task_type)) || ''}`)
110
+ .join(' ');
111
+ const goalText = `${sprint?.name || sprint?.title || ''} ${sprint?.goal || sprint?.description || ''}`;
112
+ const pathText = deliverablePaths.join(' ');
113
+ const haystack = `${goalText} ${contextText} ${pathText}`;
114
+ // ── Explicit flags ────────────────────────────────────────────────────────
115
+ const forcedRegulated = asBool(sprint?.regulated) || asBool(sprint?.strict) || asBool(process.env.KOGNAI_FORCE_REGULATED);
116
+ const requestedFast = asBool(sprint?.fast_track) || asBool(sprint?.fast);
117
+ if (forcedRegulated)
118
+ triggers.push('explicit:regulated');
119
+ // ── Sensitivity ───────────────────────────────────────────────────────────
120
+ let sensitive = false;
121
+ if (SENSITIVE_RE.test(haystack)) {
122
+ sensitive = true;
123
+ triggers.push(`sensitive:keyword(${(haystack.match(SENSITIVE_RE) || [])[1]})`);
124
+ }
125
+ if (DESTRUCTIVE_RE.test(haystack)) {
126
+ sensitive = true;
127
+ triggers.push(`sensitive:destructive(${(haystack.match(DESTRUCTIVE_RE) || [])[1]})`);
128
+ }
129
+ const dirHit = SENSITIVE_DIRS.find((dir) => pathText.toLowerCase().includes(dir.toLowerCase()));
130
+ if (dirHit) {
131
+ sensitive = true;
132
+ triggers.push(`sensitive:path(${dirHit})`);
133
+ }
134
+ // ── Sovereign / regulated domain ──────────────────────────────────────────
135
+ const sovereign = asBool(sprint?.sovereign) || asBool(sprint?.regulated_domain);
136
+ if (sovereign)
137
+ triggers.push('sovereign');
138
+ // ── Complexity (ceremony-depth, NOT model routing) ────────────────────────
139
+ const estimated = String(sprint?.estimated_complexity || '').toLowerCase();
140
+ const archHit = ARCH_RE.test(haystack);
141
+ if (archHit)
142
+ triggers.push(`complex:architectural(${(haystack.match(ARCH_RE) || [])[1]})`);
143
+ if (taskCount > 1)
144
+ triggers.push(`complex:multi-task(${taskCount})`);
145
+ if (codeFileCount > 1)
146
+ triggers.push(`complex:multi-file(${codeFileCount})`);
147
+ if (estimated === 'high')
148
+ triggers.push('complex:estimated-high');
149
+ let complexity = 'low';
150
+ if (archHit || taskCount > 2 || codeFileCount > 2 || estimated === 'high')
151
+ complexity = 'high';
152
+ else if (taskCount > 1 || codeFileCount > 1 || estimated === 'medium')
153
+ complexity = 'medium';
154
+ // ── Decision (default-to-regulated) ───────────────────────────────────────
155
+ // A build is FAST-eligible only if it is provably simple AND nothing sensitive
156
+ // / sovereign / forced fired. Anything ambiguous → REGULATED.
157
+ const isSimple = taskCount === 1 && codeFileCount <= 1 && !archHit && estimated !== 'high';
158
+ const blockFast = forcedRegulated || sensitive || sovereign || !isSimple;
159
+ let path;
160
+ let reason;
161
+ if (blockFast) {
162
+ path = 'regulated';
163
+ reason =
164
+ forcedRegulated ? 'explicit --regulated/--strict flag'
165
+ : sensitive ? `sensitive build (${triggers.filter((t) => t.startsWith('sensitive')).join(', ')})`
166
+ : sovereign ? 'sovereign / regulated-domain build'
167
+ : `complexity above fast-track threshold (${triggers.filter((t) => t.startsWith('complex')).join(', ') || 'multi-concern'})`;
168
+ }
169
+ else {
170
+ path = 'fast';
171
+ reason = requestedFast
172
+ ? 'fast-track opt-in; simple non-sensitive single-file build'
173
+ : 'simple non-sensitive single-file build → fast track';
174
+ }
175
+ return { path, reason, triggers, sensitive, sovereign, complexity, taskCount, codeFileCount };
176
+ }
177
+ /**
178
+ * Append the triage decision to logs/routing/triage.jsonl (location-independent
179
+ * via engine-paths). Best-effort: never throws into the run loop. This is the
180
+ * audit trail required by the ticket — evidence that triage routes correctly and
181
+ * the means to audit any fast-tracked build later.
182
+ */
183
+ function logBuildTriage(sprintId, result) {
184
+ try {
185
+ const file = (0, path_1.join)((0, engine_paths_1.resolveEnginePaths)().logs, 'routing', 'triage.jsonl');
186
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(file), { recursive: true });
187
+ const row = {
188
+ sprint_id: sprintId,
189
+ path: result.path,
190
+ reason: result.reason,
191
+ triggers: result.triggers,
192
+ sensitive: result.sensitive,
193
+ sovereign: result.sovereign,
194
+ complexity: result.complexity,
195
+ task_count: result.taskCount,
196
+ code_file_count: result.codeFileCount,
197
+ logged_at: new Date().toISOString(),
198
+ };
199
+ (0, fs_1.appendFileSync)(file, JSON.stringify(row) + '\n');
200
+ }
201
+ catch { /* non-fatal — audit log must never break a run */ }
202
+ }
@@ -27,6 +27,79 @@ export interface KopusConfig {
27
27
  * prefix and its own rollNumber sequence inside the shared registry.
28
28
  * Add new companies here as they come online (Achiri, SCS-001, DRI, ...). */
29
29
  export declare const COMPANY_PREFIXES: Record<string, string>;
30
+ /**
31
+ * Who a spawned citizen *belongs to* — its lineage. SAF derives this from the
32
+ * spawn requester's DID so a new citizen inherits the requester's identity:
33
+ * - `company` internal product (kognai/voxight/invoica/kreativ/achiri/…). A
34
+ * product CEO's spawn belongs to that company → `did:kognai:{company}:{agent}`.
35
+ * - `external` an outside org (through the AMD-23 Cerberus airlock) →
36
+ * `did:external:{org}:{agent}`.
37
+ * - `user` a human's wallet in the citizen journey. **Exactly ONE agent per
38
+ * user per wallet** → `did:kognai:citizen:{wallet}`.
39
+ * - `scs` a Self-Committed Swarm a user's citizen forms. Sub-agents the
40
+ * citizen spawns inherit the swarm's id → `did:kognai:scs:{scsId}:{agent}`.
41
+ */
42
+ export type CitizenOwnerKind = 'company' | 'external' | 'user' | 'scs';
43
+ export interface CitizenOwner {
44
+ kind: CitizenOwnerKind;
45
+ /** company name | external org id | user wallet | scs id. */
46
+ id: string;
47
+ }
48
+ /** The Default Instrumentation Posture (TICKET-150 / SOP-GEN-014) carried per
49
+ * citizen: KSL monitoring + failure-log contribution + skill-bank participation. */
50
+ export interface CitizenInstrumentation {
51
+ ksl: boolean;
52
+ failure_log: boolean;
53
+ skill_bank: boolean;
54
+ }
55
+ /**
56
+ * Posture by owner kind. **Internal** citizens (kognai + product companies) are
57
+ * born fully instrumented and bound to all constitutional duties — ON, mandatory.
58
+ * **External** orgs/swarms and **user** citizens (+ the SCS they form) get the
59
+ * same machinery baseline-available but OFF; they opt in (see citizenBenefits)
60
+ * for fee discounts and, for users, the improvement loop.
61
+ */
62
+ export declare function defaultInstrumentation(owner_kind?: CitizenOwnerKind): CitizenInstrumentation;
63
+ /** Kognai-fee discount (%) granted per shared channel an opt-in citizen enables.
64
+ * Tunable Founder policy. */
65
+ export declare const SHARE_DISCOUNT_PCT: {
66
+ failure_log: number;
67
+ skill_bank: number;
68
+ };
69
+ export interface CitizenBenefits {
70
+ /** % off Kognai fees for opting into sharing (external + user + scs). */
71
+ fee_discount_pct: number;
72
+ /** Users (+ their SCS) who share skills may use the skill bank for free. */
73
+ skill_bank_free_use: boolean;
74
+ /** Users (+ their SCS) who share failures get Plumber review of their work,
75
+ * driven by failure-library findings. */
76
+ plumber_review: boolean;
77
+ }
78
+ /**
79
+ * Resolve the economic + improvement-loop benefits a citizen unlocks from its
80
+ * current instrumentation. Internal citizens are first-party (mandatory posture,
81
+ * no discount concept). External/user/scs earn a fee discount per shared channel;
82
+ * users (+ their SCS) additionally unlock free skill-bank use + Plumber review.
83
+ */
84
+ export declare function citizenBenefits(c: Pick<CitizenRecord, 'owner_kind' | 'instrumentation'>): CitizenBenefits;
85
+ /** Apply a citizen's earned discount to a Kognai fee (atomic units). */
86
+ export declare function applyCitizenDiscount(feeAtomic: number, c: Pick<CitizenRecord, 'owner_kind' | 'instrumentation'>): number;
87
+ /** Skill-bank gate: may this citizen draw from the skill bank for free? */
88
+ export declare function canUseSkillBankFree(c: Pick<CitizenRecord, 'owner_kind' | 'instrumentation'>): boolean;
89
+ /** Plumber gate: is this citizen's work eligible for failure-log-driven Plumber review? */
90
+ export declare function eligibleForPlumberReview(c: Pick<CitizenRecord, 'owner_kind' | 'instrumentation'>): boolean;
91
+ /**
92
+ * Resolve an agent's canonical *instance* identity (DID + citizen_id) from the
93
+ * registry — the key failure-logging / KSL / SCORE must attribute to, instead of
94
+ * the role string (the TICKET-152 `agent_id="coder"` corruption). Returns null
95
+ * when the agent has no citizen record.
96
+ */
97
+ export declare function identityFor(agent_name: string, company?: string): {
98
+ agent_did: string;
99
+ citizen_id: string;
100
+ owner_kind?: CitizenOwnerKind;
101
+ owner_id?: string;
102
+ } | null;
30
103
  export interface CitizenRecord {
31
104
  /** Stable identifier. For company-scoped mints: `{PREFIX}-{rollNumber padded 4}`
32
105
  * (e.g. `VXG-0042`). For legacy/Kognai-internal mints (no `company`):
@@ -39,12 +112,24 @@ export interface CitizenRecord {
39
112
  /** Agent slug (matches agents/<name>/ dir within the owning company). */
40
113
  agent_name: string;
41
114
  /** Owning company. Defaults to 'kognai' for legacy records that predate the
42
- * multi-company extension. Idempotency key is `(agent_name, company)`. */
115
+ * multi-company extension. Idempotency key is `(agent_name, company)`.
116
+ * Set only when owner_kind === 'company' (back-compat alias for owner_id). */
43
117
  company?: string;
118
+ /** Lineage owner kind — who this citizen belongs to (see CitizenOwner). */
119
+ owner_kind?: CitizenOwnerKind;
120
+ /** Lineage owner id — company name | external org | user wallet | scs id. */
121
+ owner_id?: string;
122
+ /** Default Instrumentation Posture (KSL / failure-log / skill-bank). Internal
123
+ * citizens = all ON (mandatory); external/user/scs = baseline OFF, opt-in. */
124
+ instrumentation?: CitizenInstrumentation;
44
125
  /** DID for SCORE / ERC-8004 reputation tracking. Company-scoped form:
45
126
  * `did:kognai:{company}:{agent_name}`. Legacy form (no company in path)
46
127
  * is `did:kognai:{agent_name}`. */
47
128
  agent_did: string;
129
+ /** Prior DID a citizen carried before a company-scoped re-mint (e.g. legacy
130
+ * `did:kognai:{agent}`). Preserved so SCORE / ERC-8004 reputation history
131
+ * keyed on the old DID isn't orphaned by the migration. */
132
+ legacy_did?: string;
48
133
  /** ISO-8601 mint timestamp. */
49
134
  mintedAt: string;
50
135
  /** New citizens start at Tier I; promotion is constitutional. */
@@ -82,12 +167,14 @@ export declare function mintCitizen(agent_name: string, opts?: {
82
167
  tier?: CitizenTier;
83
168
  citizen_type?: CitizenType;
84
169
  now?: Date;
85
- /** Owning company. When provided, the mint uses company-scoped roll
86
- * numbers (separate sequence per company), the `{PREFIX}-{rollNum padded}`
87
- * citizen_id format, and the `did:kognai:{company}:{agent_name}` DID
88
- * form. When omitted, behaves exactly as before (legacy Kognai-internal
89
- * path: random hex citizen_id, global rollNumber sequence). */
170
+ /** @deprecated prefer `owner: { kind:'company', id }`. Owning company
171
+ * back-compat alias that maps to owner kind 'company'. */
90
172
  company?: string;
173
+ /** Lineage owner — who the citizen belongs to. SAF derives this from the
174
+ * spawn requester's DID so the new citizen inherits the requester's
175
+ * identity (company / external org / user wallet / SCS). When omitted
176
+ * (and no `company`), uses the legacy Kognai-internal path. */
177
+ owner?: CitizenOwner;
91
178
  }): CitizenRecord;
92
179
  /** Lookup an existing citizen by (agent_name, company). Returns null when
93
180
  * no match. Treats undefined company as 'kognai' for legacy compatibility. */
@@ -17,7 +17,13 @@
17
17
  * Stdlib only. No LLM, no network. Pure issuance.
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.COMPANY_PREFIXES = void 0;
20
+ exports.SHARE_DISCOUNT_PCT = exports.COMPANY_PREFIXES = void 0;
21
+ exports.defaultInstrumentation = defaultInstrumentation;
22
+ exports.citizenBenefits = citizenBenefits;
23
+ exports.applyCitizenDiscount = applyCitizenDiscount;
24
+ exports.canUseSkillBankFree = canUseSkillBankFree;
25
+ exports.eligibleForPlumberReview = eligibleForPlumberReview;
26
+ exports.identityFor = identityFor;
21
27
  exports.mintCitizen = mintCitizen;
22
28
  exports.lookupCitizen = lookupCitizen;
23
29
  exports.renderCitizenYaml = renderCitizenYaml;
@@ -41,7 +47,84 @@ exports.COMPANY_PREFIXES = {
41
47
  kognai: 'KGN',
42
48
  voxight: 'VXG',
43
49
  invoica: 'INV',
50
+ kreativ: 'KRV', // Kognai Kreativ — SCS001 → creative studio (TICKET-158/306)
51
+ scs001: 'SCS', // legacy content swarm; folds into Kreativ as agents migrate
52
+ achiri: 'ACH', // Tunisia AI companion
53
+ asterpay: 'APY', // payments product
54
+ dri: 'DRI', // Dynamic Research Instrument (Phase 3)
44
55
  };
56
+ /** citizen_id prefixes for the non-company owner kinds (company uses COMPANY_PREFIXES).
57
+ * These use a GLOBAL per-kind roll sequence; the specific owner lives in the DID. */
58
+ const OWNER_PREFIX = {
59
+ external: 'EXT',
60
+ user: 'CIT',
61
+ scs: 'SWM',
62
+ };
63
+ /**
64
+ * Posture by owner kind. **Internal** citizens (kognai + product companies) are
65
+ * born fully instrumented and bound to all constitutional duties — ON, mandatory.
66
+ * **External** orgs/swarms and **user** citizens (+ the SCS they form) get the
67
+ * same machinery baseline-available but OFF; they opt in (see citizenBenefits)
68
+ * for fee discounts and, for users, the improvement loop.
69
+ */
70
+ function defaultInstrumentation(owner_kind) {
71
+ const internal = !owner_kind || owner_kind === 'company';
72
+ return { ksl: internal, failure_log: internal, skill_bank: internal };
73
+ }
74
+ /** Kognai-fee discount (%) granted per shared channel an opt-in citizen enables.
75
+ * Tunable Founder policy. */
76
+ exports.SHARE_DISCOUNT_PCT = { failure_log: 10, skill_bank: 10 };
77
+ /**
78
+ * Resolve the economic + improvement-loop benefits a citizen unlocks from its
79
+ * current instrumentation. Internal citizens are first-party (mandatory posture,
80
+ * no discount concept). External/user/scs earn a fee discount per shared channel;
81
+ * users (+ their SCS) additionally unlock free skill-bank use + Plumber review.
82
+ */
83
+ function citizenBenefits(c) {
84
+ const i = c.instrumentation ?? defaultInstrumentation(c.owner_kind);
85
+ const optInEligible = c.owner_kind === 'external' || c.owner_kind === 'user' || c.owner_kind === 'scs';
86
+ const isUser = c.owner_kind === 'user' || c.owner_kind === 'scs';
87
+ let discount = 0;
88
+ if (optInEligible) {
89
+ if (i.failure_log)
90
+ discount += exports.SHARE_DISCOUNT_PCT.failure_log;
91
+ if (i.skill_bank)
92
+ discount += exports.SHARE_DISCOUNT_PCT.skill_bank;
93
+ }
94
+ return {
95
+ fee_discount_pct: discount,
96
+ skill_bank_free_use: isUser && i.skill_bank,
97
+ plumber_review: isUser && i.failure_log,
98
+ };
99
+ }
100
+ // ─── citizenBenefits consumers (billing / skill-bank / plumber) ─────────────────
101
+ // The integration surface for downstream systems. They call these instead of
102
+ // re-deriving policy, so the discount/free-use/review rules live in one place.
103
+ /** Apply a citizen's earned discount to a Kognai fee (atomic units). */
104
+ function applyCitizenDiscount(feeAtomic, c) {
105
+ const pct = citizenBenefits(c).fee_discount_pct;
106
+ return Math.max(0, Math.round(feeAtomic * (1 - pct / 100)));
107
+ }
108
+ /** Skill-bank gate: may this citizen draw from the skill bank for free? */
109
+ function canUseSkillBankFree(c) {
110
+ return citizenBenefits(c).skill_bank_free_use;
111
+ }
112
+ /** Plumber gate: is this citizen's work eligible for failure-log-driven Plumber review? */
113
+ function eligibleForPlumberReview(c) {
114
+ return citizenBenefits(c).plumber_review;
115
+ }
116
+ /**
117
+ * Resolve an agent's canonical *instance* identity (DID + citizen_id) from the
118
+ * registry — the key failure-logging / KSL / SCORE must attribute to, instead of
119
+ * the role string (the TICKET-152 `agent_id="coder"` corruption). Returns null
120
+ * when the agent has no citizen record.
121
+ */
122
+ function identityFor(agent_name, company) {
123
+ const c = lookupCitizen({ agent_name, company });
124
+ if (!c)
125
+ return null;
126
+ return { agent_did: c.agent_did, citizen_id: c.citizen_id, owner_kind: c.owner_kind, owner_id: c.owner_id };
127
+ }
45
128
  // ─── Public API ───────────────────────────────────────────────────────────────
46
129
  /**
47
130
  * Mint a new citizen. Assigns ID + roll number, writes the registry
@@ -65,26 +148,49 @@ function mintCitizen(agent_name, opts = {}) {
65
148
  // is a denormalized index that should always be derivable from them.
66
149
  reconcileFromDisk();
67
150
  const registry = readRegistry();
68
- // Idempotency: lookup by (agent_name, company). Treat undefined company as
69
- // 'kognai' so existing records (which predate the company field) match
70
- // mints that explicitly target the kognai company.
71
- const companyKey = opts.company; // undefined for legacy path
72
- const existing = registry.citizens.find((c) => c.agent_name === agent_name && (c.company ?? 'kognai') === (companyKey ?? 'kognai'));
151
+ // Resolve lineage. `owner` wins; `company` is the back-compat alias; neither
152
+ // legacy kognai-internal path (random hex id, global roll, did:kognai:{agent}).
153
+ const owner = opts.owner ?? (opts.company ? { kind: 'company', id: opts.company } : null);
154
+ // Idempotency. user exactly ONE per wallet (key on owner_id only, ignoring
155
+ // agent_name a wallet has a single citizen). company/external/scs
156
+ // (agent_name, kind, owner_id). legacy → (agent_name, company='kognai').
157
+ const ownerIdOf = (c) => c.owner_id ?? c.company ?? 'kognai';
158
+ // Legacy records (no owner_kind) are company-scoped (kognai by default).
159
+ const ownerKindOf = (c) => c.owner_kind ?? 'company';
160
+ const existing = registry.citizens.find((c) => {
161
+ if (owner?.kind === 'user')
162
+ return c.owner_kind === 'user' && c.owner_id === owner.id;
163
+ if (owner)
164
+ return c.agent_name === agent_name && ownerKindOf(c) === owner.kind && ownerIdOf(c) === owner.id;
165
+ return c.agent_name === agent_name && (c.company ?? 'kognai') === 'kognai';
166
+ });
73
167
  if (existing)
74
168
  return existing;
75
169
  const now = opts.now ?? new Date();
76
170
  let citizen_id;
77
171
  let rollNumber;
78
172
  let agent_did;
79
- if (companyKey) {
80
- // Company-scoped path: per-company rollNumber, prefixed ID, scoped DID.
81
- const prefix = exports.COMPANY_PREFIXES[companyKey] ?? companyKey.slice(0, 3).toUpperCase();
82
- const companyRolls = registry.citizens
83
- .filter((c) => (c.company ?? 'kognai') === companyKey)
173
+ if (owner?.kind === 'company') {
174
+ // Per-company rollNumber, COMPANY_PREFIXES id, did:kognai:{company}:{agent}.
175
+ const prefix = exports.COMPANY_PREFIXES[owner.id] ?? owner.id.slice(0, 3).toUpperCase();
176
+ const rolls = registry.citizens
177
+ .filter((c) => ownerKindOf(c) === 'company' && ownerIdOf(c) === owner.id)
84
178
  .map((c) => c.rollNumber);
85
- rollNumber = companyRolls.length > 0 ? Math.max(...companyRolls) + 1 : 1;
179
+ rollNumber = rolls.length ? Math.max(...rolls) + 1 : 1;
86
180
  citizen_id = `${prefix}-${String(rollNumber).padStart(4, '0')}`;
87
- agent_did = `did:kognai:${companyKey}:${agent_name}`;
181
+ agent_did = `did:kognai:${owner.id}:${agent_name}`;
182
+ }
183
+ else if (owner) {
184
+ // user / scs / external — GLOBAL per-kind roll; the specific owner lives in
185
+ // the DID. user: 1 per wallet → did:kognai:citizen:{wallet}.
186
+ const prefix = OWNER_PREFIX[owner.kind];
187
+ const rolls = registry.citizens.filter((c) => c.owner_kind === owner.kind).map((c) => c.rollNumber);
188
+ rollNumber = rolls.length ? Math.max(...rolls) + 1 : 1;
189
+ citizen_id = `${prefix}-${String(rollNumber).padStart(4, '0')}`;
190
+ agent_did =
191
+ owner.kind === 'user' ? `did:kognai:citizen:${owner.id}`
192
+ : owner.kind === 'scs' ? `did:kognai:scs:${owner.id}:${agent_name}`
193
+ : `did:external:${owner.id}:${agent_name}`;
88
194
  }
89
195
  else {
90
196
  // Legacy Kognai-internal path: random hex ID + global rollNumber sequence.
@@ -96,7 +202,11 @@ function mintCitizen(agent_name, opts = {}) {
96
202
  citizen_id,
97
203
  rollNumber,
98
204
  agent_name,
99
- ...(companyKey ? { company: companyKey } : {}),
205
+ ...(owner?.kind === 'company' ? { company: owner.id } : {}),
206
+ ...(owner ? { owner_kind: owner.kind, owner_id: owner.id } : {}),
207
+ // Every minted citizen carries the posture: internal ON, external/user/scs
208
+ // baseline-OFF (opt-in). KSL/failure-log/skill-bank + duties wire from this.
209
+ instrumentation: defaultInstrumentation(owner?.kind),
100
210
  agent_did,
101
211
  mintedAt: now.toISOString(),
102
212
  tier: opts.tier ?? 'I',
@@ -112,9 +222,9 @@ function mintCitizen(agent_name, opts = {}) {
112
222
  };
113
223
  registry.citizens.push(record);
114
224
  registry.total = registry.citizens.length;
115
- // Only advance the global next_roll_number on legacy mints — company-scoped
116
- // mints have their own per-company counter computed at mint time.
117
- if (!companyKey)
225
+ // Advance the global next_roll_number only on the legacy path; owner-scoped
226
+ // mints compute their own roll at mint time.
227
+ if (!owner)
118
228
  registry.next_roll_number = rollNumber + 1;
119
229
  writeRegistry(registry);
120
230
  return record;
@@ -138,14 +248,17 @@ function renderCitizenYaml(c) {
138
248
  citizen_id: ${c.citizen_id}
139
249
  rollNumber: ${c.rollNumber}
140
250
  agent_name: ${c.agent_name}
141
- agent_did: ${c.agent_did}
142
- mintedAt: "${c.mintedAt}"
251
+ ${c.owner_kind ? `owner_kind: ${c.owner_kind}\n` : ''}${c.owner_id ? `owner_id: ${c.owner_id}\n` : ''}${c.company ? `company: ${c.company}\n` : ''}agent_did: ${c.agent_did}
252
+ ${c.legacy_did ? `legacy_did: ${c.legacy_did}\n` : ''}mintedAt: "${c.mintedAt}"
143
253
  tier: ${c.tier}
144
254
  citizen_type: ${c.citizen_type}
145
255
  mascot:
146
256
  hue: ${c.mascot.hue}
147
257
  state: ${c.mascot.state}
148
258
  reputation: ${c.reputation}
259
+ instrumentation_ksl: ${c.instrumentation?.ksl ?? true}
260
+ instrumentation_failure_log: ${c.instrumentation?.failure_log ?? true}
261
+ instrumentation_skill_bank: ${c.instrumentation?.skill_bank ?? true}
149
262
  founding_agent: ${c.founding_agent ?? 'ceo'}
150
263
  proposing_agent: ${c.proposing_agent ?? 'cto'}
151
264
  ${c.origin_proposal_id ? `origin_proposal_id: ${c.origin_proposal_id}\n` : ''}`;
@@ -256,7 +369,11 @@ function parseCitizenYaml(raw) {
256
369
  citizen_id: flat.citizen_id,
257
370
  rollNumber: parseInt(flat.rollNumber, 10),
258
371
  agent_name: flat.agent_name,
372
+ ...(flat.company ? { company: flat.company } : {}),
373
+ ...(flat.owner_kind ? { owner_kind: flat.owner_kind } : {}),
374
+ ...(flat.owner_id ? { owner_id: flat.owner_id } : {}),
259
375
  agent_did: flat.agent_did || `did:kognai:${flat.agent_name}`,
376
+ ...(flat.legacy_did ? { legacy_did: flat.legacy_did } : {}),
260
377
  mintedAt: flat.mintedAt,
261
378
  tier: flat.tier || 'I',
262
379
  citizen_type: flat.citizen_type || 'spawned',
@@ -265,6 +382,13 @@ function parseCitizenYaml(raw) {
265
382
  state: mascot.state || 'idle',
266
383
  },
267
384
  reputation: flat.reputation ? parseInt(flat.reputation, 10) : 50,
385
+ instrumentation: flat.instrumentation_ksl !== undefined
386
+ ? {
387
+ ksl: flat.instrumentation_ksl === 'true',
388
+ failure_log: flat.instrumentation_failure_log === 'true',
389
+ skill_bank: flat.instrumentation_skill_bank === 'true',
390
+ }
391
+ : defaultInstrumentation(flat.owner_kind),
268
392
  founding_agent: flat.founding_agent,
269
393
  proposing_agent: flat.proposing_agent,
270
394
  origin_proposal_id: flat.origin_proposal_id,
@@ -1,3 +1,4 @@
1
+ import { type CitizenOwner } from './citizenship';
1
2
  import type { AgentTask, ReviewResult, CTOProposal, CTOReport } from './orchestrate-engine';
2
3
  export declare class SupervisorAgent {
3
4
  private systemPrompt;
@@ -62,6 +63,12 @@ export interface SpawnGateResult {
62
63
  pending_approval?: boolean;
63
64
  /** Optional one-line audit string logged on an approved decision. */
64
65
  audit?: string;
66
+ /** Lineage owner the gate resolved from the spawn requester (via SAF /
67
+ * deriveOwner). When set, the engine mints the citizen owner-scoped instead
68
+ * of legacy — so an Invoica swarm's spawn becomes an Invoica citizen, a
69
+ * Voxight swarm's a Voxight citizen, etc. The running company is carried by
70
+ * the template-injected gate's requester_did, not hardcoded in the engine. */
71
+ owner?: CitizenOwner;
65
72
  }
66
73
  export type SpawnGate = (spec: AgentSpawnSpec) => SpawnGateResult;
67
74
  export declare class AgentCreator {