orizu 0.4.0 → 0.5.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 (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +276 -21
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +945 -258
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +96 -0
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +277 -21
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +1102 -317
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +164 -0
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +8 -4
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
@@ -0,0 +1,924 @@
1
+ /**
2
+ * Workspace tracked-resource sync commands (ALI-976 CLI half, revised spec).
3
+ *
4
+ * Pure logic + injected HTTP: no process.argv parsing and no printing live in
5
+ * the command functions (the thin `workspaceSyncCommand` entry point prints).
6
+ * This module implements the client side of the ALI-933 sync semantics:
7
+ *
8
+ * - Inventory: walk the durable workspace contract (manifests + instruction/
9
+ * memory/readme files) and compute each tracked file's content sha256.
10
+ * `.orizu/**` is cache-only by definition (spec §6) and never inventoried.
11
+ * Symlinked plain files hash the LINK TARGET PATH string (spec §3.1), so
12
+ * editing `AGENTS.md` never falsely dirties a symlinked `CLAUDE.md`;
13
+ * pointer files (no-symlinks mode) hash their own bytes.
14
+ * - Missing/untracked visibility (spec §3.2): cache-known paths absent from
15
+ * the inventory are reported `missing` locally and sent to sync as
16
+ * `{path, missing: true}` (the server echoes without touching stored
17
+ * truth). Files under managed primitive dirs covered by no manifest are
18
+ * reported `untracked` in LOCAL status only — never sent to sync.
19
+ * - Cache snapshots: last-sync `base` hashes stored under
20
+ * `.orizu/cache/sync/<sha256-of-path>.json`. Filenames are path-hashed so
21
+ * the cache never mirrors the workspace directory structure. Cache is
22
+ * gitignored and safely deletable (spec §6); it is `local`/`cache`, never a
23
+ * contract.
24
+ * - Commands: `status` (local three-way against the cache; `--remote`
25
+ * performs the SAME server-side reconciliation as `sync` — it registers
26
+ * and updates tracked-resource records; there is no read-only remote
27
+ * status in v0, spec §3.1 — but never writes local cache), `sync`
28
+ * (metadata-first; cache base advances ONLY on a `clean`
29
+ * convergence-confirmation), `pull` (metadata fast-forward, only when the
30
+ * server carries real truth), and `apply` (repo → server promotion;
31
+ * refuses DB-native owners, conflicts, and remote-newer via server 409s).
32
+ *
33
+ * v0 boundary: `pull` reconciles the manifest canonical block + cache only,
34
+ * and ONLY from server-carried truth (serverSha256/serverVersionId) — never
35
+ * from local bytes. It does not materialize bulk primitive content — source
36
+ * files converge through Git, and DB-native/object bytes materialize through
37
+ * the primitive-specific commands (e.g. `orizu prompts pull`,
38
+ * `orizu datasets download`). Full bulk-content pull is deferred (spec §8).
39
+ *
40
+ * Apply identity (spec §3.1): strictly the sync-recorded tracked-resource ROW
41
+ * id from the cache snapshot (`resourceId`). There is NO fallback to the
42
+ * manifest's `canonical.serviceId` (informational/legacy). Without a recorded
43
+ * row id the command directs the user to run `orizu workspace sync` first.
44
+ * On any apply 409 (owner refusal, conflicted, remote-newer, CAS loss) the
45
+ * recovery loop is: re-sync, converge per the reported status, retry.
46
+ */
47
+ import { spawnSync } from 'child_process';
48
+ import { createHash } from 'crypto';
49
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, writeFileSync } from 'fs';
50
+ import { join, relative, sep } from 'path';
51
+ import { authedFetch } from './http.js';
52
+ import { getWorkspaceRoot, PROJECT_PRIMITIVE_DIRS, readJsonManifest, WORKSPACE_DIR_NAME, } from './workspace.js';
53
+ export const TRACKED_RESOURCE_KINDS = [
54
+ 'team_manifest',
55
+ 'project_manifest',
56
+ 'instruction_file',
57
+ 'memory_file',
58
+ 'readme',
59
+ 'dataset',
60
+ 'app',
61
+ 'task',
62
+ 'prompt',
63
+ 'scorer',
64
+ 'optimization',
65
+ 'other',
66
+ ];
67
+ const PRIMITIVE_KINDS = {
68
+ dataset: 'dataset',
69
+ app: 'app',
70
+ task: 'task',
71
+ prompt: 'prompt',
72
+ scorer: 'scorer',
73
+ optimization: 'optimization',
74
+ };
75
+ const CACHE_SYNC_SUBDIR = join(WORKSPACE_DIR_NAME, 'cache', 'sync');
76
+ export function sha256Hex(value) {
77
+ return createHash('sha256').update(value).digest('hex');
78
+ }
79
+ function toPosix(relPath) {
80
+ return relPath.split(sep).join('/');
81
+ }
82
+ function posixDirname(path) {
83
+ const index = path.lastIndexOf('/');
84
+ return index === -1 ? '' : path.slice(0, index);
85
+ }
86
+ // Content hash of a manifest EXCLUDING its canonical block. The canonical block
87
+ // is metadata about the content (versionId/contentSha256/lastPulledAt move on
88
+ // apply/pull); hashing the whole file would make every manifest self-dirtying.
89
+ function manifestContentSha(manifest) {
90
+ const { canonical: _canonical, ...rest } = manifest;
91
+ return sha256Hex(JSON.stringify(rest));
92
+ }
93
+ // Plain-file hash (spec §3.1 "What is hashed"): a symlink hashes its LINK
94
+ // TARGET PATH string — never the resolved target's contents, which would make
95
+ // every AGENTS.md edit falsely dirty a symlinked CLAUDE.md. Regular files
96
+ // (including pointer files in no-symlinks mode) hash their own bytes.
97
+ function fileContentSha(absPath) {
98
+ const stat = lstatSync(absPath);
99
+ if (stat.isSymbolicLink()) {
100
+ return sha256Hex(readlinkSync(absPath));
101
+ }
102
+ return sha256Hex(readFileSync(absPath));
103
+ }
104
+ export function readCanonical(manifest) {
105
+ const canonical = manifest?.canonical;
106
+ return canonical && typeof canonical === 'object' && !Array.isArray(canonical)
107
+ ? canonical
108
+ : {};
109
+ }
110
+ export function stringOrNull(value) {
111
+ return typeof value === 'string' && value.length > 0 ? value : null;
112
+ }
113
+ function ownerFrom(canonical) {
114
+ const owner = canonical.owner;
115
+ return owner === 'orizu-db' || owner === 'object-storage' || owner === 'local' ? owner : 'repo';
116
+ }
117
+ function repoStateFrom(canonical, fallback) {
118
+ const value = canonical.repoState;
119
+ const allowed = ['source', 'draft', 'snapshot', 'mirror', 'cache', 'object_ref_only'];
120
+ return typeof value === 'string' && allowed.includes(value) ? value : fallback;
121
+ }
122
+ function manifestKind(manifest, basename) {
123
+ const kind = manifest?.kind;
124
+ if (kind === 'team')
125
+ return 'team_manifest';
126
+ if (kind === 'project')
127
+ return 'project_manifest';
128
+ if (typeof kind === 'string' && PRIMITIVE_KINDS[kind])
129
+ return PRIMITIVE_KINDS[kind];
130
+ if (basename === 'orizu.team.json')
131
+ return 'team_manifest';
132
+ if (basename === 'orizu.project.json')
133
+ return 'project_manifest';
134
+ return 'other';
135
+ }
136
+ function plainFileKind(basename) {
137
+ if (basename === 'AGENTS.md' || basename === 'CLAUDE.md')
138
+ return 'instruction_file';
139
+ if (basename === 'Memory.md' || basename === 'memory.md')
140
+ return 'memory_file';
141
+ if (basename === 'README.md')
142
+ return 'readme';
143
+ return 'other';
144
+ }
145
+ function manifestEntry(root, absPath, projectSlug, repoStateFallback) {
146
+ const manifest = readJsonManifest(absPath);
147
+ if (!manifest) {
148
+ return null;
149
+ }
150
+ const canonical = readCanonical(manifest);
151
+ const basename = absPath.split(sep).pop() || absPath;
152
+ return {
153
+ path: toPosix(relative(root, absPath)),
154
+ absPath,
155
+ kind: manifestKind(manifest, basename),
156
+ owner: ownerFrom(canonical),
157
+ repoState: repoStateFrom(canonical, repoStateFallback),
158
+ projectSlug,
159
+ serviceResourceId: stringOrNull(canonical.serviceId),
160
+ versionId: stringOrNull(canonical.versionId),
161
+ contentSha256: manifestContentSha(manifest),
162
+ manifestPath: absPath,
163
+ isManifest: true,
164
+ };
165
+ }
166
+ function plainEntry(root, absPath, projectSlug) {
167
+ const basename = absPath.split(sep).pop() || absPath;
168
+ return {
169
+ path: toPosix(relative(root, absPath)),
170
+ absPath,
171
+ kind: plainFileKind(basename),
172
+ owner: 'repo',
173
+ repoState: 'source',
174
+ projectSlug,
175
+ serviceResourceId: null,
176
+ versionId: null,
177
+ contentSha256: fileContentSha(absPath),
178
+ manifestPath: null,
179
+ isManifest: false,
180
+ };
181
+ }
182
+ function projectDirectoryNames(projectsRoot) {
183
+ try {
184
+ return readdirSync(projectsRoot, { withFileTypes: true })
185
+ .filter(entry => entry.isDirectory())
186
+ .map(entry => entry.name)
187
+ .sort();
188
+ }
189
+ catch {
190
+ return [];
191
+ }
192
+ }
193
+ function walkPrimitiveManifests(dir, depth, into) {
194
+ if (depth > 8 || !existsSync(dir)) {
195
+ return;
196
+ }
197
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
198
+ if (entry.name === WORKSPACE_DIR_NAME || entry.name === '.git') {
199
+ continue;
200
+ }
201
+ const path = join(dir, entry.name);
202
+ if (entry.isDirectory()) {
203
+ walkPrimitiveManifests(path, depth + 1, into);
204
+ }
205
+ else if (/^orizu\..+\.json$/.test(entry.name)) {
206
+ into.push(path);
207
+ }
208
+ }
209
+ }
210
+ function walkAllFiles(dir, depth, into) {
211
+ if (depth > 8 || !existsSync(dir)) {
212
+ return;
213
+ }
214
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
215
+ if (entry.name === WORKSPACE_DIR_NAME || entry.name === '.git') {
216
+ continue;
217
+ }
218
+ const path = join(dir, entry.name);
219
+ if (entry.isDirectory()) {
220
+ walkAllFiles(path, depth + 1, into);
221
+ }
222
+ else {
223
+ into.push(path);
224
+ }
225
+ }
226
+ }
227
+ /** Walk the durable workspace contract and return one entry per tracked file. */
228
+ export function collectInventory(cwd) {
229
+ const root = getWorkspaceRoot(cwd);
230
+ const entries = [];
231
+ const teamManifest = join(root, 'orizu.team.json');
232
+ if (existsSync(teamManifest)) {
233
+ const entry = manifestEntry(root, teamManifest, null, 'source');
234
+ if (entry)
235
+ entries.push(entry);
236
+ }
237
+ for (const name of ['AGENTS.md', 'CLAUDE.md', 'Memory.md', 'README.md']) {
238
+ const path = join(root, name);
239
+ if (existsSync(path))
240
+ entries.push(plainEntry(root, path, null));
241
+ }
242
+ const projectsRoot = join(root, 'projects');
243
+ for (const slug of projectDirectoryNames(projectsRoot)) {
244
+ const projectRoot = join(projectsRoot, slug);
245
+ const projectManifest = join(projectRoot, 'orizu.project.json');
246
+ if (existsSync(projectManifest)) {
247
+ const entry = manifestEntry(root, projectManifest, slug, 'draft');
248
+ if (entry)
249
+ entries.push(entry);
250
+ }
251
+ for (const name of ['README.md', 'memory.md']) {
252
+ const path = join(projectRoot, name);
253
+ if (existsSync(path))
254
+ entries.push(plainEntry(root, path, slug));
255
+ }
256
+ for (const primitiveDir of PROJECT_PRIMITIVE_DIRS) {
257
+ const manifests = [];
258
+ walkPrimitiveManifests(join(projectRoot, primitiveDir), 0, manifests);
259
+ for (const path of manifests) {
260
+ const entry = manifestEntry(root, path, slug, 'draft');
261
+ if (entry)
262
+ entries.push(entry);
263
+ }
264
+ }
265
+ }
266
+ return entries;
267
+ }
268
+ /**
269
+ * Files present under managed primitive dirs that no inventory rule covers
270
+ * (spec §3.2 `untracked`). A file is covered when an inventoried manifest
271
+ * lives in its directory or any ancestor within the managed dir — body files
272
+ * adjacent to their manifest belong to that resource and converge via Git.
273
+ * Reported in LOCAL status only; never sent to sync.
274
+ */
275
+ export function collectUntrackedPaths(cwd, inventory) {
276
+ const root = getWorkspaceRoot(cwd);
277
+ const inventoryPaths = new Set(inventory.map(entry => entry.path));
278
+ const manifestDirs = new Set(inventory.filter(entry => entry.isManifest).map(entry => posixDirname(entry.path)));
279
+ const untracked = [];
280
+ const projectsRoot = join(root, 'projects');
281
+ for (const slug of projectDirectoryNames(projectsRoot)) {
282
+ for (const primitiveDir of PROJECT_PRIMITIVE_DIRS) {
283
+ const managedRel = `projects/${slug}/${primitiveDir}`;
284
+ const files = [];
285
+ walkAllFiles(join(projectsRoot, slug, primitiveDir), 0, files);
286
+ for (const absPath of files) {
287
+ const relPath = toPosix(relative(root, absPath));
288
+ if (inventoryPaths.has(relPath)) {
289
+ continue;
290
+ }
291
+ let dir = posixDirname(relPath);
292
+ let covered = false;
293
+ while (dir.length >= managedRel.length) {
294
+ if (manifestDirs.has(dir)) {
295
+ covered = true;
296
+ break;
297
+ }
298
+ if (dir === managedRel) {
299
+ break;
300
+ }
301
+ dir = posixDirname(dir);
302
+ }
303
+ if (!covered) {
304
+ untracked.push(relPath);
305
+ }
306
+ }
307
+ }
308
+ }
309
+ return untracked.sort();
310
+ }
311
+ // ---- Cache snapshots ------------------------------------------------------
312
+ export function cacheSnapshotPath(cwd, relPath) {
313
+ return join(getWorkspaceRoot(cwd), CACHE_SYNC_SUBDIR, `${sha256Hex(relPath)}.json`);
314
+ }
315
+ export function readCacheSnapshot(cwd, relPath) {
316
+ const file = cacheSnapshotPath(cwd, relPath);
317
+ if (!existsSync(file)) {
318
+ return null;
319
+ }
320
+ try {
321
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
322
+ return parsed && typeof parsed === 'object' ? parsed : null;
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ }
328
+ export function writeCacheSnapshot(cwd, snapshot) {
329
+ const file = cacheSnapshotPath(cwd, snapshot.path);
330
+ mkdirSync(join(getWorkspaceRoot(cwd), CACHE_SYNC_SUBDIR), { recursive: true });
331
+ writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`);
332
+ }
333
+ /** Every readable cache snapshot (regenerable; unreadable files are skipped). */
334
+ function listCacheSnapshots(cwd) {
335
+ const dir = join(getWorkspaceRoot(cwd), CACHE_SYNC_SUBDIR);
336
+ let names;
337
+ try {
338
+ names = readdirSync(dir).filter(name => name.endsWith('.json')).sort();
339
+ }
340
+ catch {
341
+ return [];
342
+ }
343
+ const snapshots = [];
344
+ for (const name of names) {
345
+ try {
346
+ const parsed = JSON.parse(readFileSync(join(dir, name), 'utf8'));
347
+ if (parsed && typeof parsed === 'object' && typeof parsed.path === 'string') {
348
+ snapshots.push(parsed);
349
+ }
350
+ }
351
+ catch {
352
+ // Cache is regenerable by definition (§6); skip unreadable snapshots.
353
+ }
354
+ }
355
+ return snapshots;
356
+ }
357
+ /** Cache-known paths absent from the inventory — the `missing` transport (§3.1). */
358
+ function missingCachePaths(cwd, inventory) {
359
+ const inventoryPaths = new Set(inventory.map(entry => entry.path));
360
+ return listCacheSnapshots(cwd)
361
+ .map(snapshot => snapshot.path)
362
+ .filter(path => !inventoryPaths.has(path));
363
+ }
364
+ // Record the server row id WITHOUT advancing the base: base/versionId move
365
+ // only on a clean confirmation or on pull/apply; the row id is pure identity
366
+ // and is safe to persist for every sync response.
367
+ function recordCacheResourceId(cwd, path, resourceId) {
368
+ if (!resourceId) {
369
+ return;
370
+ }
371
+ const existing = readCacheSnapshot(cwd, path);
372
+ if (existing?.resourceId === resourceId) {
373
+ return;
374
+ }
375
+ // Preserve every other field (base/version/lastSynced AND the §3.3
376
+ // content-pending guard): recording a row id must never advance the base nor
377
+ // silently clear a pending mark. A non-clean sync between pull and apply keeps
378
+ // the guard intact; only a `clean` convergence (or a reconciled apply/pull)
379
+ // clears it.
380
+ writeCacheSnapshot(cwd, {
381
+ ...(existing ?? { path, baseSha256: null, versionId: null, lastSyncedAt: null }),
382
+ path,
383
+ resourceId,
384
+ });
385
+ }
386
+ // ---- Manifest edits (preserve all other fields + formatting) --------------
387
+ function writeManifest(absPath, manifest) {
388
+ writeFileSync(absPath, `${JSON.stringify(manifest, null, 2)}\n`);
389
+ }
390
+ function patchCanonical(absPath, patch) {
391
+ const manifest = readJsonManifest(absPath) ?? {};
392
+ const canonical = { ...readCanonical(manifest), ...patch };
393
+ writeManifest(absPath, { ...manifest, canonical });
394
+ }
395
+ function patchTeamWorkspaceId(root, workspaceId) {
396
+ const absPath = join(root, 'orizu.team.json');
397
+ const manifest = readJsonManifest(absPath) ?? {};
398
+ const canonical = { ...readCanonical(manifest), serviceId: workspaceId };
399
+ const setupRaw = manifest.setup;
400
+ const setup = { ...(setupRaw && typeof setupRaw === 'object' && !Array.isArray(setupRaw) ? setupRaw : {}), attachedWorkspaceId: workspaceId };
401
+ writeManifest(absPath, { ...manifest, canonical, setup });
402
+ }
403
+ // ---- HTTP helpers ---------------------------------------------------------
404
+ async function readJson(response) {
405
+ try {
406
+ return (await response.json());
407
+ }
408
+ catch {
409
+ return {};
410
+ }
411
+ }
412
+ function teamSlugOf(root) {
413
+ const manifest = readJsonManifest(join(root, 'orizu.team.json'));
414
+ return stringOrNull(manifest?.slug) || 'local-team';
415
+ }
416
+ export function attachedWorkspaceId(root) {
417
+ const manifest = readJsonManifest(join(root, 'orizu.team.json'));
418
+ const setup = manifest?.setup;
419
+ const fromSetup = setup && typeof setup === 'object' && !Array.isArray(setup)
420
+ ? stringOrNull(setup.attachedWorkspaceId)
421
+ : null;
422
+ return fromSetup || stringOrNull(readCanonical(manifest).serviceId);
423
+ }
424
+ // Create-or-attach the server workspace. Deliberately does NOT write the
425
+ // manifest: the caller persists the id only after the first sync round-trip
426
+ // succeeds, so a failed sync never leaves a partially-attached manifest.
427
+ async function createOrAttachWorkspace(root, fetcher) {
428
+ const teamSlug = teamSlugOf(root);
429
+ const response = await fetcher('/api/cli/workspaces', {
430
+ method: 'POST',
431
+ headers: { 'Content-Type': 'application/json' },
432
+ body: JSON.stringify({ teamSlug, name: teamSlug, slug: teamSlug }),
433
+ });
434
+ if (!response.ok) {
435
+ const error = await readJson(response);
436
+ throw new Error(`Failed to attach workspace: ${stringOrNull(error.error) || response.status}`);
437
+ }
438
+ const data = await readJson(response);
439
+ const workspace = data.workspace;
440
+ const id = stringOrNull(workspace?.id);
441
+ if (!id) {
442
+ throw new Error('Workspace attach response did not include an id');
443
+ }
444
+ return id;
445
+ }
446
+ function requireAttachedWorkspaceId(root, opts) {
447
+ const known = stringOrNull(opts.workspaceId ?? undefined) || attachedWorkspaceId(root);
448
+ if (!known) {
449
+ throw new Error('This workspace is not attached yet. Run `orizu workspace sync` first to attach it.');
450
+ }
451
+ return known;
452
+ }
453
+ // One entry per inventoried path plus `{path, missing: true}` for every
454
+ // cache-known path absent from the inventory (spec §3.1 missing transport).
455
+ function syncBody(root, entries) {
456
+ return {
457
+ resources: [
458
+ ...entries.map(entry => ({
459
+ path: entry.path,
460
+ kind: entry.kind,
461
+ projectSlug: entry.projectSlug,
462
+ canonicalOwner: entry.owner,
463
+ repoState: entry.repoState,
464
+ serviceResourceId: entry.serviceResourceId,
465
+ versionId: entry.versionId,
466
+ contentSha256: entry.contentSha256,
467
+ baseSha256: readCacheSnapshot(root, entry.path)?.baseSha256 ?? null,
468
+ })),
469
+ ...missingCachePaths(root, entries).map(path => ({ path, missing: true })),
470
+ ],
471
+ };
472
+ }
473
+ async function callSync(root, workspaceId, entries, fetcher) {
474
+ const response = await fetcher(`/api/cli/workspaces/${workspaceId}/sync`, {
475
+ method: 'POST',
476
+ headers: { 'Content-Type': 'application/json' },
477
+ body: JSON.stringify(syncBody(root, entries)),
478
+ });
479
+ if (!response.ok) {
480
+ const error = await readJson(response);
481
+ throw new Error(`Sync failed: ${stringOrNull(error.error) || response.status}`);
482
+ }
483
+ const data = await readJson(response);
484
+ return (Array.isArray(data.resources) ? data.resources : []);
485
+ }
486
+ function defaultGitRef(cwd, ref) {
487
+ try {
488
+ // Strip repo-context env (set by git inside hooks) so the query answers
489
+ // about `cwd`, not whatever repo GIT_DIR points at.
490
+ const env = { ...process.env };
491
+ delete env.GIT_DIR;
492
+ delete env.GIT_WORK_TREE;
493
+ delete env.GIT_INDEX_FILE;
494
+ const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', ref], { cwd, encoding: 'utf8', env });
495
+ if (result.status !== 0 || typeof result.stdout !== 'string') {
496
+ return null;
497
+ }
498
+ return result.stdout.trim() || null;
499
+ }
500
+ catch {
501
+ return null;
502
+ }
503
+ }
504
+ const STALE_ACTIVE_MS = 24 * 60 * 60 * 1000;
505
+ // Fetch the workspace record and compare its recorded DEFAULT-branch push SHA
506
+ // against what the local repo knows that same branch to be. Best-effort: any
507
+ // server/non-hosted/non-git/unknown-branch condition yields null so `status`
508
+ // never fails — and never false-warns — for a local-only, offline, or
509
+ // feature-branch checkout.
510
+ async function fetchFreshness(root, workspaceId, fetcher, gitRef) {
511
+ const teamSlug = teamSlugOf(root);
512
+ const response = await fetcher(`/api/cli/workspaces?team=${encodeURIComponent(teamSlug)}`);
513
+ if (!response.ok)
514
+ return null;
515
+ const data = await readJson(response);
516
+ const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
517
+ const ws = workspaces.find(item => stringOrNull(item.id) === workspaceId) ?? null;
518
+ const serverSha = ws ? stringOrNull(ws.repoLastPushSha) : null;
519
+ const branch = ws ? stringOrNull(ws.repoLastPushBranch) : null;
520
+ // Without the server's default-branch name we cannot locate the right local
521
+ // ref to compare — stay silent rather than compare against the wrong branch.
522
+ if (!serverSha || !branch)
523
+ return null;
524
+ // Prefer the remote-tracking ref (what the last fetch/pull observed on the
525
+ // default branch); fall back to the local branch. HEAD is deliberately NOT
526
+ // used: a feature-branch checkout must never read as "repo moved".
527
+ const localDefaultSha = gitRef(root, `origin/${branch}`) ?? gitRef(root, branch);
528
+ if (!localDefaultSha || localDefaultSha === serverSha)
529
+ return null;
530
+ return {
531
+ serverSha,
532
+ branch,
533
+ pushedAt: stringOrNull(ws?.repoLastPushAt ?? null),
534
+ localDefaultSha,
535
+ };
536
+ }
537
+ // List the workspace's branched sessions and flag the stale ones. Report,
538
+ // don't reap (ALI-972): active sessions still holding a branch past 24h, and
539
+ // ended sessions whose branch metadata persists (unmerged write-back).
540
+ async function fetchStaleSessionBranches(workspaceId, fetcher, now) {
541
+ const response = await fetcher(`/api/cli/workspaces/${workspaceId}/sessions?branched=true`);
542
+ if (!response.ok)
543
+ return [];
544
+ const data = await readJson(response);
545
+ const sessions = Array.isArray(data.sessions) ? data.sessions : [];
546
+ const stale = [];
547
+ for (const session of sessions) {
548
+ const branch = stringOrNull(session.repoBranch);
549
+ if (!branch)
550
+ continue;
551
+ const status = stringOrNull(session.status);
552
+ const startedAt = stringOrNull(session.startedAt);
553
+ const sessionId = stringOrNull(session.id) ?? '';
554
+ if (status === 'active') {
555
+ const started = startedAt ? Date.parse(startedAt) : NaN;
556
+ if (!Number.isNaN(started) && now - started > STALE_ACTIVE_MS) {
557
+ stale.push({ sessionId, branch, startedAt, reason: 'active-stale' });
558
+ }
559
+ }
560
+ else if (status === 'ended') {
561
+ stale.push({ sessionId, branch, startedAt, reason: 'unmerged' });
562
+ }
563
+ }
564
+ return stale;
565
+ }
566
+ export async function runWorkspaceStatus(opts = {}) {
567
+ const root = getWorkspaceRoot(opts.cwd);
568
+ const fetcher = opts.fetcher ?? authedFetch;
569
+ const inventory = collectInventory(root);
570
+ const lines = inventory.map(entry => {
571
+ const snapshot = readCacheSnapshot(root, entry.path);
572
+ const base = snapshot?.baseSha256 ?? null;
573
+ // Surface the §3.3 guard only while it still bites: if the working content
574
+ // has since reconciled to the pending base, apply would clear it and proceed.
575
+ const pendingBase = snapshot?.contentPending === true
576
+ ? stringOrNull(snapshot.pendingBaseSha256 ?? snapshot.baseSha256 ?? null)
577
+ : null;
578
+ const contentPending = pendingBase !== null && pendingBase !== entry.contentSha256;
579
+ return {
580
+ path: entry.path,
581
+ kind: entry.kind,
582
+ owner: entry.owner,
583
+ repoState: entry.repoState,
584
+ localSha: entry.contentSha256,
585
+ baseSha: base,
586
+ dirty: base !== null && base !== entry.contentSha256,
587
+ ...(contentPending ? { contentPending: true } : {}),
588
+ };
589
+ });
590
+ // Cache-known paths that no longer exist on disk (spec §3.2 `missing`).
591
+ for (const path of missingCachePaths(root, inventory)) {
592
+ lines.push({
593
+ path,
594
+ kind: null,
595
+ owner: null,
596
+ repoState: null,
597
+ localSha: null,
598
+ baseSha: readCacheSnapshot(root, path)?.baseSha256 ?? null,
599
+ dirty: false,
600
+ localStatus: 'missing',
601
+ });
602
+ }
603
+ // Rule-less files under managed dirs (spec §3.2 `untracked`) — local only.
604
+ for (const path of collectUntrackedPaths(root, inventory)) {
605
+ lines.push({
606
+ path,
607
+ kind: null,
608
+ owner: null,
609
+ repoState: null,
610
+ localSha: null,
611
+ baseSha: null,
612
+ dirty: false,
613
+ localStatus: 'untracked',
614
+ });
615
+ }
616
+ let workspaceId = opts.workspaceId ?? attachedWorkspaceId(root);
617
+ if (opts.remote) {
618
+ // No read-only remote status exists in v0 (spec §3.1): this is the same
619
+ // server-side reconciliation as `sync` (it registers/updates records).
620
+ // It never writes the local cache, though.
621
+ workspaceId = requireAttachedWorkspaceId(root, opts);
622
+ const remote = await callSync(root, workspaceId, inventory, fetcher);
623
+ const byPath = new Map(remote.map(item => [item.path, item]));
624
+ for (const line of lines) {
625
+ const match = byPath.get(line.path);
626
+ if (match) {
627
+ line.remoteStatus = match.status;
628
+ line.serverVersionId = match.serverVersionId;
629
+ }
630
+ }
631
+ }
632
+ // Hosted-repo freshness + stale-branch report (best-effort): only for attached
633
+ // workspaces, and every server/non-git failure is swallowed so a local-only or
634
+ // offline `status` behaves exactly as before.
635
+ let freshness = null;
636
+ let staleSessionBranches = [];
637
+ if (workspaceId) {
638
+ const gitRef = opts.gitRef ?? defaultGitRef;
639
+ const now = (opts.now ?? (() => Date.now()))();
640
+ try {
641
+ freshness = await fetchFreshness(root, workspaceId, fetcher, gitRef);
642
+ }
643
+ catch {
644
+ freshness = null;
645
+ }
646
+ try {
647
+ staleSessionBranches = await fetchStaleSessionBranches(workspaceId, fetcher, now);
648
+ }
649
+ catch {
650
+ staleSessionBranches = [];
651
+ }
652
+ }
653
+ return { root, workspaceId, remote: Boolean(opts.remote), resources: lines, freshness, staleSessionBranches };
654
+ }
655
+ export async function runWorkspaceSync(opts = {}) {
656
+ const root = getWorkspaceRoot(opts.cwd);
657
+ const fetcher = opts.fetcher ?? authedFetch;
658
+ const knownId = stringOrNull(opts.workspaceId ?? undefined) || attachedWorkspaceId(root);
659
+ const workspaceId = knownId ?? await createOrAttachWorkspace(root, fetcher);
660
+ const inventory = collectInventory(root);
661
+ const remote = await callSync(root, workspaceId, inventory, fetcher);
662
+ // Persist the attachment only after the sync round-trip succeeded, so a
663
+ // failed first sync leaves orizu.team.json byte-identical.
664
+ if (!knownId) {
665
+ patchTeamWorkspaceId(root, workspaceId);
666
+ }
667
+ const resources = remote.map(item => {
668
+ // Cache base advances ONLY on a `clean` convergence confirmation; every
669
+ // other status leaves the last-sync base untouched (spec §3.1 metadata-first).
670
+ // The server row id, however, is recorded for EVERY returned resource —
671
+ // it is what `apply` addresses, so losing it would strand new resources.
672
+ const resourceId = stringOrNull(item.id) ?? readCacheSnapshot(root, item.path)?.resourceId ?? null;
673
+ let cacheAdvanced = false;
674
+ if (item.status === 'clean') {
675
+ writeCacheSnapshot(root, {
676
+ path: item.path,
677
+ baseSha256: item.serverSha256,
678
+ versionId: item.serverVersionId,
679
+ lastSyncedAt: item.lastSyncedAt,
680
+ resourceId,
681
+ });
682
+ cacheAdvanced = true;
683
+ }
684
+ else {
685
+ recordCacheResourceId(root, item.path, resourceId);
686
+ }
687
+ return { ...item, cacheAdvanced };
688
+ });
689
+ return { root, workspaceId, resources };
690
+ }
691
+ export async function runWorkspacePull(targetPath, opts = {}) {
692
+ const root = getWorkspaceRoot(opts.cwd);
693
+ const fetcher = opts.fetcher ?? authedFetch;
694
+ const entry = collectInventory(root).find(item => item.path === targetPath);
695
+ if (!entry) {
696
+ const known = readCacheSnapshot(root, targetPath);
697
+ return {
698
+ path: targetPath,
699
+ status: 'noop',
700
+ remoteStatus: null,
701
+ reason: known
702
+ ? `File is missing locally at ${targetPath}. Restore it via Git; v0 pull reconciles metadata only and does not materialize content.`
703
+ : `No tracked resource at ${targetPath}`,
704
+ manifestUpdated: false,
705
+ cacheAdvanced: false,
706
+ };
707
+ }
708
+ const workspaceId = requireAttachedWorkspaceId(root, opts);
709
+ const results = await callSync(root, workspaceId, [entry], fetcher);
710
+ const remote = results.find(item => item.path === entry.path) ?? null;
711
+ const remoteStatus = remote?.status ?? null;
712
+ if (remoteStatus === 'divergent' || remoteStatus === 'conflicted') {
713
+ return {
714
+ path: targetPath,
715
+ status: 'refused',
716
+ remoteStatus,
717
+ reason: 'Pull refused: local edits are ahead of the last-sync base; resolve in the working tree (Git) or `apply` to promote.',
718
+ manifestUpdated: false,
719
+ cacheAdvanced: false,
720
+ };
721
+ }
722
+ // Fast-forward ONLY from server-carried truth. Truthless statuses
723
+ // (local-only/stale/missing/untracked) have nothing to pull; fabricating a
724
+ // base from local bytes is forbidden (spec §3.2).
725
+ const serverSha256 = stringOrNull(remote?.serverSha256 ?? null);
726
+ const serverVersionId = stringOrNull(remote?.serverVersionId ?? null);
727
+ if (!remote || serverSha256 === null) {
728
+ const reason = remoteStatus === 'stale'
729
+ ? 'Nothing to pull: no verifiable server truth for this resource. Refresh it through the primitive-specific command (e.g. `orizu prompts pull`, `orizu datasets download`), then sync.'
730
+ : 'Nothing to pull: this resource has no server truth yet. `orizu workspace apply` promotes repo-owned resources; run `orizu workspace sync` first if it is not registered.';
731
+ return { path: targetPath, status: 'noop', remoteStatus, reason, manifestUpdated: false, cacheAdvanced: false };
732
+ }
733
+ // Metadata fast-forward: advance cache base + manifest canonical to the
734
+ // SERVER truth (never local bytes). v0 is metadata-level: source bytes
735
+ // converge through Git; DB-native/object bytes materialize via the
736
+ // primitive-specific commands.
737
+ //
738
+ // Metadata-only-pull guard (spec §3.3): this pull advanced the base to the
739
+ // server hash WITHOUT delivering the bytes. For a repo-owned resource whose
740
+ // working content does not already match that hash, the working tree is now
741
+ // BEHIND the base — a naive three-way read would call it `divergent` and a
742
+ // CAS `apply` would pass (base == server truth) and silently revert the
743
+ // pulled version. Mark the resource content-pending so `apply` refuses until
744
+ // Git delivers the bytes (working == base again). If the content already
745
+ // matches (Git first, the §7 happy path), leave it unmarked.
746
+ const contentPending = entry.owner === 'repo' && entry.contentSha256 !== serverSha256;
747
+ writeCacheSnapshot(root, {
748
+ path: entry.path,
749
+ baseSha256: serverSha256,
750
+ versionId: serverVersionId,
751
+ lastSyncedAt: remote.lastSyncedAt ?? new Date().toISOString(),
752
+ resourceId: stringOrNull(remote.id) ?? readCacheSnapshot(root, entry.path)?.resourceId ?? null,
753
+ ...(contentPending
754
+ ? { contentPending: true, pendingBaseSha256: serverSha256 }
755
+ : {}),
756
+ });
757
+ let manifestUpdated = false;
758
+ if (entry.manifestPath) {
759
+ patchCanonical(entry.manifestPath, {
760
+ versionId: serverVersionId,
761
+ contentSha256: serverSha256,
762
+ lastPulledAt: remote.lastSyncedAt ?? new Date().toISOString(),
763
+ });
764
+ manifestUpdated = true;
765
+ }
766
+ const note = contentPending
767
+ ? 'Base advanced to the server version but the working content has not been delivered yet (content-pending). `apply` will refuse until Git delivers the new bytes (working == base); run `git pull` to fetch them first.'
768
+ : entry.owner === 'repo'
769
+ ? undefined
770
+ : 'v0 pull reconciles metadata only; materialize bulk content with the primitive-specific command (e.g. `orizu prompts pull`, `orizu datasets download`).';
771
+ return { path: targetPath, status: 'converged', remoteStatus, note, manifestUpdated, cacheAdvanced: true };
772
+ }
773
+ export async function runWorkspaceApply(targetPath, opts = {}) {
774
+ const root = getWorkspaceRoot(opts.cwd);
775
+ const fetcher = opts.fetcher ?? authedFetch;
776
+ const entry = collectInventory(root).find(item => item.path === targetPath);
777
+ if (!entry) {
778
+ return { path: targetPath, status: 'noop', reason: `No tracked resource at ${targetPath}`, manifestUpdated: false, cacheAdvanced: false };
779
+ }
780
+ if (entry.owner !== 'repo') {
781
+ return {
782
+ path: targetPath,
783
+ status: 'refused',
784
+ reason: `Apply refused: ${entry.owner} resources are pull-only; change DB-native state through the primitive's own command and promotion flow.`,
785
+ manifestUpdated: false,
786
+ cacheAdvanced: false,
787
+ };
788
+ }
789
+ // Apply identity (spec §3.1): STRICTLY the sync-recorded tracked-resource
790
+ // row id. The manifest's canonical.serviceId is informational/legacy and is
791
+ // never used — the API resolves by row id only, so a serviceId would 404.
792
+ const snapshot = readCacheSnapshot(root, entry.path);
793
+ const resourceId = stringOrNull(snapshot?.resourceId ?? null);
794
+ if (!resourceId) {
795
+ return {
796
+ path: targetPath,
797
+ status: 'noop',
798
+ reason: 'No server tracked-resource id is recorded for this path. Run `orizu workspace sync` first to register it and record its row id, then retry apply.',
799
+ manifestUpdated: false,
800
+ cacheAdvanced: false,
801
+ };
802
+ }
803
+ // Metadata-only-pull guard (spec §3.3): a prior `pull` advanced the base to
804
+ // the server hash without delivering the bytes. If the working content still
805
+ // doesn't match that pulled base, the working tree is BEHIND and a CAS apply
806
+ // would pass (base == server truth) and silently revert the pulled version.
807
+ // Refuse before any server call. Once Git delivers the bytes (working ==
808
+ // pending base), clear the mark and proceed — a genuine later edit off that
809
+ // base is then a legitimate, applyable `divergent`.
810
+ if (snapshot?.contentPending === true) {
811
+ const pendingBase = stringOrNull(snapshot.pendingBaseSha256 ?? snapshot.baseSha256 ?? null);
812
+ if (pendingBase !== null && entry.contentSha256 !== pendingBase) {
813
+ return {
814
+ path: targetPath,
815
+ status: 'refused',
816
+ reason: 'Apply refused: this resource is content-pending — a metadata-only pull advanced its base but the working file still holds the old content. Reconcile the working file to the pulled base first (e.g. `git pull`, or re-pull once Git has delivered the content), then retry apply.',
817
+ manifestUpdated: false,
818
+ cacheAdvanced: false,
819
+ };
820
+ }
821
+ // Working content now matches the pulled base — the pull is fully realized.
822
+ writeCacheSnapshot(root, { ...snapshot, contentPending: false, pendingBaseSha256: null });
823
+ }
824
+ const workspaceId = requireAttachedWorkspaceId(root, opts);
825
+ const baseSha = snapshot?.baseSha256 ?? null;
826
+ const response = await fetcher(`/api/cli/workspaces/${workspaceId}/resources/${resourceId}/apply`, {
827
+ method: 'POST',
828
+ headers: { 'Content-Type': 'application/json' },
829
+ body: JSON.stringify({ contentSha256: entry.contentSha256, baseSha256: baseSha }),
830
+ });
831
+ if (response.status === 409) {
832
+ // Owner refusal, conflicted, remote-newer, or CAS loss — in every case
833
+ // the recovery loop is the same: re-sync, converge, retry.
834
+ const error = await readJson(response);
835
+ return {
836
+ path: targetPath,
837
+ status: 'refused',
838
+ reason: stringOrNull(error.error) || 'Apply refused',
839
+ hint: 'Run `orizu workspace sync` to refresh statuses, converge per the reported status (pull/merge), then retry the apply.',
840
+ manifestUpdated: false,
841
+ cacheAdvanced: false,
842
+ };
843
+ }
844
+ if (!response.ok) {
845
+ const error = await readJson(response);
846
+ throw new Error(`Apply failed: ${stringOrNull(error.error) || response.status}`);
847
+ }
848
+ const data = await readJson(response);
849
+ const resource = data.resource ?? {};
850
+ const versionId = stringOrNull(resource.versionId);
851
+ const contentSha256 = stringOrNull(resource.contentSha256) ?? entry.contentSha256;
852
+ const lastSyncedAt = stringOrNull(resource.lastSyncedAt) ?? new Date().toISOString();
853
+ if (entry.manifestPath) {
854
+ patchCanonical(entry.manifestPath, { versionId, contentSha256, lastPulledAt: lastSyncedAt });
855
+ }
856
+ writeCacheSnapshot(root, {
857
+ path: entry.path,
858
+ baseSha256: contentSha256,
859
+ versionId,
860
+ lastSyncedAt,
861
+ resourceId: stringOrNull(resource.id) ?? resourceId,
862
+ });
863
+ return {
864
+ path: targetPath,
865
+ status: 'applied',
866
+ versionId,
867
+ contentSha256,
868
+ lastSyncedAt,
869
+ manifestUpdated: Boolean(entry.manifestPath),
870
+ cacheAdvanced: true,
871
+ };
872
+ }
873
+ function emit(io, human, payload) {
874
+ io.print(io.json ? JSON.stringify(payload) : human);
875
+ }
876
+ export async function workspaceSyncCommand(args, io) {
877
+ const positional = args.filter(arg => !arg.startsWith('--'));
878
+ const subcommand = positional[0];
879
+ const target = positional[1];
880
+ const remote = args.includes('--remote');
881
+ const fetcher = io.fetcher;
882
+ if (subcommand === 'status') {
883
+ const result = await runWorkspaceStatus({ remote, fetcher });
884
+ const lines = result.resources.map(r => `${r.dirty ? '*' : ' '} ${r.path} [${r.kind ?? '?'}/${r.owner ?? '?'}/${r.repoState ?? '?'}]`
885
+ + `${r.localStatus ? ` ${r.localStatus}` : ''}${r.remoteStatus ? ` ${r.remoteStatus}` : ''}`
886
+ + `${r.contentPending ? ' (content-pending)' : ''}`);
887
+ if (result.freshness) {
888
+ const { serverSha, branch, pushedAt } = result.freshness;
889
+ lines.push(`repo moved since your last pull: ${serverSha}`
890
+ + `${branch ? ` on ${branch}` : ''}${pushedAt ? ` at ${pushedAt}` : ''}`);
891
+ }
892
+ if (result.staleSessionBranches && result.staleSessionBranches.length > 0) {
893
+ lines.push('stale session branches:');
894
+ for (const stale of result.staleSessionBranches) {
895
+ const detail = stale.reason === 'active-stale' ? 'active >24h' : 'unmerged';
896
+ lines.push(` ${stale.branch} (started ${stale.startedAt ?? '?'}, ${detail})`);
897
+ }
898
+ }
899
+ emit(io, ['workspace status', ...lines].join('\n'), result);
900
+ return 0;
901
+ }
902
+ if (subcommand === 'sync') {
903
+ const result = await runWorkspaceSync({ fetcher });
904
+ const lines = result.resources.map(r => `${r.status.padEnd(13)} ${r.path}`);
905
+ emit(io, [`workspace sync (workspace ${result.workspaceId})`, ...lines].join('\n'), result);
906
+ return 0;
907
+ }
908
+ if (subcommand === 'pull' && target) {
909
+ const result = await runWorkspacePull(target, { fetcher });
910
+ const human = [`${result.status}: ${result.path}${result.reason ? ` — ${result.reason}` : ''}`, result.note].filter(Boolean).join('\n');
911
+ emit(io, human, result);
912
+ return result.status === 'refused' ? 1 : 0;
913
+ }
914
+ if (subcommand === 'apply' && target) {
915
+ const result = await runWorkspaceApply(target, { fetcher });
916
+ const human = [`${result.status}: ${result.path}${result.reason ? ` — ${result.reason}` : ''}`, result.hint]
917
+ .filter(Boolean)
918
+ .join('\n');
919
+ emit(io, human, result);
920
+ return result.status === 'applied' ? 0 : 1;
921
+ }
922
+ io.print('Usage: orizu workspace <status|sync|pull|apply> [<path>] [--remote] [--json]');
923
+ return 1;
924
+ }