orizu 0.5.38 → 0.5.39

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 (91) hide show
  1. package/README.md +18 -3
  2. package/dist/app-commands.d.ts.map +1 -1
  3. package/dist/app-commands.js +215 -57
  4. package/dist/app-publish-failure.d.ts +6 -0
  5. package/dist/app-publish-failure.d.ts.map +1 -0
  6. package/dist/app-publish-failure.js +9 -0
  7. package/dist/app-publish-paths.d.ts +4 -0
  8. package/dist/app-publish-paths.d.ts.map +1 -0
  9. package/dist/app-publish-paths.js +32 -0
  10. package/dist/app-publish-workspace.d.ts +16 -0
  11. package/dist/app-publish-workspace.d.ts.map +1 -0
  12. package/dist/app-publish-workspace.js +175 -0
  13. package/dist/artifacts-git-runtime.d.ts +5 -0
  14. package/dist/artifacts-git-runtime.d.ts.map +1 -1
  15. package/dist/artifacts-git-runtime.js +70 -25
  16. package/dist/artifacts-session-merge.d.ts +62 -0
  17. package/dist/artifacts-session-merge.d.ts.map +1 -0
  18. package/dist/artifacts-session-merge.js +123 -0
  19. package/dist/dataset-append-workflow.d.ts +12 -0
  20. package/dist/dataset-append-workflow.d.ts.map +1 -0
  21. package/dist/dataset-append-workflow.js +198 -0
  22. package/dist/dataset-commands.d.ts.map +1 -1
  23. package/dist/dataset-commands.js +11 -130
  24. package/dist/dataset-transfer-download.js +6 -6
  25. package/dist/dataset-transfer-upload.js +8 -8
  26. package/dist/git-credential.d.ts +1 -1
  27. package/dist/git-credential.d.ts.map +1 -1
  28. package/dist/git-credential.js +8 -1
  29. package/dist/help.js +3 -3
  30. package/dist/hosted-boot.d.ts +11 -0
  31. package/dist/hosted-boot.d.ts.map +1 -1
  32. package/dist/hosted-boot.js +46 -1
  33. package/dist/hosted-session-cli.d.ts.map +1 -1
  34. package/dist/hosted-session-cli.js +6 -1
  35. package/dist/hosted-session-seed.d.ts +45 -0
  36. package/dist/hosted-session-seed.d.ts.map +1 -0
  37. package/dist/hosted-session-seed.js +61 -0
  38. package/dist/jsonl-stream.d.ts +1 -0
  39. package/dist/jsonl-stream.d.ts.map +1 -1
  40. package/dist/jsonl-stream.js +7 -0
  41. package/dist/provider-list-workflow.d.ts +3 -0
  42. package/dist/provider-list-workflow.d.ts.map +1 -0
  43. package/dist/provider-list-workflow.js +95 -0
  44. package/dist/provider-wire-record.d.ts +3 -0
  45. package/dist/provider-wire-record.d.ts.map +1 -0
  46. package/dist/provider-wire-record.js +4 -0
  47. package/dist/providers-cli.d.ts.map +1 -1
  48. package/dist/providers-cli.js +5 -15
  49. package/dist/publish-merge-job.d.ts +68 -0
  50. package/dist/publish-merge-job.d.ts.map +1 -0
  51. package/dist/publish-merge-job.js +320 -0
  52. package/dist/publish-merge-timing.d.ts +18 -0
  53. package/dist/publish-merge-timing.d.ts.map +1 -0
  54. package/dist/publish-merge-timing.js +23 -0
  55. package/dist/workbench-cli.d.ts +4 -0
  56. package/dist/workbench-cli.d.ts.map +1 -1
  57. package/dist/workbench-cli.js +11 -1
  58. package/dist/workspace-bootstrap-job.d.ts +1 -0
  59. package/dist/workspace-bootstrap-job.d.ts.map +1 -1
  60. package/dist/workspace-bootstrap-job.js +1 -1
  61. package/package.json +5 -2
  62. package/src/app-commands.ts +234 -66
  63. package/src/app-publish-failure.ts +7 -0
  64. package/src/app-publish-paths.ts +27 -0
  65. package/src/app-publish-workspace.ts +207 -0
  66. package/src/artifacts-git-runtime.ts +73 -31
  67. package/src/artifacts-session-merge.ts +253 -0
  68. package/src/dataset-append-workflow.ts +205 -0
  69. package/src/dataset-commands.ts +10 -170
  70. package/src/dataset-transfer-download.ts +5 -5
  71. package/src/dataset-transfer-upload.ts +7 -7
  72. package/src/git-credential.ts +8 -1
  73. package/src/help.ts +3 -3
  74. package/src/hosted-boot.ts +56 -1
  75. package/src/hosted-session-cli.ts +6 -1
  76. package/src/hosted-session-seed.ts +78 -0
  77. package/src/jsonl-stream.ts +8 -0
  78. package/src/provider-list-workflow.ts +108 -0
  79. package/src/provider-wire-record.ts +4 -0
  80. package/src/providers-cli.ts +5 -13
  81. package/src/publish-merge-job.ts +350 -0
  82. package/src/publish-merge-timing.ts +27 -0
  83. package/src/workbench-cli.ts +15 -1
  84. package/src/workspace-bootstrap-job.ts +1 -1
  85. package/vendor/skills/orizu/flows/onboard.md +1 -1
  86. package/vendor/skills/orizu/references/authority-map.md +2 -2
  87. package/vendor/skills/orizu/references/building-apps.md +24 -0
  88. package/vendor/skills/orizu/references/cli-reference.md +13 -4
  89. package/vendor/skills/orizu/references/optimization-reports.md +1 -1
  90. package/vendor/skills/orizu/references/primer.md +1 -0
  91. package/vendor/skills/orizu/references/prompt-control-plane.md +9 -5
@@ -0,0 +1,4 @@
1
+ /** Preserve the provider commands' permissive ordinary-TypeScript row coercion. */
2
+ export function providerWireRecord(value: unknown): Record<string, unknown> {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
4
+ }
@@ -1,4 +1,5 @@
1
1
  import { authedFetch } from './http.js'
2
+ import { providerWireRecord as object } from './provider-wire-record.js'
2
3
  import { credentialEnvFor, providerLookupFor } from './provider-lookup.js'
3
4
  import { CREDENTIAL_ENV_PATTERN, PROVIDER_FIELD_PATTERNS, RESERVED_CREDENTIAL_ENVS, isProviderRefusalCode } from './provider-refusals.js'
4
5
  import { findProvider, parseModelIdentity } from './provider-registry.js'
