ultra-builder-pro-cli 0.24.2 → 0.25.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 (40) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/adapters/claude.js +104 -0
  3. package/adapters/codex.js +363 -40
  4. package/adapters/grok.js +1254 -58
  5. package/adapters/kimi.js +682 -13
  6. package/adapters/opencode.js +132 -11
  7. package/docs/AGENT-CONTEXT.md +14 -0
  8. package/docs/ARTIFACT-AUTHORITY.md +13 -0
  9. package/docs/COMMIT-HASH-BACKFILL.md +25 -18
  10. package/docs/RUNTIME-COMPAT-MATRIX.md +18 -2
  11. package/mcp-server/lib/artifact-registry.cjs +181 -51
  12. package/mcp-server/lib/baseline-workflow.cjs +160 -23
  13. package/mcp-server/lib/change-workflow.cjs +21 -16
  14. package/mcp-server/lib/context-envelope.cjs +124 -51
  15. package/mcp-server/lib/decision-records.cjs +164 -78
  16. package/mcp-server/lib/kernel-change-workflow.cjs +2029 -0
  17. package/mcp-server/lib/legacy-change-workflow.cjs +294 -71
  18. package/mcp-server/lib/managed-file-write.cjs +56 -0
  19. package/mcp-server/lib/prd-parser.cjs +2 -2
  20. package/mcp-server/lib/stage-checkpoints.cjs +163 -9
  21. package/mcp-server/lib/state-db.cjs +186 -8
  22. package/mcp-server/lib/state-ops.cjs +214 -31
  23. package/mcp-server/lib/task-contract.cjs +404 -0
  24. package/mcp-server/lib/task-expander.cjs +2 -2
  25. package/mcp-server/lib/task-ledger.cjs +175 -62
  26. package/mcp-server/lib/ultra-facade.cjs +1786 -169
  27. package/mcp-server/lib/worker-packet.cjs +2 -2
  28. package/mcp-server/server.cjs +23 -4
  29. package/orchestrator/parallel-orchestrator.cjs +114 -27
  30. package/orchestrator/session-runner.cjs +25 -136
  31. package/package.json +1 -1
  32. package/skills/ultra-change/SKILL.md +6 -1
  33. package/skills/ultra-dev/SKILL.md +9 -4
  34. package/skills/ultra-plan/SKILL.md +13 -4
  35. package/skills/ultra-plan/references/semantic-preflight.md +6 -4
  36. package/spec/mcp-tools.yaml +114 -9
  37. package/spec/schemas/context-file.v4.5.schema.json +2 -2
  38. package/spec/schemas/state-db.sql +10 -6
  39. package/spec/schemas/task-ledger.v2.schema.json +23 -8
  40. package/spec/schemas/tasks.v4.5.schema.json +34 -12
@@ -6,6 +6,8 @@ const fs = require('node:fs');
6
6
  const crypto = require('node:crypto');
7
7
  const os = require('node:os');
8
8
  const path = require('node:path');
9
+ const { spawnSync } = require('node:child_process');
10
+ const { isDeepStrictEqual } = require('node:util');
9
11
 