@@ -8,9 +9,6 @@ import type { ModelConfigsCommandIo } from './model-configs-cli.js'
8
9
  import type { ProviderEntry, WireProtocol } from './provider-registry.js'
9
10
 
10
11
  const FIELD_CONTRACT = 'Anthropic Messages base URL excludes /v1; OpenAI base URLs include the version segment.'
11
- function object(value: unknown): Record<string, unknown> {
12
- return typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
13
- }
14
12
  function assertProtocol(value: unknown): asserts value is WireProtocol {
15
13
  if (typeof value !== 'string' || !Object.hasOwn(PROBE_PROTOCOLS, value)) throw new Error('provider_protocol_invalid')
16
14
  }
@@ -89,6 +87,10 @@ export async function providersCommand(args: string[], io: ModelConfigsCommandIo
89
87
  baseUrl = baseUrl.replace(/^(https:\/\/)([^/]+)/, (_, scheme: string, host: string) => scheme + host.toLowerCase())
90
88
  provider = providerRow({ id, baseUrl, protocol: flags.get('--protocol') ?? 'openai-chat', credentialEnv: flags.get('--credential-env') ?? credentialEnvFor(id), authHeader: flags.get('--auth-header') ?? 'Authorization', authValuePrefix: flags.get('--auth-value-prefix') ?? 'Bearer ' })
91
89
  }
90
+ if (checkIdentity === undefined && command === 'list' && positional.length === 1) {
91
+ const { listProviders } = await import('./provider-list-workflow.js')
92
+ return listProviders(flags.get('--project') || null, io)
93
+ }
92
94
  const project = await io.resolveProjectSlug?.(flags.get('--project') || null)
93
95
  if (!project) throw new Error('Project resolver unavailable')
94
96
  const fetcher = io.fetcher ?? authedFetch