10
12
  const {
11
13
  copyTree,
@@ -64,6 +66,79 @@ function resolveRepoRoot(ctx = {}) {
64
66
  return ctx.repoRoot || path.resolve(__dirname, '..');
65
67
  }
66
68
 
69
+ function shouldRunHostCli(ctx = {}) {
70
+ if (typeof ctx.runHostCli === 'boolean') return ctx.runHostCli;
71
+ return ctx.scope === 'global' && !ctx.configDir;
72
+ }
73
+
74
+ function runOpenCodeCli(args, ctx = {}) {
75
+ const target = resolveTarget(ctx);
76
+ const result = spawnSync(ctx.opencodeBin || 'opencode', args, {
77
+ cwd: ctx.cwd || process.cwd(),
78
+ encoding: 'utf8',
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ killSignal: 'SIGKILL',
81
+ env: {
82
+ ...process.env,
83
+ OPENCODE_CONFIG_DIR: target,
84
+ },
85
+ timeout: ctx.hostCliTimeoutMs || 30000,
86
+ });
87
+ if (result.error) {
88
+ if (result.error.code === 'ETIMEDOUT') {
89
+ const error = new Error(`opencode ${args.join(' ')} timed out`);
90
+ error.code = 'HOST_CLI_TIMEOUT';
91
+ throw error;
92
+ }
93
+ throw result.error;
94
+ }
95
+ if (result.status !== 0) {
96
+ const detail = (result.stderr || result.stdout || '').trim();
97
+ throw new Error(`opencode ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`);
98
+ }
99
+ return result.stdout;
100
+ }
101
+
102
+ function openCodeMcpRow(output, serverName) {
103
+ for (const rawLine of output.split(/\r?\n/)) {
104
+ const tokens = rawLine.trim().split(/\s+/);
105
+ if (tokens.some((token) => token === serverName)) return rawLine;
106
+ }
107
+ return '';
108
+ }
109
+
110
+ function inspectOpenCodeHost(ctx = {}) {
111
+ const target = resolveTarget(ctx);
112
+ const expectedLauncher = path.join(target, BUNDLE_DIR, 'runtime', 'launch.cjs');
113
+ const expected = managedMcpEntry(target);
114
+ const config = JSON.parse(runOpenCodeCli(['debug', 'config'], ctx));
115
+ const entry = config?.mcp?.[MCP_SERVER_NAME];
116
+ const configOk = isDeepStrictEqual(entry, expected);
117
+ const mcpOutput = runOpenCodeCli(['mcp', 'list'], ctx)
118
+ .replace(/\u001b\[[0-9;]*m/g, '');
119
+ const mcpLine = openCodeMcpRow(mcpOutput, MCP_SERVER_NAME);
120
+ const mcpOk = /\bconnected\b/i.test(mcpLine);
121
+ return { config, configOk, mcpOutput, mcpOk };
122
+ }
123
+
124
+ function managedMcpEntry(target) {
125
+ const command = mcpCommand(path.join(target, BUNDLE_DIR, 'runtime', 'launch.cjs'));
126
+ return {
127
+ type: 'local',
128
+ enabled: true,
129
+ command: [command.command, ...command.args],
130
+ };
131
+ }
132
+
133
+ function hasManagedMcpIdentity(entry, target) {
134
+ return Boolean(
135
+ entry
136
+ && typeof entry === 'object'
137
+ && !Array.isArray(entry)
138
+ && isDeepStrictEqual(entry.command, managedMcpEntry(target).command),
139
+ );
140
+ }
141
+
67
142
  function lowercaseFrontmatterTransform(buf, relPath) {
68
143
  if (!relPath.endsWith('.md')) return buf;
69
144
  const text = buf.toString('utf8');
@@ -550,12 +625,7 @@ function buildStaging(repoRoot, staging, target) {
550
625
  const configFile = path.join(target, 'opencode.json');
551
626
  const existing = readJsonSafe(configFile, { rescue: true });
552
627
  const mcp = { ...(existing.mcp || {}) };
553
- const command = mcpCommand(path.join(target, BUNDLE_DIR, 'runtime', 'launch.cjs'));
554
- mcp[MCP_SERVER_NAME] = {
555
- type: 'local',
556
- enabled: true,
557
- command: [command.command, ...command.args],
558
- };
628
+ mcp[MCP_SERVER_NAME] = managedMcpEntry(target);
559
629
  const next = { ...existing, mcp };
560
630
  delete next[LEGACY_SENTINEL_KEY];
561
631
  writeAtomic(path.join(staging, 'opencode.json'), JSON.stringify(next, null, 2) + '\n');
@@ -720,12 +790,45 @@ function doctor(ctx = {}) {
720
790
  });
721
791
  }
722
792
  report.checks.registration = { status: registrationOk ? 'pass' : 'fail' };
793
+ if (shouldRunHostCli(ctx)) {
794
+ try {
795
+ const host = inspectOpenCodeHost(ctx);
796
+ report.checks.host_config = { status: host.configOk ? 'pass' : 'fail' };
797
+ report.checks.host_mcp = { status: host.mcpOk ? 'pass' : 'fail' };
798
+ if (!host.configOk) {
799
+ report.issues.push({
800
+ code: 'HOST_CONFIG_NOT_CONSUMED',
801
+ path: configFile,
802
+ });
803
+ }
804
+ if (!host.mcpOk) {
805
+ report.issues.push({
806
+ code: 'HOST_MCP_NOT_CONNECTED',
807
+ server: MCP_SERVER_NAME,
808
+ });
809
+ }
810
+ } catch (error) {
811
+ report.checks.host_config = { status: 'fail' };
812
+ report.checks.host_mcp = { status: 'fail' };
813
+ report.issues.push({
814
+ code: error.code === 'HOST_CLI_TIMEOUT'
815
+ ? 'HOST_CLI_TIMEOUT'
816
+ : 'HOST_CLI_INSPECTION_FAILED',
817
+ message: error.message,
818
+ });
819
+ }
820
+ }
723
821
  return applyNativeDoctor(report, path.join(target, BUNDLE_DIR, 'runtime'));
724
822
  }
725
823
 
726
824
  function uninstall(ctx = {}) {
727
825
  const target = resolveTarget(ctx);
728
- const report = { target, removed: {}, config: { updated: false } };
826
+ const report = {
827
+ target,
828
+ removed: {},
829
+ config: { updated: false },
830
+ issues: [],
831
+ };
729
832
  const pluginFile = path.join(target, 'plugins', 'ultra-builder-pro.js');
730
833
  const bundleRoot = path.join(target, BUNDLE_DIR);
731
834
  const pluginOwned = fs.existsSync(pluginFile)
@@ -734,14 +837,31 @@ function uninstall(ctx = {}) {
734
837
  const configFile = path.join(target, 'opencode.json');
735
838
  if (fs.existsSync(configFile)) {
736
839
  const existing = readJsonSafe(configFile);
737
- if (pluginOwned || bundleOwned || existing[LEGACY_SENTINEL_KEY]) {
840
+ const currentMcp = existing.mcp?.[MCP_SERVER_NAME];
841
+ const expectedMcp = managedMcpEntry(target);
842
+ const mcpOwned = hasManagedMcpIdentity(currentMcp, target);
843
+ if (currentMcp !== undefined && !mcpOwned) {
844
+ report.config.skipped = true;
845
+ report.config.reason = 'ownership-conflict';
846
+ report.issues.push({
847
+ code: 'MCP_OWNERSHIP_CONFLICT',
848
+ path: configFile,
849
+ server: MCP_SERVER_NAME,
850
+ expected: expectedMcp,
851
+ actual: currentMcp,
852
+ });
853
+ return report;
854
+ }
855
+ if (mcpOwned || existing[LEGACY_SENTINEL_KEY]) {
738
856
  const mcp = { ...(existing.mcp || {}) };
739
- delete mcp[MCP_SERVER_NAME];
857
+ if (mcpOwned) delete mcp[MCP_SERVER_NAME];
740
858
  const next = { ...existing, mcp };
741
859
  delete next[LEGACY_SENTINEL_KEY];
742
860
  if (Object.keys(next.mcp || {}).length === 0) delete next.mcp;
743
- writeAtomic(configFile, JSON.stringify(next, null, 2) + '\n');
744
- report.config.updated = true;
861
+ if (!isDeepStrictEqual(next, existing)) {
862
+ writeAtomic(configFile, JSON.stringify(next, null, 2) + '\n');
863
+ report.config.updated = true;
864
+ }
745
865
  }
746
866
  }
747
867
  if (pluginOwned) {
@@ -792,6 +912,7 @@ module.exports = {
792
912
  SOURCE_TAG,
793
913
  BUNDLE_DIR,
794
914
  pluginSource,
915
+ inspectOpenCodeHost,
795
916
  resolveTarget,
796
917
  install,
797
918
  uninstall,
@@ -58,6 +58,20 @@ digest. It never creates the rejected semantic row or claims that authority was
58
58
  committed. `_ultra.projection_commit` reports only the post-call generated-view cycle;
59
59
  it is not a semantic acceptance receipt.
60
60
 
61
+ Every public input has one exact kind/action contract. Unknown fields, wrong scalar or
62
+ container types, invalid enums, malformed digests, and mismatched scopes are diagnosed
63
+ before SQLite affinity, filesystem writers, or host adapters can coerce them. A
64
+ correctable rejection returns `accepted: false`, `mutable: true`, typed diagnostics,
65
+ and bounded audit history; it is not an MCP transport failure. Reusing an idempotency
66
+ key for a different request follows that same repairable path, while retrying the exact
67
+ request returns the original result.
68
+
69
+ Every accepted mutation and its idempotency receipt are one logical commit. DB-only
70
+ writes share one immediate transaction. A write that also owns a managed file, team
71
+ checkpoint, worktree, Worker Packet, or archive journal either publishes both sides or
72
+ restores the prior bytes and rows. If compensation itself cannot be proven, the kernel
73
+ returns a typed recovery requirement instead of reporting partial success.
74
+
61
75
  ## Context Envelope
62
76
 
63
77
  Every Skill begins by reading `ultra.context` for the relevant stage and scope. The
@@ -88,6 +88,19 @@ durable effects, typed applied references, Stage Checkpoint evidence, artifact h
88
88
  verification commands and results, provenance, events, and recovery state. It must not
89
89
  include chain-of-thought, UI-click proof, raw prompts, or conversation transcripts.
90
90
 
91
+ Public kernel inputs are exact at their owning kind/action boundary. SQLite constraints
92
+ remain defense in depth, not the first definition of a business vocabulary: wrong
93
+ types, unsupported enums, unknown fields, malformed references, and invalid digests
94
+ are rejected as typed mutable diagnostics before any row or file write. The rejected
95
+ attempt is retained as audit evidence but never becomes semantic authority.
96
+
97
+ An accepted mutation and its durable idempotency receipt form one logical transaction.
98
+ DB-only operations commit them together. Operations that also publish managed files,
99
+ the Git team checkpoint, a Session worktree/Worker Packet, or an archive packet use a
100
+ recoverable journal or verified compensation. Failure restores the exact prior file
101
+ bytes, rows, ledger generation, lease, packet, and worktree where applicable; an
102
+ unprovable rollback becomes an explicit recovery condition rather than false success.
103
+
91
104
  Verified conclusions from `.ultra/.runtime/collab/` or other scratch locations must be promoted
92
105
  into the invoking DB-bound workflow artifact or report. Keeping a file in the scratch
93
106
  directory is not promotion.
@@ -11,27 +11,34 @@ longer changes any tracked file.
11
11
 
12
12
  After implementation, verification, and review are current:
13
13
 
14
- 1. Update the task to `completed`. The MCP publishes its durable status and contract to
15
- `.ultra/tasks/tasks.json`.
16
- 2. Create one local task commit containing the implementation, semantic Ultra
17
- artifacts, and the team ledger checkpoint.
18
- 3. Read the resulting `HEAD` and update only `completion_commit` through `task.update`.
19
- 4. Verify the task worktree `HEAD`, integration ancestry, review evidence, and public
20
- seam; then close the session and complete the dev workflow.
21
-
22
- The third step changes only ignored runtime state. Repeating it with the same hash is
23
- byte-idempotent and leaves Git clean.
24
-
25
- If the commit fails after step 1, transition the still-session-owned task from
26
- `completed` to `blocked`, preserve the worktree, and record the failure. This recovery
27
- transition is accepted only while `completion_commit` is absent. A task with a recorded
28
- completion commit remains terminal.
14
+ 1. Record `task_outcome / complete { id, packet_digest }`. Completion validates the
15
+ assigned Worker Packet and durable output, and accepts neither an arbitrary `patch`
16
+ nor any commit hash.
17
+ 2. Publish the completed durable status and Task contract with `ultra.sync`.
18
+ 3. Create one local integration commit containing the implementation, semantic Ultra
19
+ artifacts, and `.ultra/tasks/tasks.json`.
20
+ 4. Read the resulting full `HEAD` and record
21
+ `task_outcome / attest_commit { id, completion_commit }`.
22
+ 5. Verify the task worktree head, integration ancestry, review evidence, and public
23
+ seam; then release the session.
24
+
25
+ Attestation requires an already completed Task and a real commit integrated into the
26
+ current checkout. It changes only ignored runtime state, does not republish team
27
+ authority, and is idempotent for the same hash. Git remains clean, so no bookkeeping
28
+ commit is permitted.
29
+
30
+ If sync or the integration commit fails after completion, record
31
+ `task_outcome / block { id }`, preserve the still-session-owned worktree, and record the
32
+ failure. This recovery transition is accepted only while `completion_commit` is absent.
33
+ An attested Task cannot use this block recovery; an explicit later reopen records the
34
+ prior attestation in event history before clearing the current local proof.
29
35
 
30
36
  ## Parent-supervised workers
31
37
 
32
- A worker records evidence and exits without finalizing the parent-owned task. The
33
- primary host verifies and integrates the worker commit, publishes the task completion
34
- checkpoint, records the integrated commit hash locally, and settles the lease.
38
+ A worker records evidence and exits without finalizing the parent-owned Task. The
39
+ primary host validates and stages the worker result, records durable completion,
40
+ publishes the team checkpoint, creates the single authoritative integration commit,
41
+ attests that integrated hash locally, and settles the lease.
35
42
 
36
43
  ## Why the hash is local
37
44
 
@@ -106,6 +106,8 @@ instructions, project `.ultra`, unrelated plugins, and unrelated host configurat
106
106
  | Global flag | `--claude` | `--opencode` | `--codex` | `--kimi` | `--grok` |
107
107
  | Staging + atomic swap | FULL | FULL | FULL | FULL | FULL |
108
108
  | Exact-host preflight | FULL | FULL | FULL | FULL | FULL |
109
+ | Native registration truth | host manifest | host config | plugin manifest/cache | installed registry | `grok plugin list/details` |
110
+ | Active MCP truth | host MCP config | host MCP config | plugin cache | managed plugin config | `grok inspect` + `grok mcp doctor` |
109
111
  | Managed uninstall guard | FULL | FULL | FULL | FULL | FULL |
110
112
  | Preserve unrelated config | FULL | FULL | FULL | FULL | FULL |
111
113
  | Read-only Doctor | FULL | FULL | FULL | FULL | FULL |
@@ -114,6 +116,20 @@ Preflight uses the final manifest command and verifies initialize, `tools/list =
114
116
  public write/read, Doctor backup, close/reopen consistency, native provenance, and
115
117
  lazy project initialization.
116
118
 
119
+ The Grok adapter probes the installed CLI's command and flag capabilities rather
120
+ than branching on a Grok version number. Global installation uses Grok's native
121
+ plugin registry, keeps a durable managed local source for native refresh, and
122
+ resolves the MCP launcher through `${GROK_PLUGIN_ROOT}` after Grok copies the plugin
123
+ under its dynamic `installed-plugins/<repo-key>` path. An older raw
124
+ `$GROK_HOME/plugins/ultra-builder-pro` installation is backed up and migrated; a
125
+ failed native install restores the prior source and registration.
126
+
127
+ `grok mcp list` is not the plugin-MCP inventory surface in current Grok releases;
128
+ it reports explicit user/project MCP configuration. Ultra therefore does not add a
129
+ second `[mcp_servers]` entry merely to make that command display the plugin. The
130
+ authoritative plugin consumer checks are the native plugin registry, merged
131
+ `grok inspect` MCP source, and the live `grok mcp doctor` handshake.
132
+
117
133
  ## Durable compatibility
118
134
 
119
135
  | Capability | Contract |
@@ -138,5 +154,5 @@ are reconstructed only from verified authority.
138
154
  preservation.
139
155
  - `tests/package-smoke.test.cjs` installs the npm tarball and performs five real
140
156
  launcher write/read/backup/reopen round trips.
141
- - Grok acceptance also runs the available native plugin validate/inspect command and
142
- camelCase Hook fixtures.
157
+ - Grok acceptance runs capability probing, native install/list/details/enable,
158
+ merged inspect, MCP Doctor, migration rollback, and camelCase Hook fixtures.
@@ -17,6 +17,12 @@ const ENDPOINT_TYPES = new Set([
17
17
  ]);
18
18
  const OWNER_TYPES = new Set(['project', 'baseline', 'change', 'task', 'workflow']);
19
19
  const STATUSES = new Set(['current', 'stale', 'terminal', 'archived']);
20
+ const ARTIFACT_INPUT_FIELDS = new Set([
21
+ 'id', 'owner_type', 'owner_id', 'change_id', 'task_id', 'kind', 'path',
22
+ 'content_digest', 'expected_before_digest', 'source_refs', 'consumer_refs',
23
+ 'provenance', 'metadata', 'status',
24
+ ]);
25
+ const DIGEST = /^[0-9a-f]{64}$/;
20
26
  const OWNER_TABLES = Object.freeze({
21
27
  baseline: 'baselines',
22
28
  change: 'changes',
@@ -48,6 +54,70 @@ class ArtifactRegistryError extends Error {
48
54
  }
49
55
  }
50
56
 
57
+ function artifactText(value, field, { optional = false, nullable = false } = {}) {
58
+ if (value === undefined && optional) return undefined;
59
+ if (value === null && nullable) return null;
60
+ if (typeof value !== 'string' || value.trim() === '') {
61
+ throw new ArtifactRegistryError(
62
+ 'VALIDATION_ERROR',
63
+ `${field} must be a non-empty string${nullable ? ' or null' : ''}`,
64
+ );
65
+ }
66
+ return value.trim();
67
+ }
68
+
69
+ function artifactDigest(value, field, { nullable = false } = {}) {
70
+ if (value === undefined) return undefined;
71
+ if (value === null && nullable) return null;
72
+ if (typeof value !== 'string' || !DIGEST.test(value)) {
73
+ throw new ArtifactRegistryError(
74
+ 'VALIDATION_ERROR',
75
+ `${field} must be a lowercase SHA-256 digest${nullable ? ' or null' : ''}`,
76
+ );
77
+ }
78
+ return value;
79
+ }
80
+
81
+ function assertJsonValue(value, field, seen = new Set()) {
82
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
83
+ if (typeof value === 'number') {
84
+ if (Number.isFinite(value)) return;
85
+ throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must contain valid JSON values`);
86
+ }
87
+ if (typeof value !== 'object') {
88
+ throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must contain valid JSON values`);
89
+ }
90
+ if (seen.has(value)) {
91
+ throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must not contain circular references`);
92
+ }
93
+ if (!Array.isArray(value)) {
94
+ const prototype = Object.getPrototypeOf(value);
95
+ if (prototype !== Object.prototype && prototype !== null) {
96
+ throw new ArtifactRegistryError(
97
+ 'VALIDATION_ERROR', `${field} must contain only JSON objects`,
98
+ );
99
+ }
100
+ }
101
+ seen.add(value);
102
+ if (Array.isArray(value)) {
103
+ value.forEach((item, index) => assertJsonValue(item, `${field}[${index}]`, seen));
104
+ } else {
105
+ Object.entries(value).forEach(([key, item]) => (
106
+ assertJsonValue(item, `${field}.${key}`, seen)
107
+ ));
108
+ }
109
+ seen.delete(value);
110
+ }
111
+
112
+ function normalizeJsonObject(value, field) {
113
+ if (value === undefined) return {};
114
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
115
+ throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must be a JSON object`);
116
+ }
117
+ assertJsonValue(value, field);
118
+ return JSON.parse(JSON.stringify(value));
119
+ }
120
+
51
121
  function parseJson(value, fallback) {
52
122
  if (value == null || value === '') return fallback;
53
123
  try { return JSON.parse(value); } catch { return fallback; }
@@ -302,6 +372,13 @@ function normalizeEndpoint(endpoint, field) {
302
372
  if (!endpoint || typeof endpoint !== 'object' || Array.isArray(endpoint)) {
303
373
  throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must be an object`);
304
374
  }
375
+ const unknown = Object.keys(endpoint)
376
+ .filter((key) => !['type', 'id', 'relation'].includes(key));
377
+ if (unknown.length > 0) {
378
+ throw new ArtifactRegistryError(
379
+ 'VALIDATION_ERROR', `${field}.${unknown[0]} is not allowed`,
380
+ );
381
+ }
305
382
  const type = typeof endpoint.type === 'string' ? endpoint.type.trim() : '';
306
383
  const id = typeof endpoint.id === 'string' ? endpoint.id.trim() : '';
307
384
  const relation = typeof endpoint.relation === 'string' ? endpoint.relation.trim() : '';
@@ -313,6 +390,59 @@ function normalizeEndpoint(endpoint, field) {
313
390
  return { type, id, relation };
314
391
  }
315
392
 
393
+ function normalizeArtifactInput(input) {
394
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
395
+ throw new ArtifactRegistryError('VALIDATION_ERROR', 'artifact input is required');
396
+ }
397
+ const unknown = Object.keys(input).filter((field) => !ARTIFACT_INPUT_FIELDS.has(field));
398
+ if (unknown.length > 0) {
399
+ throw new ArtifactRegistryError(
400
+ 'VALIDATION_ERROR', `artifact.${unknown[0]} is not allowed`,
401
+ );
402
+ }
403
+ const status = input.status === undefined
404
+ ? 'current'
405
+ : artifactText(input.status, 'artifact.status');
406
+ if (!STATUSES.has(status)) {
407
+ throw new ArtifactRegistryError('VALIDATION_ERROR', `invalid artifact status: ${status}`);
408
+ }
409
+ const normalizeRefs = (value, field) => {
410
+ if (value === undefined) return [];
411
+ if (!Array.isArray(value)) {
412
+ throw new ArtifactRegistryError('VALIDATION_ERROR', `${field} must be an array`);
413
+ }
414
+ return value.map((item, index) => normalizeEndpoint(item, `${field}[${index}]`));
415
+ };
416
+ return {
417
+ id: artifactText(input.id, 'artifact.id', { optional: true }),
418
+ ownerType: artifactText(input.owner_type, 'artifact.owner_type'),
419
+ ownerId: artifactText(input.owner_id, 'artifact.owner_id'),
420
+ changeId: artifactText(
421
+ input.change_id,
422
+ 'artifact.change_id',
423
+ { optional: true, nullable: true },
424
+ ),
425
+ taskId: artifactText(
426
+ input.task_id,
427
+ 'artifact.task_id',
428
+ { optional: true, nullable: true },
429
+ ),
430
+ kind: artifactText(input.kind, 'artifact.kind'),
431
+ path: artifactText(input.path, 'artifact.path'),
432
+ status,
433
+ contentDigest: artifactDigest(input.content_digest, 'artifact.content_digest'),
434
+ expectedBeforeDigest: artifactDigest(
435
+ input.expected_before_digest,
436
+ 'artifact.expected_before_digest',
437
+ { nullable: true },
438
+ ),
439
+ sourceRefs: normalizeRefs(input.source_refs, 'artifact.source_refs'),
440
+ consumerRefs: normalizeRefs(input.consumer_refs, 'artifact.consumer_refs'),
441
+ provenance: normalizeJsonObject(input.provenance, 'artifact.provenance'),
442
+ metadata: normalizeJsonObject(input.metadata, 'artifact.metadata'),
443
+ };
444
+ }
445
+
316
446
  function assertEndpoint(db, endpoint, field) {
317
447
  const normalized = normalizeEndpoint(endpoint, field);
318
448
  const { type, id } = normalized;
@@ -339,6 +469,34 @@ function assertOwner(db, ownerType, ownerId) {
339
469
  }
340
470
  }