@@ -116,16 +118,6 @@ export async function providersCommand(args: string[], io: ModelConfigsCommandIo
116
118
  const entry = lookup.find(identity.provider)
117
119
  if (!entry) throw new Error('provider_not_found')
118
120
  await runProbe(entry, identity.modelId, io)
119
- } else if (command === 'list' && positional.length === 1) {
120
- const body = await request(root, { method: 'GET' })
121
- if (io.json) io.print(JSON.stringify(body))
122
- else {
123
- io.print('ID PROTOCOL HOST CREDENTIAL ENV BUILT-IN')
124
- for (const value of Array.isArray(body.providers) ? body.providers : []) {
125
- const row = object(value)
126
- io.print(`${row.id} ${row.protocol} ${row.host} ${row.credentialEnv} ${row.builtIn ? 'yes' : 'no'}`)
127
- }
128
- }
129
121
  } else if (command === 'remove' && id && positional.length === 2) {
130
122
  await request(`/api/cli/providers/${encodeURIComponent(id)}?project=${encodeURIComponent(project)}`, { method: 'DELETE' })
131
123
  io.print(io.json ? JSON.stringify({ removed: id }) : `Removed provider ${id}`)
@@ -0,0 +1,350 @@
1
+ /** ORI-2280: the agent-free publish merge one-shot. The coordinator runs it
2
+ * in a sandbox with network access to Artifacts only. It merges one session
3
+ * copy into its team copy with `publishSessionMerge`, getting one scoped key
4
+ * at a time from the web app and revoking every key before it reports. */
5
+ import { execFile } from 'node:child_process'
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { promisify } from 'node:util'
10
+
11
+ import { createEphemeralAskPass, type GitAuth } from './artifacts-git-runtime.js'
12
+ import { publishSessionMerge, type PublishSessionMergeResult } from './artifacts-session-merge.js'
13
+ import { CLOUDFLARE_ARTIFACTS_HOST_PATTERN } from './cloudflare-artifacts-git-remote.js'
14
+ import { assertSecureTokenTransport } from './http.js'
15
+ import { assertWorkspaceBootstrapRemote, isLoopbackOrigin } from './workspace-bootstrap-job.js'
16
+ import {
17
+ PUBLISH_MERGE_CONFLICT_FILES_LIMIT,
18
+ PUBLISH_MERGE_CONFLICT_PATH_MAX,
19
+ PUBLISH_MERGE_COORDINATOR_WALL_MS,
20
+ PUBLISH_MERGE_WALL_AT_ENV,
21
+ type PublishMergeJobFailureCode,
22
+ } from './publish-merge-timing.js'
23
+ import { DEFAULT_REPOSITORY_BACKUP_LIMITS } from './repository-backup-core.js'
24
+
25
+ const PUBLISH_MERGE_JOB_CAPABILITY = 'publish-merge-job:v1'
26
+ /** Timing. The job's wall is the earlier of:
27
+ * - ORIZU_PUBLISH_MERGE_WALL_AT, when set: the epoch-ms moment the
28
+ * coordinator stops the sandbox (its createdAt + 120 s, so sandbox create
29
+ * and CLI boot are already counted);
30
+ * - 110 s from the job's own start, the fallback when it is not set.
31
+ * Within it:
32
+ * - the merge ends by 90 s from start, and always 10 s before the wall;
33
+ * - revoking the keys still live ends 10 s before the wall, each call at
34
+ * most 15 s or whatever is left;
35
+ * - the last 10 s are kept for posting the result, so it always goes out.
36
+ * All of this is well below the read keys' five-minute life, so no key is
37
+ * ever renewed. */
38
+ const JOB_DEADLINE_MS = 90_000
39
+ const RESULT_RESERVE_MS = 10_000
40
+ const JOB_WALL_MS = PUBLISH_MERGE_COORDINATOR_WALL_MS - RESULT_RESERVE_MS
41
+ const LATE_CALL_TIMEOUT_MS = 15_000
42
+ const GIT_TIMEOUT_MS = 60_000
43
+ /** ADR-012's envelope for a repository's whole object graph. The graph is
44
+ * always larger than the content it was seeded from, so ADR-011's 1 GiB
45
+ * content limit is not the measure here. */
46
+ const ADMISSION = {
47
+ totalBytes: DEFAULT_REPOSITORY_BACKUP_LIMITS.maxObjectBytes,
48
+ objectBytes: DEFAULT_REPOSITORY_BACKUP_LIMITS.maxSingleObjectBytes,
49
+ objects: DEFAULT_REPOSITORY_BACKUP_LIMITS.maxObjects,
50
+ commits: DEFAULT_REPOSITORY_BACKUP_LIMITS.maxCommits,
51
+ }
52
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
53
+ const IDENTITY = {
54
+ GIT_AUTHOR_NAME: 'Orizu',
55
+ GIT_AUTHOR_EMAIL: 'publish@orizu.invalid',
56
+ GIT_COMMITTER_NAME: 'Orizu',
57
+ GIT_COMMITTER_EMAIL: 'publish@orizu.invalid',
58
+ }
59
+
60
+ interface PublishMergeJobEnv {
61
+ bootSecret: string
62
+ jobId: string
63
+ coordinatorUrl: string
64
+ baseUrl: string
65
+ /** The Artifacts git host the coordinator expects; localhost only in tests. */
66
+ expectedGitHost: string
67
+ /** The coordinator's wall, epoch ms; see the timing note above. */
68
+ wallAt?: number
69
+ }
70
+
71
+ export type PublishMergeJobOutcome =
72
+ | { outcome: 'merged'; mergeSha: string }
73
+ | { outcome: 'already_merged'; headSha: string }
74
+ /** At most PUBLISH_MERGE_CONFLICT_FILES_LIMIT paths; `moreFiles` counts the rest. */
75
+ | { outcome: 'conflict'; files: readonly string[]; moreFiles: number }
76
+ | { outcome: 'denied' }
77
+ | { outcome: 'race_lost' }
78
+ | { outcome: 'failed'; code: PublishMergeJobFailureCode }
79
+
80
+ interface Credential {
81
+ tokenId: string
82
+ plaintext: string
83
+ remote: string
84
+ }
85
+
86
+ class JobFailure extends Error {
87
+ constructor(readonly code: PublishMergeJobFailureCode | 'denied') {
88
+ super(code)
89
+ }
90
+ }
91
+
92
+ function resolvePublishMergeJobEnv(env: Record<string, string | undefined>): PublishMergeJobEnv | string {
93
+ const value = (name: string) => env[name]?.trim() ?? ''
94
+ const resolved = {
95
+ bootSecret: value('ORIZU_BOOT_SECRET'),
96
+ jobId: value('ORIZU_PUBLISH_MERGE_JOB_ID'),
97
+ coordinatorUrl: value('ORIZU_COORDINATOR_URL').replace(/\/+$/, ''),
98
+ baseUrl: value('ORIZU_BASE_URL').replace(/\/+$/, ''),
99
+ expectedGitHost: value('ORIZU_PUBLISH_MERGE_GIT_HOST').toLowerCase(),
100
+ }
101
+ if (Object.values(resolved).some((item) => item.length === 0)) return 'missing required env'
102
+ const wallAtText = value(PUBLISH_MERGE_WALL_AT_ENV)
103
+ const wallAt = wallAtText ? Number(wallAtText) : undefined
104
+ if (wallAt !== undefined && (!/^[0-9]{13}$/.test(wallAtText) || !Number.isSafeInteger(wallAt))) return 'invalid wall deadline'
105
+ try {
106
+ assertSecureTokenTransport(resolved.coordinatorUrl)
107
+ assertSecureTokenTransport(resolved.baseUrl)
108
+ } catch {
109
+ return 'insecure coordinator or base URL'
110
+ }
111
+ const localGitAllowed = isLoopbackOrigin(resolved.coordinatorUrl) && isLoopbackOrigin(resolved.baseUrl) && resolved.expectedGitHost === 'localhost'
112
+ if (!UUID_PATTERN.test(resolved.jobId) || (!CLOUDFLARE_ARTIFACTS_HOST_PATTERN.test(resolved.expectedGitHost) && !localGitAllowed)) {
113
+ return 'invalid job id or git host'
114
+ }
115
+ return wallAt === undefined ? resolved : { ...resolved, wallAt }
116
+ }
117
+
118
+ /** Every remote must be an https repository in the hosted namespace on the expected host. */
119
+ function assertRemote(remote: unknown, expectedGitHost: string): string {
120
+ try {
121
+ assertWorkspaceBootstrapRemote(String(remote), expectedGitHost)
122
+ } catch {
123
+ throw new JobFailure('scope_refused')
124
+ }
125
+ return new URL(String(remote)).toString()
126
+ }
127
+
128
+ async function post(url: string, bearer: string, body: object, signal?: AbortSignal): Promise<Record<string, unknown>> {
129
+ let response: Response
130
+ try {
131
+ response = await fetch(url, {
132
+ method: 'POST',
133
+ redirect: 'error',
134
+ signal,
135
+ headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' },
136
+ body: JSON.stringify(body),
137
+ })
138
+ } catch {
139
+ throw new JobFailure(signal?.aborted ? 'job_timed_out' : 'api_unavailable')
140
+ }
141
+ const value: unknown = await response.json().catch(() => null)
142
+ const record = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
143
+ // The session lost its authority while a key was being minted: a refusal.
144
+ if (response.status === 403 && record?.error === 'gate_denied') throw new JobFailure('denied')
145
+ if (response.status === 401 || response.status === 403) throw new JobFailure('scope_refused')
146
+ if (!response.ok || !record) throw new JobFailure('api_unavailable')
147
+ return record
148
+ }
149
+
150
+ /** The permission check's answer, or unavailable for anything else. */
151
+ function gateAllows(value: Record<string, unknown>): boolean {
152
+ const keys = Object.keys(value).sort().join(',')
153
+ if (keys === 'allowed' && value.allowed === true) return true
154
+ if (keys === 'allowed,reason' && value.allowed === false && typeof value.reason === 'string') return false
155
+ throw new JobFailure('api_unavailable')
156
+ }
157
+
158
+ /** ADR-012: the team copy is canonical state, so the merged graph must fit
159
+ * the admission envelope before a write key is asked for. The merge folder
160
+ * holds exactly both copies' fetched objects and the merge commit. */
161
+ async function assertMergeAdmitted(worktree: string, signal: AbortSignal, narrower: Partial<typeof ADMISSION> = {}): Promise<void> {
162
+ const { stdout } = await promisify(execFile)(
163
+ 'git',
164
+ ['cat-file', '--batch-all-objects', '--batch-check=%(objecttype) %(objectsize)'],
165
+ { cwd: worktree, maxBuffer: 64 * 1024 * 1024, signal }
166
+ )
167
+ if (!isMergeInventoryAdmitted(stdout, narrower)) throw new JobFailure('admission_refused')
168
+ }
169
+
170
+ /** One `<type> <size>` line per object, as `git cat-file --batch-check` prints them. */
171
+ export function isMergeInventoryAdmitted(inventory: string, narrower: Partial<typeof ADMISSION> = {}): boolean {
172
+ const limits = Object.fromEntries(
173
+ Object.entries(ADMISSION).map(([key, max]) => [key, Math.min(max, narrower[key as keyof typeof ADMISSION] ?? max)])
174
+ ) as typeof ADMISSION
175
+ let totalBytes = 0
176
+ let objects = 0
177
+ let commits = 0
178
+ for (const line of inventory.split('\n')) {
179
+ if (!line) continue
180
+ const [type, size] = line.split(' ')
181
+ const bytes = Number(size)
182
+ objects += 1
183
+ totalBytes += bytes
184
+ if (type === 'commit') commits += 1
185
+ if (!Number.isSafeInteger(bytes) || bytes > limits.objectBytes) return false
186
+ }
187
+ return totalBytes <= limits.totalBytes && objects <= limits.objects && commits <= limits.commits
188
+ }
189
+
190
+ /** A post with its own time limit. An AbortController and timer, as for the
191
+ * deadline: AbortSignal.timeout did not fire inside the job under bun test. */
192
+ async function postWithin(timeoutMs: number, url: string, bearer: string, body: object): Promise<Record<string, unknown>> {
193
+ const stop = new AbortController()
194
+ const timer = setTimeout(() => stop.abort(), timeoutMs)
195
+ try {
196
+ return await post(url, bearer, body, stop.signal)
197
+ } finally {
198
+ clearTimeout(timer)
199
+ }
200
+ }
201
+
202
+ /** The conflict list the job reports, bounded so the result always fits the
203
+ * coordinator's body limit. A path too long to name is counted instead. */
204
+ export function boundConflictFiles(files: readonly string[]): { files: string[]; moreFiles: number } {
205
+ const named = files.filter((file) => file.length <= PUBLISH_MERGE_CONFLICT_PATH_MAX).slice(0, PUBLISH_MERGE_CONFLICT_FILES_LIMIT)
206
+ return { files: named, moreFiles: files.length - named.length }
207
+ }
208
+
209
+ function toOutcome(result: PublishSessionMergeResult): PublishMergeJobOutcome {
210
+ switch (result.kind) {
211
+ case 'merged':
212
+ return { outcome: 'merged', mergeSha: result.mergeSha }
213
+ case 'already_merged':
214
+ return { outcome: 'already_merged', headSha: result.canonicalSha }
215
+ case 'conflict':
216
+ return { outcome: 'conflict', ...boundConflictFiles(result.files) }
217
+ case 'denied':
218
+ return { outcome: 'denied' }
219
+ case 'race_lost':
220
+ return { outcome: 'race_lost' }
221
+ }
222
+ }
223
+
224
+ export async function runPublishMergeJob(options: {
225
+ env: PublishMergeJobEnv
226
+ deadlineMs?: number
227
+ /** Narrower admission limits; tests only. Never wider than ADMISSION. */
228
+ admission?: Partial<typeof ADMISSION>
229
+ /** Shorter timing; tests only. Each is clamped to its production value. */
230
+ timing?: { resultReserveMs?: number; lateCallMs?: number }
231
+ }): Promise<{ reported: boolean; outcome: PublishMergeJobOutcome }> {
232
+ const { env } = options
233
+ const startedAt = Date.now()
234
+ const wallEnd = Math.min(startedAt + JOB_WALL_MS, env.wallAt ?? Number.POSITIVE_INFINITY)
235
+ const reserveMs = Math.min(options.timing?.resultReserveMs ?? RESULT_RESERVE_MS, RESULT_RESERVE_MS)
236
+ const lateCallMs = Math.min(options.timing?.lateCallMs ?? LATE_CALL_TIMEOUT_MS, LATE_CALL_TIMEOUT_MS)
237
+ const stop = new AbortController()
238
+ const timer = setTimeout(() => stop.abort(), Math.max(0, Math.min(options.deadlineMs ?? JOB_DEADLINE_MS, wallEnd - reserveMs - startedAt)))
239
+ const deadline = stop.signal
240
+ const api = (action: string) => `${env.baseUrl}/api/internal/publish-merge/${env.jobId}/${action}`
241
+ const issued: Array<{ target: 'session' | 'team'; tokenId: string }> = []
242
+ let jobToken = ''
243
+ /** A late call's bound: its own limit, or the time left before the last `keepMs` of the wall. */
244
+ const lateBound = (keepMs: number) => Math.min(lateCallMs, wallEnd - keepMs - Date.now())
245
+ const revokeCall = async (target: 'session' | 'team', tokenId: string) => {
246
+ const bound = lateBound(reserveMs)
247
+ if (bound <= 0) throw new JobFailure('credentials_pending')
248
+ return postWithin(bound, api('revoke'), jobToken, { target, tokenId })
249
+ }
250
+ let outcome: PublishMergeJobOutcome
251
+ const root = await mkdtemp(join(tmpdir(), 'orizu-publish-merge-'))
252
+ try {
253
+ const askPass = await createEphemeralAskPass(root)
254
+ const claimed = await post(`${env.coordinatorUrl}/publish-merge-jobs/${env.jobId}/claim`, env.bootSecret, {}, deadline)
255
+ if (typeof claimed.jobToken !== 'string' || !claimed.jobToken) throw new JobFailure('scope_refused')
256
+ jobToken = claimed.jobToken
257
+ const spec = await post(api('spec'), jobToken, {}, deadline)
258
+ const sessionRemote = assertRemote((spec.session as { remote?: unknown } | undefined)?.remote, env.expectedGitHost)
259
+ if (spec.jobId !== env.jobId || typeof spec.sessionCommitSha !== 'string' || spec.ref !== 'refs/heads/main' || typeof spec.commitMessage !== 'string') {
260
+ throw new JobFailure('scope_refused')
261
+ }
262
+ const mint = async (target: 'session' | 'team', scope: 'read' | 'write'): Promise<Credential> => {
263
+ const value = await post(api('credential'), jobToken, { target, scope }, deadline)
264
+ if (typeof value.tokenId !== 'string' || typeof value.plaintext !== 'string') throw new JobFailure('scope_refused')
265
+ issued.push({ target, tokenId: value.tokenId })
266
+ return { tokenId: value.tokenId, plaintext: value.plaintext, remote: assertRemote(value.remote, env.expectedGitHost) }
267
+ }
268
+ const revoke = async (target: 'session' | 'team', tokenId: string) => {
269
+ const value = await revokeCall(target, tokenId)
270
+ if (value.revoked !== true) throw new JobFailure('credentials_pending')
271
+ issued.splice(issued.findIndex((item) => item.tokenId === tokenId), 1)
272
+ }
273
+ const auth = (credential: Credential): GitAuth => ({ askPassPath: askPass.path, token: credential.plaintext })
274
+
275
+ const sessionRead = await mint('session', 'read')
276
+ if (sessionRead.remote !== sessionRemote) throw new JobFailure('scope_refused')
277
+ const teamRead = await mint('team', 'read')
278
+ const result = await publishSessionMerge({
279
+ root,
280
+ canonical: { remote: teamRead.remote, auth: auth(teamRead) },
281
+ source: { remote: sessionRemote, auth: auth(sessionRead) },
282
+ canonicalRef: 'refs/heads/main',
283
+ sourceRef: 'refs/heads/main',
284
+ expectedSourceSha: spec.sessionCommitSha,
285
+ commitMessage: spec.commitMessage,
286
+ commitIdentityEnv: IDENTITY,
287
+ authorize: async (merge) => {
288
+ await assertMergeAdmitted(merge.worktree, deadline, options.admission)
289
+ return gateAllows(await post(api('gate'), jobToken, {}, deadline))
290
+ },
291
+ withCanonicalWrite: async (push) => {
292
+ const write = await mint('team', 'write')
293
+ if (write.remote !== teamRead.remote) throw new JobFailure('scope_refused')
294
+ try {
295
+ return await push(auth(write))
296
+ } finally {
297
+ await revoke('team', write.tokenId)
298
+ }
299
+ },
300
+ timeoutMs: GIT_TIMEOUT_MS,
301
+ signal: deadline,
302
+ })
303
+ outcome = toOutcome(result)
304
+ } catch (error) {
305
+ if (error instanceof JobFailure && error.code === 'denied') outcome = { outcome: 'denied' }
306
+ else if (deadline.aborted) outcome = { outcome: 'failed', code: 'job_timed_out' }
307
+ else outcome = { outcome: 'failed', code: error instanceof JobFailure && error.code !== 'denied' ? error.code : 'publish_failed' }
308
+ } finally {
309
+ clearTimeout(timer)
310
+ // Revoke whatever is still live, newest first, even past the deadline; an
311
+ // unrevoked key wins over the merge outcome because its authority is then uncertain.
312
+ for (const { target, tokenId } of [...issued].reverse()) {
313
+ try {
314
+ const value = await revokeCall(target, tokenId)
315
+ if (value.revoked !== true) throw new JobFailure('credentials_pending')
316
+ } catch {
317
+ outcome = { outcome: 'failed', code: 'credentials_pending' }
318
+ }
319
+ }
320
+ await rm(root, { recursive: true, force: true })
321
+ }
322
+ let reported = false
323
+ try {
324
+ const bound = lateBound(0)
325
+ if (bound <= 0) throw new JobFailure('job_timed_out')
326
+ await postWithin(bound, `${env.coordinatorUrl}/publish-merge-jobs/${env.jobId}/result`, env.bootSecret, outcome)
327
+ reported = true
328
+ } catch {
329
+ reported = false
330
+ }
331
+ return { reported, outcome }
332
+ }
333
+
334
+ export async function publishMergeJobCommand(
335
+ args: readonly string[],
336
+ io: { print: (line: string) => void; printErr?: (line: string) => void }
337
+ ): Promise<number> {
338
+ if (args.includes('--capability-check')) {
339
+ io.print(PUBLISH_MERGE_JOB_CAPABILITY)
340
+ return 0
341
+ }
342
+ const env = resolvePublishMergeJobEnv(process.env)
343
+ if (typeof env === 'string') {
344
+ io.printErr?.(`publish-merge-job: ${env}`)
345
+ return 1
346
+ }
347
+ const result = await runPublishMergeJob({ env })
348
+ io.print(JSON.stringify(result.outcome))
349
+ return result.reported ? 0 : 1
350
+ }
@@ -0,0 +1,27 @@
1
+ /** ORI-2280: the publish merge job's wall, shared by the coordinator, which
2
+ * stops the sandbox at it, and the CLI job, which must report before it. No
3
+ * Node or Worker imports: both sides compile this file. */
4
+
5
+ /** From the coordinator creating the job to it stopping the sandbox. */
6
+ export const PUBLISH_MERGE_COORDINATOR_WALL_MS = 2 * 60 * 1000
7
+ /** The coordinator sets this on the sandbox to `createdAt + PUBLISH_MERGE_COORDINATOR_WALL_MS`, in epoch milliseconds. */
8
+ export const PUBLISH_MERGE_WALL_AT_ENV = 'ORIZU_PUBLISH_MERGE_WALL_AT'
9
+
10
+ /** A conflict result names at most this many paths, each at most
11
+ * PUBLISH_MERGE_CONFLICT_PATH_MAX characters, and counts the rest, so the
12
+ * result always fits the coordinator's result body limit. */
13
+ export const PUBLISH_MERGE_CONFLICT_FILES_LIMIT = 200
14
+ export const PUBLISH_MERGE_CONFLICT_PATH_MAX = 1024
15
+
16
+ /** The failure codes the sandbox may report. The coordinator's own codes
17
+ * (`timed_out`, `provider_unavailable`, `snapshot_missing_command`, ...) are
18
+ * not among them, so a reported failure always says which side saw it. */
19
+ export const PUBLISH_MERGE_JOB_FAILURE_CODES = [
20
+ 'scope_refused',
21
+ 'admission_refused',
22
+ 'credentials_pending',
23
+ 'api_unavailable',
24
+ 'job_timed_out',
25
+ 'publish_failed',
26
+ ] as const
27
+ export type PublishMergeJobFailureCode = (typeof PUBLISH_MERGE_JOB_FAILURE_CODES)[number]
@@ -45,6 +45,8 @@ export interface WorkbenchRunCosts {
45
45
  export interface WorkbenchRunSummary extends WorkbenchRunCosts {
46
46
  id: string
47
47
  status: string
48
+ /** Why a failed run failed, in plain words, mapped by the server (ORI-2264). */
49
+ failureReason?: string
48
50
  }
49
51
 
50
52
  export interface WorkbenchSession {
@@ -76,6 +78,8 @@ export interface WorkbenchRun extends WorkbenchRunCosts {
76
78
  status: string
77
79
  evidence?: Record<string, unknown>
78
80
  summary?: Record<string, unknown>
81
+ /** Why a failed run failed, in plain words, mapped by the server (ORI-2264). */
82
+ failureReason?: string
79
83
  latestSequence?: number
80
84
  startedAt?: string | null
81
85
  finishedAt?: string | null
@@ -1130,6 +1134,8 @@ function formatSessionStatus(result: SessionStatusResult): string {
1130
1134
  const cost = finiteOrNull(run.modelCostUsd)
1131
1135
  const suffix = cost !== null ? ` ${formatUsd(cost)}` : ''
1132
1136
  lines.push(` run ${run.id} ${run.status}${suffix}`)
1137
+ const why = failedRunLine(run)
1138
+ if (why) lines.push(` ${why}`)
1133
1139
  }
1134
1140
  return lines.join('\n')
1135
1141
  }
@@ -1167,10 +1173,18 @@ function formatSessionFinishOutcome(result: SessionFinishResult): string {
1167
1173
  return `manifest ${id} ${status} repo_merge (${files} files, +${additions}/-${deletions})`
1168
1174
  }
1169
1175
 
1176
+ // The web page's failed line, with the reason the server mapped (ORI-2264).
1177
+ function failedRunLine(run: { status: string; failureReason?: unknown }): string | null {
1178
+ return run.status === 'failed' && typeof run.failureReason === 'string' && run.failureReason
1179
+ ? `The agent stopped: ${run.failureReason}.`
1180
+ : null
1181
+ }
1182
+
1170
1183
  function formatRunStatus(run: WorkbenchRun): string {
1171
1184
  const sequence = typeof run.latestSequence === 'number' ? ` latestSequence=${run.latestSequence}` : ''
1172
1185
  const costLine = formatCostSummary(run)
1173
- return `run ${run.id} ${run.status}${sequence}${costLine ? `\n ${costLine}` : ''}`
1186
+ const why = failedRunLine(run)
1187
+ return `run ${run.id} ${run.status}${sequence}${costLine ? `\n ${costLine}` : ''}${why ? `\n ${why}` : ''}`
1174
1188
  }
1175
1189
 
1176
1190
  // ALI-1045: one compact digest line per event — shared with the attached
@@ -114,7 +114,7 @@ function isObject(value: unknown): value is Record<string, unknown> {
114
114
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
115
115
  }
116
116
 
117
- function isLoopbackOrigin(value: string): boolean {
117
+ export function isLoopbackOrigin(value: string): boolean {
118
118
  try {
119
119
  const host = new URL(value).hostname
120
120
  return host === 'localhost' || host === '127.0.0.1' || host === '::1'
@@ -20,7 +20,7 @@ Compare the inventoried surfaces using source metadata only: availability, prove
20
20
 
21
21
  ## Install and establish the project
22
22
 
23
- After the human chooses Orizu, check whether `orizu` is available and record `orizu --version`. If it is missing, require Node.js 20+ and install the CLI with `npm i -g orizu`. Then run `orizu setup`; verify authentication and runtime with `orizu whoami --json` and `orizu capabilities --json`, and verify the installed skill separately with `orizu skills status --json` (use `orizu skills update` when stale). Use `references/cli-reference.md` for authentication and team/project commands. Follow `references/authority-map.md` for who executes each action. Do not rely on remembered commands.
23
+ After the human chooses Orizu, check that `node --version` is 22 or newer (the CLI requires Node.js 22+), then check whether `orizu` is available and record `orizu --version`. If it is missing, install the CLI with `npm i -g orizu`. Then run `orizu setup`; verify authentication and runtime with `orizu whoami --json` and `orizu capabilities --json`, and verify the installed skill separately with `orizu skills status --json` (use `orizu skills update` when stale). Use `references/cli-reference.md` for authentication and team/project commands. Follow `references/authority-map.md` for who executes each action. Do not rely on remembered commands.
24
24
 
25
25
  ## Ratify the improvement plan
26
26
 
@@ -8,12 +8,12 @@ This is the single source of truth for who runs each action. Replace placeholder
8
8
  | All surfaces | Edit files | Edit and commit source, configs, `App.tsx`, schemas, and local traces under `logs/<run-id>` in the repo working set. | Repo files are not canonical pointer or run state. |
9
9
  | Local and hosted agents | Product feedback filing | File product feedback autonomously with `orizu feedback`, using scrubbed narrative and no customer content. | This bounded feedback command is the agent's own authority; no human approval is required. |
10
10
  | Local agent under the user's token | Workflow | Survey the customer codebase, resolve state, prepare the plan, files, manifests, diffs, and evidence, then ask the human to ratify the workflow design. | The human moments are plan ratification and execution of all production/default pointer moves. |
11
- | Local agent under the user's token | Catalog mutations and pointer preparation | After plan ratification, pull instruction material from the customer codebase and run `orizu instructions create <manifest> --project <team/project>`, `orizu instructions push <manifest> --project <team/project> --set <slug-or-exact-name>`, shape add/remove, archive/restore itself under the user's token. For pointer changes, gather only read-only resolution and binding evidence, prepare the exact commands, then hand them off. | Per the Micro-round 3 custody ruling, a human admin or curator using a user token executes every runner default-label move, optimizer-label move, scorer production-label move, profile promote/rollback, default move, and labeled optimization promotion on every surface. |
11
+ | Local agent under the user's token | Catalog mutations and pointer preparation | After plan ratification, pull instruction material from the customer codebase and run `orizu instructions create <manifest> --project <team/project>`, `orizu instructions push <manifest> --project <team/project> --set <slug-or-exact-name>`, shape add/remove before first promotion, archive/restore itself under the user's token. For pointer changes, gather only read-only resolution and binding evidence, prepare the exact commands, then hand them off. | Per the Micro-round 3 custody ruling, a human admin or curator using a user token executes every runner default-label move, optimizer-label move, scorer production-label move, profile promote/rollback, default move, and labeled optimization promotion on every surface. |
12
12
  | Hosted session | Start and scope | Use the pre-authenticated CLI from `ORIZU_TOKEN_FILE` and `ORIZU_BASE_URL`; resolve only the session's signed team with `orizu teams list`, then list or create projects only within it. Run `orizu projects create --name "<project name>" --team <signed-team>` when needed. | Team creation is platform-enforced human-only. Hand off: `orizu teams create --name "<team name>"`, then ask the human to start a new hosted session scoped to that team; the current session cannot switch teams. |
13
13
  | Hosted session | Team-wide curator work | While the hosted session is active and its kill switch is clear, perform setup plus ordinary curator reads and writes within the signed team. Run `orizu instructions profiles new <set> --project <team/project> --model-config <identity>` and `orizu instructions sync <set> --project <team/project> --out <app-root>`; synced material lands under `<app-root>/orizu/`. For git-canonical judges, scorers, runners, and optimizers, write with `--session <session-id>`, keep bytes as commits on the session branch, then verify with `git ls-files <path>` and inspect the commit. | The agent executes this work; an Orizu version id alone is not proof of committed bytes. Signed-project exceptions are in the next row. |
14
14
  | Hosted session | Signed-project work | Run optimization mutations only for the JWT's project, keep git-canonical draft writes on that session and project, and read diff comments only for that project. | Sibling-project optimization mutations, draft writes, and diff-comment reads are out of scope even where ordinary curator access is team-wide. |
15
15
  | Hosted session | Materialize an instruction set | When the survey finds only a candidate seed, turn it into the complete manifest. | ADR-008 requires a human curator using a local CLI user token. Create/push are hosted CLI route-enforced; direct RPC remains agent-writable and is a fix-forward gap. Hand off `orizu instructions create <manifest> --project <team/project>` for a fresh set, or `orizu instructions push <manifest> --project <team/project> --set <slug-or-exact-name>` for an existing set. Their measured 403 is `Sessionless registration is not available to the hosted agent: this artifact class is git-canonical (ADR-007) and its bytes must be committed to the session branch. Re-run with --session <session-id>.` Neither create nor push accepts `--session`. |
16
- | Hosted session | Profiles and component shape | Run `orizu instructions profiles new <set> --project <team/project> --model-config <identity>`, `orizu instructions shape add <set> --project <team/project> --key <key> --from <manifest>`, and `orizu instructions shape remove <set> --project <team/project> --key <key>` with team-scoped curator authority. Shape changes create unpromoted profile heads and do not move pointers. | A human admin or curator with `can_manage_project` runs every profile promote/rollback, instruction-set default move, and other production/default pointer move. |
16
+ | Hosted session | Profiles and component shape | Run `orizu instructions profiles new <set> --project <team/project> --model-config <identity>`, `orizu instructions shape add <set> --project <team/project> --key <key> --from <manifest>`, and `orizu instructions shape remove <set> --project <team/project> --key <key>` with team-scoped curator authority. Shape add/remove works only until the first promotion of any profile, creates unpromoted profile heads, and does not move pointers. First promotion permanently freezes the shape, and sets that existed when this rule shipped are frozen too; a changed frozen shape requires a new instruction set. | A human admin or curator with `can_manage_project` runs every profile promote/rollback, instruction-set default move, and other production/default pointer move. |
17
17
  | Hosted session | Instruction-set visibility | Prepare the requested visibility change. | A human admin or curator with `can_manage_project` runs `orizu instructions archive <slug-or-exact-name> --project <team/project>` or `orizu instructions restore <slug-or-exact-name> --project <team/project>`. Hosted CLI route enforcement returns `Agent sessions cannot perform this action`; direct RPC remains agent-writable. |
18
18
  | Hosted session | Instruction scorers | Run `orizu instructions scorers set-headline <set> --key <component-key> --scorer-version <id> --project <team/project>` and `orizu instructions scorers add <set> --key <component-key> --scorer-version <id> --project <team/project>`; prepare scorer-pointer evidence. | A human admin or curator with `can_manage_project` runs `orizu scorers labels set <scorer-name> production --version <scorer-version-id> --project <team/project>`. The measured 403 is `Agents cannot move scorer labels; labels are human-only pointers. Propose the label change as a promotion manifest for a human to apply.` |
19
19
  | Hosted session | Promotion | After report acceptance, the two-stage path materializes once with unlabeled `orizu optimizations promote <run-id> --candidate <id> --project <team/project>`; hand the exact run id, candidate id, project, and report evidence to the human. | For the staged path, a human admin or curator with `can_manage_project` re-runs the same promotion as `orizu optimizations promote <run-id> --candidate <id> --label production --project <team/project>`. The idempotent finalizer finds the existing version by run and candidate provenance, moves production to that exact materialized version, and creates no duplicate profile version or candidate-promoted event. Equivalent simpler one-shot path: without prior materialization, the human runs the same labeled command once. A human admin or curator also runs `orizu manifests approve <id>`, `orizu manifests reject <id>`, and, after approval, `orizu manifests apply <id>`. |
@@ -1608,3 +1608,27 @@ Before `orizu apps create`:
1608
1608
  - [ ] `?` shortcut overlay or visible cheat sheet
1609
1609
  - [ ] Smoke test passes (`node scripts/test-app.mjs ...`)
1610
1610
  - [ ] Local preview renders and screenshot is inspected against the user's intended workflow (`orizu apps preview ... --screenshot preview.png`)
1611
+
1612
+ ---
1613
+
1614
+ ## Publishing from a hosted session
1615
+
1616
+ In a hosted session, `orizu apps create` and `orizu apps update` publish through git. Pass `--file`, `--input-schema` and `--output-schema` as usual; the command then:
1617
+
1618
+ 1. Copies the three files into `projects/<project-slug>/apps/<app-id>/` in the workspace as `App.tsx`, `input.schema.json` and `output.schema.json`, commits only App.tsx, input.schema.json, and output.schema.json, and pushes the session copy.
1619
+ 2. Merges the session into the team copy, then records the version. It prints `Merged into the team copy: <sha>` or `Already in the team copy: <sha>`.
1620
+
1621
+ Edit an existing app in its folder and pass those files to `apps update`. A new app can live anywhere until `apps create` places it.
1622
+
1623
+ The merge includes all earlier committed work on the session branch, including files outside the app folder. Review that work before publishing. Unrelated uncommitted work stays local. A conflict may name files outside the app folder; resolve and commit those files too.
1624
+
1625
+ When a hosted `apps create` or `apps update` stops after its folder is known, its error prints `App ID`, `Folder`, and a full `Retry` command. Keep that ID and folder. After all three app files have been written, the retry uses safe canonical files, preserving committed conflict resolutions. If the attempt stops before all three files are written, the retry keeps the original input paths. Both retries preserve the selected server. After following the error's instructions, run the printed retry command; create keeps its original `--id`, and update keeps its `--app`. This also applies when the push is rejected, the API refuses the create, or the merge is still running. The files and commits already made stay in the workspace; a refused push has not sent them to the server.
1626
+
1627
+ An explicit publish refusal before recording means no app version was recorded. Act on its message, then use the printed retry command. If transport fails or the response cannot be read, the outcome is uncertain: check the retained app ID before retrying.
1628
+
1629
+ - **Conflict** (names the files): run the full printed `Recovery` command. It checks whether the workspace is shallow, fetches missing history from origin if needed, then pulls the team copy with an explicit merge strategy. If ancestry preparation fails, stop and repair origin before retrying; the team pull must not run. Resolve those files and commit. For `apps create`, use the printed retry command with its original `--id`, so the app keeps its folder.
1630
+ - **Session copy has commits this workspace does not**: run `git pull --no-rebase --no-edit`, then retry. For `apps create`, keep the printed `--id`.
1631
+ - **Still merging, other publishes landed first, or failed**: retry. For `apps create`, use the printed retry command with its original `--id`; for `apps update`, use its printed retry command.
1632
+ - **Refused** (the session ended or the team paused agents): stop and tell the user.
1633
+
1634
+ Outside a hosted session the command records the version only, and prints a note that it is not in the team repository yet.
@@ -192,10 +192,17 @@ Profile, never a version, and exposes no fallback or affected-Profile list.
192
192
  `default move` repoints
193
193
  Default to the named Profile without moving Production; if that Profile is
194
194
  unpromoted, bare resolution refuses with `instruction_set_profile_not_promoted`.
195
- `shape add` and `shape remove` create a new complete version for every profile;
196
- they never move Default or a Production label. The set does not resolve for affected model
197
- configs until those pointers move to the new shape-change profile versions; the
198
- text CLI prints the required follow-up commands.
195
+ `shape add` and `shape remove` work only until the first promotion of any
196
+ profile. They create a new complete unpromoted version for every profile and
197
+ never move Default or a Production label. First promotion permanently freezes
198
+ the shape; changing component names after publication requires a new instruction
199
+ set, even after archiving or removing production pointers. Every set that
200
+ existed when this rule shipped is already frozen, including drafts, so a 409
201
+ `Instruction set shape is permanently frozen` on an existing set means: create a
202
+ new instruction set. If sync or a run refuses with
203
+ `instruction_set_production_shape_mismatch`, the Production version predates the
204
+ freeze and disagrees with the set's components; hand off creating and promoting
205
+ a compatible version to a human. Exact `@vN` sync still works.
199
206
 
200
207
  The manifest is a JSON object with `name`, optional unversioned `description`,
201
208
  ordered `shape`, and `components`. The description belongs to the instruction
@@ -309,6 +316,8 @@ orizu apps update \
309
316
  --output-schema ./schemas/output.json
310
317
  ```
311
318
 
319
+ In a hosted session both commands commit the app into `projects/<project-slug>/apps/<app-id>/` and merge it into the team copy before recording it; `apps create --id <app-id>` reruns a create that stopped and keeps its folder. See [building-apps.md](building-apps.md#publishing-from-a-hosted-session).
320
+
312
321
  Link dataset:
313
322
 
314
323
  ```bash
@@ -52,7 +52,7 @@ Second, before pushing, require the target profile's `model_config_settings_vers
52
52
 
53
53
  Once a supported surface can prove equality, the agent may copy the selected value exactly into the complete manifest while preserving every other override and pin, then use the runner-pinned push above on the local surface. Moving exactly one verified serving pointer remains human-only. An existing Production Profile would be promoted by the human and recover with `orizu instructions profiles rollback <set> --project <team/project> --model-config <identity> --to <previous-production-version-number>`. A human managing Default would first confirm the target Profile has a reviewed Production Version, move Default to that Profile identity, and recover by moving the prior Default Profile identity back. If a change must affect only one identity, a curator establishes a dedicated Profile only when none exists and confirms a recovery target; an existing unpromoted Profile makes `profiles new` reject and remains unusable until promoted. These are blocked flow shapes, not permission to bypass the settings proof.
54
54
 
55
- If the blocked set-of-one run should instead become a profile-recorded optimization, change the instruction-set shape to more than one real, separately optimized component, validate the new multi-component manifest and dataset contract, and only then rerun. Repeating the same one-component shape remains a prompt-version run. If that material redesign is not warranted, keep the current run at **Gather more evidence**.
55
+ If the blocked set-of-one run should instead become a profile-recorded optimization, give it more than one real, separately optimized component, validate the new multi-component manifest and dataset contract, and only then rerun. Shape edits work only until a set's first promotion; a published or rollout-frozen set needs a new instruction set with the new shape. Repeating the same one-component shape remains a prompt-version run. If that material redesign is not warranted, keep the current run at **Gather more evidence**.
56
56
 
57
57
  Pre-promotion Final-held-out confirmation is unsupported for a plain-prompt run today: the selected prompt version does not exist until its live pointer move. The report must present validation-only evidence, mark Final-held-out as **not obtainable before promotion**, and never describe Final-held-out proof as completed. If policy requires Final-held-out evidence before production, recommend **Gather more evidence** and do not run the promotion command. If a human explicitly accepts live-first verification, phrase the ship recommendation as a **post-promotion verification plan**: name the watch window, failure threshold, and confirmed rollback path before promotion. The current CLI has no plain-prompt rollback command, so do not invent one; if no external/manual rollback path is confirmed, recommend **Gather more evidence**. A set-of-one run also stops today when complete local runner evidence is unavailable and, even when that runner is known, because run-versus-target settings equality cannot be proven through the supported CLI.
58
58
 
@@ -125,6 +125,7 @@ Two artifacts: an **app** (custom labeler UI) and a **task** (the labeling round
125
125
 
126
126
  ```bash
127
127
  # 1. Create the labeler app, bound to the dataset
128
+ # (in a hosted session it is committed and merged into the team copy first; see building-apps.md)
128
129
  orizu apps create \
129
130
  --project <teamSlug>/<projectSlug> \
130
131
  --name "Support QA Labeler" \
@@ -67,17 +67,21 @@ display name. Archiving changes list visibility only, so archived sets still res
67
67
  and sync; use `--status archived` or `--status all` to find them before
68
68
  restoring.
69
69
 
70
- Shape changes create unpromoted profile heads but leave the default and
71
- production pointers in place. The instruction set does not resolve for affected
72
- model configs until those pointers move to their new shape-change versions; use
73
- the follow-up commands printed by the text CLI.
70
+ Shape add/remove works only until the first promotion of any profile. Draft
71
+ shape changes create unpromoted profile heads and leave Default in place. First
72
+ promotion permanently freezes component names; changing a published shape
73
+ requires a new instruction set, even after archiving or removing production
74
+ pointers. Sets that existed when this rule shipped are frozen too, including
75
+ drafts. `instruction_set_production_shape_mismatch` means a pre-freeze Production
76
+ version disagrees with the set's components; a human must promote a compatible
77
+ version, and exact `@vN` sync still works meanwhile.
74
78
 
75
79
  `default show` reports the Default Profile and the Version its Production
76
80
  currently names, or `null` when that Profile is unpromoted. `default move`
77
81
  targets a Profile by model-config identity, never a Version, and does not move
78
82
  Production. Moving Default to an unpromoted Profile makes bare resolution refuse
79
83
  with `instruction_set_profile_not_promoted`; no other Profile falls back to it.
80
- Shape changes create a new `shape_change` Version for every Profile; they do not
84
+ Before first promotion, shape changes create a new `shape_change` Version for every Profile; they do not
81
85
  repoint Default or any Production label.
82
86
 
83
87
  Sync an offline runner directory with `orizu instructions sync planner