341
471
 
472
+ function assertArtifactOwner(db, ownerType, ownerId) {
473
+ assertOwner(db, ownerType, ownerId);
474
+ }
475
+
476
+ function assertDeclaredOwnerRefs(db, normalized) {
477
+ const expectedTaskId = normalized.ownerType === 'task' ? normalized.ownerId : null;
478
+ const expectedChangeId = normalized.ownerType === 'change'
479
+ ? normalized.ownerId
480
+ : normalized.ownerType === 'task'
481
+ ? db.prepare('SELECT change_id FROM tasks WHERE id = ?').get(normalized.ownerId)?.change_id || null
482
+ : normalized.ownerType === 'workflow'
483
+ ? db.prepare('SELECT change_id FROM workflow_runs WHERE id = ?').get(normalized.ownerId)?.change_id || null
484
+ : null;
485
+ for (const [field, supplied, expected] of [
486
+ ['change_id', normalized.changeId, expectedChangeId],
487
+ ['task_id', normalized.taskId, expectedTaskId],
488
+ ]) {
489
+ if (supplied !== undefined && supplied !== expected) {
490
+ throw new ArtifactRegistryError(
491
+ 'ARTIFACT_AUTHORITY_CONFLICT',
492
+ `artifact ${field} does not match its owner authority`,
493
+ { field, supplied, expected },
494
+ );
495
+ }
496
+ }
497
+ return { changeId: expectedChangeId, taskId: expectedTaskId };
498
+ }
499
+
342
500
  function edgesForArtifact(db, artifactId) {
343
501
  const sources = db.prepare(
344
502
  `SELECT source_type AS type, source_id AS id, relation
@@ -527,28 +685,20 @@ function invalidateDownstreamInTx(db, artifactId, priorConsumers = []) {
527
685
  }
528
686
 
529
687
  function preflightArtifactPublication(db, input, { rootDir = process.cwd() } = {}) {
530
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
531
- throw new ArtifactRegistryError('VALIDATION_ERROR', 'artifact input is required');
532
- }
533
- const ownerType = typeof input.owner_type === 'string' ? input.owner_type.trim() : '';
534
- const ownerId = typeof input.owner_id === 'string' ? input.owner_id.trim() : '';
535
- const kind = typeof input.kind === 'string' ? input.kind.trim() : '';
536
- const status = input.status || 'current';
537
- if (!kind || !STATUSES.has(status)) {
538
- throw new ArtifactRegistryError(
539
- 'VALIDATION_ERROR', 'artifact kind and valid status are required',
540
- );
541
- }
688
+ const normalized = normalizeArtifactInput(input);
689
+ const {
690
+ ownerType, ownerId, kind, status,
691
+ } = normalized;
542
692
  assertOwner(db, ownerType, ownerId);
543
- const resolved = resolveArtifactFile(rootDir, input.path);
693
+ assertDeclaredOwnerRefs(db, normalized);
694
+ const resolved = resolveArtifactFile(rootDir, normalized.path);
544
695
  if (isExemptArtifactPath(resolved.relative) || isGeneratedProjectionPath(resolved.relative)) {
545
696
  throw new ArtifactRegistryError(
546
697
  'ARTIFACT_PATH_EXEMPT',
547
698
  `runtime, scratch, and generated projection paths are not registry authority: ${resolved.relative}`,
548
699
  );
549
700
  }
550
- const explicitId = typeof input.id === 'string' && input.id.trim() !== ''
551
- ? input.id.trim() : null;
701
+ const explicitId = normalized.id || null;
552
702
  const existingById = explicitId
553
703
  ? db.prepare('SELECT * FROM artifacts WHERE id = ?').get(explicitId)
554
704
  : null;
@@ -625,18 +775,14 @@ function preflightArtifactPublication(db, input, { rootDir = process.cwd() } = {
625
775
  }
626
776
 
627
777
  function recordArtifactInTx(db, input, { rootDir = process.cwd() } = {}) {
628
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
629
- throw new ArtifactRegistryError('VALIDATION_ERROR', 'artifact input is required');
630
- }
631
- const ownerType = typeof input.owner_type === 'string' ? input.owner_type.trim() : '';
632
- const ownerId = typeof input.owner_id === 'string' ? input.owner_id.trim() : '';
633
- const kind = typeof input.kind === 'string' ? input.kind.trim() : '';
634
- const status = input.status || 'current';
635
- if (!kind || !STATUSES.has(status)) {
636
- throw new ArtifactRegistryError('VALIDATION_ERROR', 'artifact kind and valid status are required');
637
- }
778
+ const normalized = normalizeArtifactInput(input);
779
+ const {
780
+ ownerType, ownerId, kind, status,
781
+ sourceRefs, consumerRefs, provenance, metadata,
782
+ } = normalized;
638
783
  assertOwner(db, ownerType, ownerId);
639
- const resolved = resolveArtifactFile(rootDir, input.path);
784
+ const ownerRefs = assertDeclaredOwnerRefs(db, normalized);
785
+ const resolved = resolveArtifactFile(rootDir, normalized.path);
640
786
  if (isExemptArtifactPath(resolved.relative) || isGeneratedProjectionPath(resolved.relative)) {
641
787
  throw new ArtifactRegistryError(
642
788
  'ARTIFACT_PATH_EXEMPT',
@@ -645,18 +791,7 @@ function recordArtifactInTx(db, input, { rootDir = process.cwd() } = {}) {
645
791
  }
646
792
  const stableRead = openStableArtifactRead(resolved);
647
793
  try {
648
- const sourceRefs = Array.isArray(input.source_refs)
649
- ? input.source_refs.map((item, index) => normalizeEndpoint(item, `source_refs[${index}]`))
650
- : [];
651
- const consumerRefs = Array.isArray(input.consumer_refs)
652
- ? input.consumer_refs.map((item, index) => normalizeEndpoint(item, `consumer_refs[${index}]`))
653
- : [];
654
- const provenance = input.provenance && typeof input.provenance === 'object'
655
- && !Array.isArray(input.provenance) ? input.provenance : {};
656
- const metadata = input.metadata && typeof input.metadata === 'object'
657
- && !Array.isArray(input.metadata) ? input.metadata : {};
658
- const explicitId = typeof input.id === 'string' && input.id.trim() !== ''
659
- ? input.id.trim() : null;
794
+ const explicitId = normalized.id || null;
660
795
  const existingById = explicitId
661
796
  ? db.prepare('SELECT * FROM artifacts WHERE id = ?').get(explicitId)
662
797
  : null;
@@ -710,31 +845,25 @@ function recordArtifactInTx(db, input, { rootDir = process.cwd() } = {}) {
710
845
  });
711
846
  }
712
847
  const beforeDigest = existing?.digest || existing?.content_hash || null;
713
- if (input.expected_before_digest !== undefined
714
- && input.expected_before_digest !== beforeDigest) {
848
+ if (normalized.expectedBeforeDigest !== undefined
849
+ && normalized.expectedBeforeDigest !== beforeDigest) {
715
850
  throw new ArtifactRegistryError(
716
851
  'ARTIFACT_DIGEST_CONFLICT',
717
852
  `artifact ${artifactId} changed since it was read`,
718
- { expected: input.expected_before_digest, actual: beforeDigest },
853
+ { expected: normalized.expectedBeforeDigest, actual: beforeDigest },
719
854
  );
720
855
  }
721
856
  const afterDigest = stableRead.digest;
722
- if (input.content_digest !== undefined && input.content_digest !== afterDigest) {
857
+ if (normalized.contentDigest !== undefined && normalized.contentDigest !== afterDigest) {
723
858
  throw new ArtifactRegistryError(
724
859
  'ARTIFACT_DIGEST_CONFLICT',
725
860
  `artifact bytes do not match the supplied digest: ${resolved.relative}`,
726
- { expected: input.content_digest, actual: afterDigest },
861
+ { expected: normalized.contentDigest, actual: afterDigest },
727
862
  );
728
863
  }
729
864
  const changed = beforeDigest !== afterDigest;
730
865
  const priorConsumers = existing ? edgesForArtifact(db, artifactId).consumers : [];
731
- const ownerChangeId = ownerType === 'change'
732
- ? ownerId
733
- : ownerType === 'task'
734
- ? db.prepare('SELECT change_id FROM tasks WHERE id = ?').get(ownerId)?.change_id || null
735
- : ownerType === 'workflow'
736
- ? db.prepare('SELECT change_id FROM workflow_runs WHERE id = ?').get(ownerId)?.change_id || null
737
- : null;
866
+ const ownerChangeId = ownerRefs.changeId;
738
867
  const now = new Date().toISOString();
739
868
  let invalidated = [];
740
869
  db.prepare(
@@ -754,7 +883,7 @@ function recordArtifactInTx(db, input, { rootDir = process.cwd() } = {}) {
754
883
  ).run(
755
884
  artifactId, ownerType, ownerId,
756
885
  ownerChangeId,
757
- ownerType === 'task' ? ownerId : null,
886
+ ownerRefs.taskId,
758
887
  kind, resolved.relative, afterDigest, afterDigest, beforeDigest, afterDigest,
759
888
  JSON.stringify(provenance), JSON.stringify(metadata), status,
760
889
  existing?.created_at || now, now,
@@ -1085,6 +1214,7 @@ module.exports = {
1085
1214
  ENDPOINT_TYPES,
1086
1215
  OWNER_TYPES,
1087
1216
  STATUSES,
1217
+ assertArtifactOwner,
1088
1218
  digestFile,
1089
1219
  getArtifact,
1090
1220
  inspectArtifactHealth,