orizu 0.4.1 → 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 +271 -16
  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 +855 -237
  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 +39 -2
  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 +272 -16
  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 +987 -296
  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 +70 -2
  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 +3 -1
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
package/src/workspace.ts CHANGED
@@ -1,112 +1,1099 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
2
- import { join, resolve } from 'path'
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readlinkSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ symlinkSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from 'fs'
13
+ import { dirname, join, relative, resolve } from 'path'
3
14
 
4
15
  export const WORKSPACE_DIR_NAME = '.orizu'
16
+ export const WORKSPACE_SCHEMA_VERSION = 'orizu.workspace.v0'
17
+ export const WORKSPACE_SETUP_VERSION = '0.1.0'
18
+
19
+ export const WORKSPACE_ROOT_FILES = [
20
+ 'README.md',
21
+ 'orizu.team.json',
22
+ 'AGENTS.md',
23
+ 'CLAUDE.md',
24
+ 'Memory.md',
25
+ '.gitignore',
26
+ ] as const
27
+
28
+ export const WORKSPACE_ROOT_DIRS = [
29
+ WORKSPACE_DIR_NAME,
30
+ 'source-repos',
31
+ 'projects',
32
+ 'sessions',
33
+ ] as const
34
+
35
+ export const PROJECT_ROOT_FILES = [
36
+ 'README.md',
37
+ 'memory.md',
38
+ 'orizu.project.json',
39
+ '.gitignore',
40
+ ] as const
41
+
42
+ export const PROJECT_PRIMITIVE_DIRS = [
43
+ 'datasets',
44
+ 'apps',
45
+ 'tasks',
46
+ 'prompts',
47
+ 'scorers',
48
+ 'optimizations',
49
+ ] as const
50
+
51
+ export const REPO_STATES = [
52
+ 'source',
53
+ 'draft',
54
+ 'snapshot',
55
+ 'mirror',
56
+ 'cache',
57
+ 'object_ref_only',
58
+ ] as const
59
+
60
+ export const CANONICAL_OWNERS = [
61
+ 'repo',
62
+ 'orizu-db',
63
+ 'object-storage',
64
+ 'local',
65
+ ] as const
66
+
67
+ export type RepoState = typeof REPO_STATES[number]
68
+ export type CanonicalOwner = typeof CANONICAL_OWNERS[number]
69
+
70
+ export type WorkspaceOperationAction =
71
+ | 'create_file'
72
+ | 'create_dir'
73
+ | 'append_gitignore'
74
+ | 'create_symlink'
75
+ | 'write_pointer_file'
76
+ | 'rename_file'
77
+ | 'replace_file'
78
+ | 'replace_symlink'
79
+
80
+ export interface WorkspaceOperation {
81
+ action: WorkspaceOperationAction
82
+ path: string
83
+ safe: boolean
84
+ reason: string
85
+ content?: string
86
+ entries?: string[]
87
+ sourcePath?: string
88
+ target?: string
89
+ }
90
+
91
+ export interface WorkspaceProjectSeed {
92
+ slug: string
93
+ id?: string | null
94
+ name?: string | null
95
+ }
96
+
97
+ export type WorkspaceFindingSeverity = 'error' | 'warning' | 'info'
98
+
99
+ export interface WorkspaceFinding {
100
+ severity: WorkspaceFindingSeverity
101
+ code: string
102
+ path?: string
103
+ message: string
104
+ fixable: boolean
105
+ }
5
106
 
6
107
  export interface WorkspaceInitOptions {
7
108
  cwd?: string
109
+ workspaceRoot?: string
110
+ teamSlug?: string | null
111
+ teamId?: string | null
112
+ projectSlug?: string | null
113
+ projects?: WorkspaceProjectSeed[]
8
114
  baseUrl?: string | null
115
+ serviceOrigin?: string | null
116
+ attachWorkspaceId?: string | null
9
117
  cliVersion?: string | null
10
118
  dryRun?: boolean
119
+ validateOnly?: boolean
120
+ fix?: boolean
121
+ noSymlinks?: boolean
11
122
  }
12
123
 
13
124
  export interface WorkspaceInitResult {
14
125
  root: string
15
- state: 'created' | 'exists' | 'would-create'
126
+ state: 'created' | 'exists' | 'would-create' | 'validated' | 'invalid' | 'repaired'
16
127
  gitignoreUpdated: boolean
17
128
  actions: string[]
129
+ operations: WorkspaceOperation[]
130
+ findings: WorkspaceFinding[]
131
+ }
132
+
133
+ interface WorkspaceSetupPlan {
134
+ root: string
135
+ teamSlug: string
136
+ projects: WorkspaceProjectSeed[]
137
+ operations: WorkspaceOperation[]
138
+ findings: WorkspaceFinding[]
139
+ exists: boolean
18
140
  }
19
141
 
20
- const WORKSPACE_README = `# Orizu workspace
142
+ function hasAllowedValue(values: readonly string[], value: unknown): value is string {
143
+ return typeof value === 'string' && values.includes(value)
144
+ }
21
145
 
22
- Local Orizu artifacts for this repository. This folder is gitignored by
23
- default: generated and server-derived content here is not guaranteed to stay
24
- in sync with the Orizu server, so checking it in would risk drift. Safe,
25
- user-authored configuration may move to a committed location once a sync model
26
- exists.
146
+ function normalizeSlug(value: string | null | undefined, fallback: string): string {
147
+ const normalized = (value || fallback)
148
+ .trim()
149
+ .toLowerCase()
150
+ .replace(/[^a-z0-9]+/g, '-')
151
+ .replace(/^-+|-+$/g, '')
27
152
 
28
- - \`workspace.json\`: safe local metadata (no secrets; credentials stay in
29
- \`~/.config/orizu\`).
30
- - \`generated/\`: runner artifacts, exports, and other generated files.
31
- `
153
+ return normalized || fallback
154
+ }
155
+
156
+ function normalizeProjectSeeds(options?: WorkspaceInitOptions): WorkspaceProjectSeed[] {
157
+ const explicitProjects = options?.projects?.length
158
+ ? options.projects
159
+ : [{ slug: options?.projectSlug || 'local-project', id: null, name: null }]
160
+ const bySlug = new Map<string, WorkspaceProjectSeed>()
161
+
162
+ for (const project of explicitProjects) {
163
+ const slug = normalizeSlug(project.slug, 'local-project')
164
+ if (!bySlug.has(slug)) {
165
+ bySlug.set(slug, {
166
+ slug,
167
+ id: project.id || null,
168
+ name: project.name || null,
169
+ })
170
+ }
171
+ }
172
+
173
+ return Array.from(bySlug.values())
174
+ }
175
+
176
+ function resolveWorkspaceRoot(options?: WorkspaceInitOptions): string {
177
+ return resolve(options?.workspaceRoot || options?.cwd || process.cwd())
178
+ }
32
179
 
33
180
  export function getWorkspaceRoot(cwd?: string): string {
34
- return resolve(cwd || process.cwd(), WORKSPACE_DIR_NAME)
181
+ return resolve(cwd || process.cwd())
35
182
  }
36
183
 
37
184
  export function workspaceExists(cwd?: string): boolean {
38
- return existsSync(join(getWorkspaceRoot(cwd), 'workspace.json'))
185
+ return existsSync(join(getWorkspaceRoot(cwd), 'orizu.team.json'))
186
+ }
187
+
188
+ function formatJson(value: Record<string, unknown>): string {
189
+ return `${JSON.stringify(value, null, 2)}\n`
190
+ }
191
+
192
+ function createTeamManifest(options: {
193
+ teamSlug: string
194
+ teamId: string | null
195
+ serviceOrigin: string | null
196
+ attachWorkspaceId: string | null
197
+ cliVersion: string | null
198
+ }): string {
199
+ return formatJson({
200
+ schemaVersion: WORKSPACE_SCHEMA_VERSION,
201
+ kind: 'team',
202
+ slug: options.teamSlug,
203
+ teamId: options.teamId,
204
+ serviceOrigin: options.serviceOrigin,
205
+ defaultObjectStore: {
206
+ provider: 'supabase',
207
+ bucket: 'orizu-artifacts',
208
+ },
209
+ canonical: {
210
+ owner: 'repo',
211
+ repoState: 'source',
212
+ serviceId: options.attachWorkspaceId,
213
+ versionId: null,
214
+ contentSha256: null,
215
+ lastPulledAt: null,
216
+ objectRef: null,
217
+ notes: null,
218
+ },
219
+ setup: {
220
+ setupVersion: WORKSPACE_SETUP_VERSION,
221
+ createdBy: 'orizu setup',
222
+ createdAt: new Date().toISOString(),
223
+ cliVersion: options.cliVersion,
224
+ attachedWorkspaceId: options.attachWorkspaceId,
225
+ },
226
+ })
227
+ }
228
+
229
+ function createProjectManifest(teamSlug: string, project: WorkspaceProjectSeed): string {
230
+ return formatJson({
231
+ schemaVersion: WORKSPACE_SCHEMA_VERSION,
232
+ kind: 'project',
233
+ slug: project.slug,
234
+ name: project.name || null,
235
+ projectId: project.id || null,
236
+ teamSlug,
237
+ sourceRepos: [],
238
+ policies: {
239
+ defaultRepoState: 'draft',
240
+ },
241
+ canonical: {
242
+ owner: 'repo',
243
+ repoState: 'draft',
244
+ serviceId: null,
245
+ versionId: null,
246
+ contentSha256: null,
247
+ lastPulledAt: null,
248
+ objectRef: null,
249
+ notes: null,
250
+ },
251
+ })
252
+ }
253
+
254
+ function rootReadme(teamSlug: string): string {
255
+ return `# ${teamSlug} Orizu workbench
256
+
257
+ This repository is the portable Orizu workbench for this team. Durable context,
258
+ manifests, intentional snapshots, summaries, and promotion artifacts live in
259
+ Git. Live product state remains in Orizu DB/storage unless a repo file is
260
+ explicitly applied through Orizu.
261
+
262
+ Generated exports, local caches, raw logs, raw transcripts, bulky datasets, and
263
+ temporary runner materialization belong in ignored or object-backed locations.
264
+ `
265
+ }
266
+
267
+ function projectReadme(projectSlug: string): string {
268
+ return `# ${projectSlug}
269
+
270
+ Project-level Orizu context. Add human notes here and keep machine-readable
271
+ state in the adjacent \`orizu.project.json\` manifest and primitive manifests.
272
+ `
273
+ }
274
+
275
+ const AGENTS_MD = `# Orizu Agent Instructions
276
+
277
+ This is a team-level Orizu workbench. Use this file for durable, human-readable
278
+ agent guidance; keep detailed CLI manuals in installed skills or reference
279
+ docs instead of pasting them here.
280
+
281
+ Before changing anything, read:
282
+
283
+ - \`README.md\` for the team workbench overview.
284
+ - \`Memory.md\` for durable team preferences and decisions.
285
+ - \`projects/*/README.md\` and \`projects/*/memory.md\` for project context.
286
+ - \`orizu.*.json\` manifests for machine-readable ids, ownership, source-of-truth
287
+ state, commands, and object refs.
288
+
289
+ Use \`orizu --help\`, \`orizu <command> --help\`, and
290
+ \`orizu capabilities --json\` to discover exact CLI behavior. Do not duplicate
291
+ Orizu runtime behavior in repo scripts; go through the CLI.
292
+
293
+ Treat Git-tracked files as source/context or explicit snapshots. Live Orizu
294
+ state remains in Orizu DB/storage unless a manifest says a repo file is the
295
+ source of truth. Treat \`.orizu/\`, raw logs, raw transcripts, bulky datasets,
296
+ and local source checkouts as ignored cache or object-backed state unless
297
+ explicitly promoted.
298
+
299
+ Ask before creating Orizu server state or changing human-authored repo files.
300
+ `
301
+
302
+ const MEMORY_MD = `# Orizu Memory
303
+
304
+ Record durable team-level preferences, decisions, and lessons here.
305
+ `
306
+
307
+ const PROJECT_MEMORY_MD = `# Project Memory
308
+
309
+ Record durable project-level preferences, decisions, and lessons here.
310
+ `
311
+
312
+ const CLAUDE_POINTER = `# Claude Instructions
313
+
314
+ Read \`AGENTS.md\` in this directory for the canonical Orizu agent instructions.
315
+ `
316
+
317
+ const OLD_CLAUDE_POINTER = `# Claude Instructions
318
+
319
+ Read \`Agents.md\` in this directory for the canonical Orizu agent instructions.
320
+ `
321
+
322
+ const ORIZU_CLI_AGENTS_START_MARKER = '<!-- orizu-cli:start -->'
323
+ const ORIZU_CLI_AGENTS_END_MARKER = '<!-- orizu-cli:end -->'
324
+
325
+ const LOCAL_WORKSPACE_README = `# Local Orizu cache
326
+
327
+ This directory is ignored by default. Use it for generated exports, local
328
+ caches, temporary runner materialization, and server-derived files that may
329
+ drift. The durable workspace contract lives in the root and project manifests
330
+ outside this directory.
331
+ `
332
+
333
+ const ROOT_GITIGNORE_ENTRIES = [
334
+ '.orizu/',
335
+ '.logs/',
336
+ '**/raw_transcript/',
337
+ '**/*.raw.jsonl',
338
+ '**/*.log',
339
+ '**/log_dir/',
340
+ '**/downloaded-datasets/',
341
+ '**/trace-pulls/',
342
+ 'source-repos/*/checkout/',
343
+ 'source-repos/*/worktree/',
344
+ '.env',
345
+ '.env.*',
346
+ 'node_modules/',
347
+ '.next/',
348
+ 'dist/',
349
+ 'build/',
350
+ '.venv/',
351
+ '__pycache__/',
352
+ ]
353
+
354
+ const PROJECT_GITIGNORE_ENTRIES = [
355
+ '.orizu/',
356
+ '.logs/',
357
+ '**/raw_transcript/',
358
+ '**/*.raw.jsonl',
359
+ '**/*.log',
360
+ '**/log_dir/',
361
+ '**/downloaded-datasets/',
362
+ '**/trace-pulls/',
363
+ '.env',
364
+ '.env.*',
365
+ '.venv/',
366
+ '__pycache__/',
367
+ ]
368
+
369
+ function operation(action: WorkspaceOperationAction, path: string, reason: string, extra?: Partial<WorkspaceOperation>): WorkspaceOperation {
370
+ return {
371
+ action,
372
+ path,
373
+ reason,
374
+ safe: extra?.safe ?? true,
375
+ ...extra,
376
+ }
377
+ }
378
+
379
+ function lstatIfExists(path: string): ReturnType<typeof lstatSync> | null {
380
+ try {
381
+ return lstatSync(path)
382
+ } catch {
383
+ return null
384
+ }
385
+ }
386
+
387
+ function pathExists(path: string): boolean {
388
+ return existsSync(path) || lstatIfExists(path) !== null
389
+ }
390
+
391
+ function rootEntryCaseMatches(root: string, canonical: string): { hasCanonical: boolean, nonCanonical: string[] } {
392
+ const matches = rootEntryNames(root).filter(name => name.toLowerCase() === canonical.toLowerCase())
393
+ return {
394
+ hasCanonical: matches.includes(canonical),
395
+ nonCanonical: matches.filter(name => name !== canonical),
396
+ }
397
+ }
398
+
399
+ function pushCreateDir(operations: WorkspaceOperation[], path: string, reason: string) {
400
+ if (!pathExists(path)) {
401
+ operations.push(operation('create_dir', path, reason))
402
+ }
403
+ }
404
+
405
+ function pushCreateFile(operations: WorkspaceOperation[], path: string, content: string, reason: string) {
406
+ if (!pathExists(path)) {
407
+ operations.push(operation('create_file', path, reason, { content }))
408
+ }
409
+ }
410
+
411
+ function pushCreateRootFile(operations: WorkspaceOperation[], root: string, filename: string, content: string, reason: string) {
412
+ const { hasCanonical, nonCanonical } = rootEntryCaseMatches(root, filename)
413
+ if (!hasCanonical && nonCanonical.length === 0) {
414
+ operations.push(operation('create_file', join(root, filename), reason, { content }))
415
+ }
39
416
  }
40
417
 
41
- function ensureGitignoreEntry(cwd: string, dryRun: boolean): { updated: boolean, action: string | null } {
42
- const gitignorePath = resolve(cwd, '.gitignore')
43
- const entry = `${WORKSPACE_DIR_NAME}/`
418
+ function pushCaseRename(operations: WorkspaceOperation[], root: string, filename: string, fix: boolean | undefined) {
419
+ if (!fix) {
420
+ return
421
+ }
422
+
423
+ const { hasCanonical, nonCanonical } = rootEntryCaseMatches(root, filename)
424
+ if (!hasCanonical && nonCanonical.length === 1) {
425
+ operations.push(operation('rename_file', join(root, filename), `rename ${nonCanonical[0]} to ${filename}`, {
426
+ sourcePath: join(root, nonCanonical[0]),
427
+ }))
428
+ }
429
+ }
430
+
431
+ function isKnownClaudePointer(path: string): boolean {
432
+ try {
433
+ const trimmed = readFileSync(path, 'utf8').trim()
434
+ return trimmed === CLAUDE_POINTER.trim() ||
435
+ trimmed === OLD_CLAUDE_POINTER.trim()
436
+ } catch {
437
+ return false
438
+ }
439
+ }
440
+
441
+ function pushClaudeMigrationRepair(operations: WorkspaceOperation[], root: string, sourceName: string, options?: WorkspaceInitOptions) {
442
+ if (!options?.fix) {
443
+ return
444
+ }
445
+
446
+ const sourcePath = join(root, sourceName)
447
+ const claudePath = join(root, 'CLAUDE.md')
448
+ let repairable = false
449
+ try {
450
+ const stat = lstatSync(sourcePath)
451
+ repairable = stat.isSymbolicLink() || isKnownClaudePointer(sourcePath)
452
+ } catch {
453
+ repairable = false
454
+ }
455
+
456
+ if (!repairable) {
457
+ return
458
+ }
459
+
460
+ operations.push(options.noSymlinks
461
+ ? operation('replace_file', claudePath, 'repair CLAUDE.md pointer file', { content: CLAUDE_POINTER })
462
+ : operation('replace_symlink', claudePath, 'repair CLAUDE.md symlink target', { target: 'AGENTS.md' }))
463
+ }
464
+
465
+ function pushExistingClaudeRepair(operations: WorkspaceOperation[], root: string, options?: WorkspaceInitOptions) {
466
+ if (!options?.fix) {
467
+ return
468
+ }
469
+
470
+ const claudePath = join(root, 'CLAUDE.md')
471
+ if (!rootEntryCaseMatches(root, 'CLAUDE.md').hasCanonical) {
472
+ return
473
+ }
44
474
 
45
- if (existsSync(gitignorePath)) {
46
- const existing = readFileSync(gitignorePath, 'utf8')
47
- const hasEntry = existing
48
- .split('\n')
49
- .some(line => line.trim() === entry || line.trim() === WORKSPACE_DIR_NAME)
50
- if (hasEntry) {
51
- return { updated: false, action: null }
475
+ try {
476
+ const stat = lstatSync(claudePath)
477
+ if (stat.isSymbolicLink()) {
478
+ const target = readlinkSync(claudePath)
479
+ if (options.noSymlinks) {
480
+ operations.push(operation('replace_file', claudePath, 'repair CLAUDE.md pointer file', { content: CLAUDE_POINTER }))
481
+ } else if (target !== 'AGENTS.md') {
482
+ operations.push(operation('replace_symlink', claudePath, 'repair CLAUDE.md symlink target', { target: 'AGENTS.md' }))
483
+ }
484
+ return
52
485
  }
53
- if (!dryRun) {
54
- const separator = existing.endsWith('\n') || existing.length === 0 ? '' : '\n'
55
- writeFileSync(gitignorePath, `${existing}${separator}\n# Local Orizu workspace (generated; not synced with the server)\n${entry}\n`, 'utf8')
486
+
487
+ if (isKnownClaudePointer(claudePath)) {
488
+ operations.push(options.noSymlinks
489
+ ? operation('replace_file', claudePath, 'repair CLAUDE.md pointer file', { content: CLAUDE_POINTER })
490
+ : operation('replace_symlink', claudePath, 'repair CLAUDE.md symlink target', { target: 'AGENTS.md' }))
56
491
  }
57
- return { updated: true, action: `append ${entry} to .gitignore` }
492
+ } catch {
493
+ // Validation will report unreadable or missing files.
58
494
  }
495
+ }
496
+
497
+ function gitignoreHasEntry(content: string, entry: string): boolean {
498
+ return content
499
+ .split('\n')
500
+ .map(line => line.trim())
501
+ .some(line => line === entry || line === entry.replace(/\/$/, ''))
502
+ }
59
503
 
60
- if (!dryRun) {
61
- writeFileSync(gitignorePath, `# Local Orizu workspace (generated; not synced with the server)\n${entry}\n`, 'utf8')
504
+ function missingGitignoreEntries(path: string, entries: string[]): string[] {
505
+ if (!existsSync(path)) {
506
+ return entries
62
507
  }
63
- return { updated: true, action: `create .gitignore with ${entry}` }
508
+
509
+ const content = readFileSync(path, 'utf8')
510
+ return entries.filter(entry => !gitignoreHasEntry(content, entry))
64
511
  }
65
512
 
66
- export function initOrizuWorkspace(options?: WorkspaceInitOptions): WorkspaceInitResult {
67
- const cwd = resolve(options?.cwd || process.cwd())
68
- const root = getWorkspaceRoot(cwd)
69
- const dryRun = Boolean(options?.dryRun)
70
- const exists = workspaceExists(cwd)
71
- const actions: string[] = []
513
+ function pushGitignore(operations: WorkspaceOperation[], path: string, entries: string[], reason: string) {
514
+ const missing = missingGitignoreEntries(path, entries)
515
+ if (missing.length > 0) {
516
+ operations.push(operation('append_gitignore', path, reason, { entries: missing }))
517
+ }
518
+ }
519
+
520
+ function existingTeamSlug(root: string): string | null {
521
+ const manifest = readJsonManifest(join(root, 'orizu.team.json'))
522
+ return typeof manifest?.slug === 'string' ? manifest.slug : null
523
+ }
524
+
525
+ function buildSetupPlan(options?: WorkspaceInitOptions, includeFindings = true): WorkspaceSetupPlan {
526
+ const root = resolveWorkspaceRoot(options)
527
+ const currentTeamSlug = existingTeamSlug(root)
528
+ const requestedTeamSlug = options?.teamSlug ? normalizeSlug(options.teamSlug, 'local-team') : null
529
+ const normalizedCurrentTeamSlug = currentTeamSlug ? normalizeSlug(currentTeamSlug, 'local-team') : null
530
+ if (requestedTeamSlug && normalizedCurrentTeamSlug && requestedTeamSlug !== normalizedCurrentTeamSlug) {
531
+ throw new Error(
532
+ `This directory is already an Orizu workspace for team '${normalizedCurrentTeamSlug}'. `
533
+ + `Run setup in another directory to set up team '${requestedTeamSlug}'.`
534
+ )
535
+ }
536
+ const teamSlug = requestedTeamSlug || normalizedCurrentTeamSlug || 'local-team'
537
+ const existingProjectSlugs = projectDirectoryNames(join(root, 'projects'))
538
+ const projects = normalizeProjectSeeds({
539
+ ...options,
540
+ projects: options?.projects?.length
541
+ ? options.projects
542
+ : (!options?.projectSlug && existingProjectSlugs.length > 0
543
+ ? existingProjectSlugs.map(slug => ({ slug }))
544
+ : undefined),
545
+ })
546
+ const serviceOrigin = options?.serviceOrigin || options?.baseUrl || null
547
+ const attachWorkspaceId = options?.attachWorkspaceId || null
548
+ const operations: WorkspaceOperation[] = []
549
+
550
+ pushCreateDir(operations, root, 'create workspace root')
551
+ for (const dir of WORKSPACE_ROOT_DIRS) {
552
+ pushCreateDir(operations, join(root, dir), `create root ${dir}/ directory`)
553
+ }
554
+ pushCreateDir(operations, join(root, WORKSPACE_DIR_NAME, 'generated'), 'create local generated cache directory')
555
+ pushCreateDir(operations, join(root, WORKSPACE_DIR_NAME, 'cache'), 'create local cache directory')
556
+
557
+ for (const project of projects) {
558
+ const projectRoot = join(root, 'projects', project.slug)
559
+ pushCreateDir(operations, projectRoot, `create ${project.slug} project directory`)
560
+ for (const dir of PROJECT_PRIMITIVE_DIRS) {
561
+ pushCreateDir(operations, join(projectRoot, dir), `create ${project.slug} ${dir}/ directory`)
562
+ }
563
+ }
564
+
565
+ pushCaseRename(operations, root, 'README.md', true)
566
+ pushCreateRootFile(operations, root, 'README.md', rootReadme(teamSlug), 'create team workbench README')
567
+ pushCreateFile(
568
+ operations,
569
+ join(root, 'orizu.team.json'),
570
+ createTeamManifest({ teamSlug, teamId: options?.teamId || null, serviceOrigin, attachWorkspaceId, cliVersion: options?.cliVersion || null }),
571
+ 'create team manifest'
572
+ )
573
+ pushCaseRename(operations, root, 'AGENTS.md', options?.fix)
574
+ pushCaseRename(operations, root, 'CLAUDE.md', options?.fix)
575
+ for (const variant of rootEntryCaseMatches(root, 'CLAUDE.md').nonCanonical) {
576
+ pushClaudeMigrationRepair(operations, root, variant, options)
577
+ }
578
+ pushExistingClaudeRepair(operations, root, options)
579
+ pushCreateRootFile(operations, root, 'AGENTS.md', AGENTS_MD, 'create canonical agent instructions')
580
+ pushCreateRootFile(operations, root, 'Memory.md', MEMORY_MD, 'create team memory file')
581
+
582
+ const claudePath = join(root, 'CLAUDE.md')
583
+ const claudeMatches = rootEntryCaseMatches(root, 'CLAUDE.md')
584
+ if (!claudeMatches.hasCanonical && claudeMatches.nonCanonical.length === 0) {
585
+ operations.push(options?.noSymlinks
586
+ ? operation('write_pointer_file', claudePath, 'create Claude pointer file', { content: CLAUDE_POINTER })
587
+ : operation('create_symlink', claudePath, 'link Claude instructions to AGENTS.md', { target: 'AGENTS.md' }))
588
+ }
589
+
590
+ pushGitignore(operations, join(root, '.gitignore'), ROOT_GITIGNORE_ENTRIES, 'seed root gitignore policy')
591
+ pushCreateFile(operations, join(root, WORKSPACE_DIR_NAME, 'README.md'), LOCAL_WORKSPACE_README, 'create local cache README')
592
+ for (const project of projects) {
593
+ const projectRoot = join(root, 'projects', project.slug)
594
+ pushCaseRename(operations, projectRoot, 'README.md', true)
595
+ pushCreateRootFile(operations, projectRoot, 'README.md', projectReadme(project.slug), 'create project README')
596
+ pushCreateFile(operations, join(projectRoot, 'memory.md'), PROJECT_MEMORY_MD, 'create project memory file')
597
+ pushCreateFile(operations, join(projectRoot, 'orizu.project.json'), createProjectManifest(teamSlug, project), 'create project manifest')
598
+ pushGitignore(operations, join(projectRoot, '.gitignore'), PROJECT_GITIGNORE_ENTRIES, 'seed project gitignore policy')
599
+ }
600
+
601
+ return {
602
+ root,
603
+ teamSlug,
604
+ projects,
605
+ operations,
606
+ findings: includeFindings ? validateWorkspaceContract({ ...options, workspaceRoot: root }) : [],
607
+ exists: workspaceExists(root),
608
+ }
609
+ }
72
610
 
73
- if (!exists) {
74
- actions.push(`create ${WORKSPACE_DIR_NAME}/workspace.json, README.md, and generated/`)
611
+ export function planWorkspaceSetup(options?: WorkspaceInitOptions): WorkspaceSetupPlan {
612
+ return buildSetupPlan(options)
613
+ }
614
+
615
+ function ensureParent(path: string) {
616
+ mkdirSync(dirname(path), { recursive: true })
617
+ }
618
+
619
+ function appendGitignore(path: string, entries: string[]) {
620
+ ensureParent(path)
621
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : ''
622
+ const terminator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''
623
+ const gap = existing.length > 0 ? '\n' : ''
624
+ const prefix = `${terminator}${gap}# Orizu workspace policy\n`
625
+ writeFileSync(path, `${existing}${prefix}${entries.join('\n')}\n`, 'utf8')
626
+ }
627
+
628
+ function applyOperation(op: WorkspaceOperation): boolean {
629
+ if (!op.safe) {
630
+ return false
75
631
  }
76
632
 
77
- const gitignore = ensureGitignoreEntry(cwd, dryRun || exists)
78
- if (!exists && gitignore.action) {
79
- actions.push(gitignore.action)
633
+ if (op.action === 'create_dir') {
634
+ mkdirSync(op.path, { recursive: true })
635
+ return true
80
636
  }
81
637
 
638
+ if (op.action === 'create_file' || op.action === 'write_pointer_file') {
639
+ if (pathExists(op.path)) {
640
+ return false
641
+ }
642
+ ensureParent(op.path)
643
+ writeFileSync(op.path, op.content || '', 'utf8')
644
+ return true
645
+ }
646
+
647
+ if (op.action === 'append_gitignore') {
648
+ appendGitignore(op.path, op.entries || [])
649
+ return true
650
+ }
651
+
652
+ if (op.action === 'create_symlink') {
653
+ if (pathExists(op.path)) {
654
+ return false
655
+ }
656
+ ensureParent(op.path)
657
+ try {
658
+ symlinkSync(op.target || 'AGENTS.md', op.path)
659
+ } catch {
660
+ writeFileSync(op.path, CLAUDE_POINTER, 'utf8')
661
+ }
662
+ return true
663
+ }
664
+
665
+ if (op.action === 'rename_file') {
666
+ if (!op.sourcePath || !pathExists(op.sourcePath)) {
667
+ return false
668
+ }
669
+ ensureParent(op.path)
670
+ const sameCaseFoldedPath = op.sourcePath.toLowerCase() === op.path.toLowerCase()
671
+ if (sameCaseFoldedPath) {
672
+ const tempPath = `${op.path}.orizu-rename-${Date.now()}`
673
+ renameSync(op.sourcePath, tempPath)
674
+ renameSync(tempPath, op.path)
675
+ } else {
676
+ if (pathExists(op.path)) {
677
+ return false
678
+ }
679
+ renameSync(op.sourcePath, op.path)
680
+ }
681
+ return true
682
+ }
683
+
684
+ if (op.action === 'replace_file') {
685
+ ensureParent(op.path)
686
+ if (pathExists(op.path)) {
687
+ unlinkSync(op.path)
688
+ }
689
+ writeFileSync(op.path, op.content || '', 'utf8')
690
+ return true
691
+ }
692
+
693
+ if (op.action === 'replace_symlink') {
694
+ ensureParent(op.path)
695
+ if (pathExists(op.path)) {
696
+ unlinkSync(op.path)
697
+ }
698
+ try {
699
+ symlinkSync(op.target || 'AGENTS.md', op.path)
700
+ } catch {
701
+ writeFileSync(op.path, CLAUDE_POINTER, 'utf8')
702
+ }
703
+ return true
704
+ }
705
+
706
+ return false
707
+ }
708
+
709
+ function relativePath(root: string, path: string): string {
710
+ const rel = relative(root, path)
711
+ return rel || '.'
712
+ }
713
+
714
+ function operationLabel(root: string, op: WorkspaceOperation): string {
715
+ const path = relativePath(root, op.path)
716
+ if (op.action === 'create_dir') return `create ${path}/`
717
+ if (op.action === 'create_file') return `create ${path}`
718
+ if (op.action === 'append_gitignore') return `append ${op.entries?.join(', ')} to ${path}`
719
+ if (op.action === 'create_symlink') return `link ${path} -> ${op.target}`
720
+ if (op.action === 'write_pointer_file') return `create ${path} pointer file`
721
+ if (op.action === 'rename_file') return `rename ${relativePath(root, op.sourcePath || '')} -> ${path}`
722
+ if (op.action === 'replace_file') return `repair ${path} pointer file`
723
+ if (op.action === 'replace_symlink') return `repair ${path} -> ${op.target}`
724
+ return `repair ${path}`
725
+ }
726
+
727
+ function finding(severity: WorkspaceFindingSeverity, code: string, message: string, path?: string, fixable = false): WorkspaceFinding {
728
+ return { severity, code, path, message, fixable }
729
+ }
730
+
731
+ function rootEntryNames(root: string): string[] {
732
+ try {
733
+ return readdirSync(root)
734
+ } catch {
735
+ return []
736
+ }
737
+ }
738
+
739
+ function checkCanonicalCase(root: string, canonical: string, findings: WorkspaceFinding[]) {
740
+ const matches = rootEntryNames(root).filter(name => name.toLowerCase() === canonical.toLowerCase())
741
+ const hasCanonical = matches.includes(canonical)
742
+ const nonCanonical = matches.filter(name => name !== canonical)
743
+ const canAutoRepair = !hasCanonical && nonCanonical.length <= 1
744
+
745
+ if (!hasCanonical) {
746
+ findings.push(finding('error', 'missing_required_file', `Missing required root file ${canonical}.`, join(root, canonical), canAutoRepair))
747
+ }
748
+ for (const variant of nonCanonical) {
749
+ findings.push(finding('warning', 'non_canonical_case', `${variant} should be named ${canonical}.`, join(root, variant), canAutoRepair))
750
+ }
751
+ if (hasCanonical && nonCanonical.length > 0) {
752
+ findings.push(finding('error', 'case_conflict', `Both ${canonical} and non-canonical variants exist. Resolve manually.`, join(root, canonical), false))
753
+ }
754
+ }
755
+
756
+ function checkProjectFileCanonicalCase(projectRoot: string, projectName: string, canonical: string, findings: WorkspaceFinding[], fixable: boolean) {
757
+ const matches = rootEntryNames(projectRoot).filter(name => name.toLowerCase() === canonical.toLowerCase())
758
+ const hasCanonical = matches.includes(canonical)
759
+ const nonCanonical = matches.filter(name => name !== canonical)
760
+
761
+ if (!hasCanonical) {
762
+ findings.push(finding('error', 'missing_project_file', `Missing project file projects/${projectName}/${canonical}.`, join(projectRoot, canonical), fixable))
763
+ }
764
+ for (const variant of nonCanonical) {
765
+ findings.push(finding('warning', 'non_canonical_case', `projects/${projectName}/${variant} should be named ${canonical}.`, join(projectRoot, variant), fixable))
766
+ }
767
+ if (hasCanonical && nonCanonical.length > 0) {
768
+ findings.push(finding('error', 'case_conflict', `Both projects/${projectName}/${canonical} and non-canonical variants exist. Resolve manually.`, join(projectRoot, canonical), false))
769
+ }
770
+ }
771
+
772
+ export function readJsonManifest(path: string): Record<string, unknown> | null {
773
+ try {
774
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
775
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
776
+ return null
777
+ }
778
+ return parsed as Record<string, unknown>
779
+ } catch {
780
+ return null
781
+ }
782
+ }
783
+
784
+ function validateManifest(path: string, findings: WorkspaceFinding[]) {
785
+ const parsed = readJsonManifest(path)
786
+ if (!parsed) {
787
+ findings.push(finding('error', 'invalid_manifest_json', 'Manifest must be valid JSON with an object root.', path, false))
788
+ return
789
+ }
790
+
791
+ if (parsed.schemaVersion !== WORKSPACE_SCHEMA_VERSION) {
792
+ findings.push(finding('error', 'invalid_schema_version', `Manifest schemaVersion must be ${WORKSPACE_SCHEMA_VERSION}.`, path, false))
793
+ }
794
+ if (typeof parsed.kind !== 'string' || parsed.kind.length === 0) {
795
+ findings.push(finding('error', 'missing_manifest_kind', 'Manifest must include kind.', path, false))
796
+ }
797
+ if (typeof parsed.slug !== 'string' && typeof parsed.runId !== 'string' && typeof parsed.sessionId !== 'string') {
798
+ findings.push(finding('error', 'missing_manifest_identity', 'Manifest must include slug, runId, or sessionId.', path, false))
799
+ }
800
+
801
+ const canonical = parsed.canonical
802
+ if (!canonical || typeof canonical !== 'object' || Array.isArray(canonical)) {
803
+ findings.push(finding('error', 'missing_canonical', 'Manifest must include canonical owner and repoState.', path, false))
804
+ return
805
+ }
806
+
807
+ const canonicalRecord = canonical as Record<string, unknown>
808
+ if (!hasAllowedValue(CANONICAL_OWNERS, canonicalRecord.owner)) {
809
+ findings.push(finding('error', 'invalid_canonical_owner', `canonical.owner must be one of ${CANONICAL_OWNERS.join(', ')}.`, path, false))
810
+ }
811
+ if (!hasAllowedValue(REPO_STATES, canonicalRecord.repoState)) {
812
+ findings.push(finding('error', 'invalid_repo_state', `canonical.repoState must be one of ${REPO_STATES.join(', ')}.`, path, false))
813
+ }
814
+
815
+ if (parsed.kind === 'team') {
816
+ const setup = parsed.setup
817
+ if (!setup || typeof setup !== 'object' || Array.isArray(setup)) {
818
+ findings.push(finding('warning', 'missing_setup_metadata', 'Team manifest should include setup metadata.', path, false))
819
+ }
820
+ }
821
+ }
822
+
823
+ function collectManagedWorkspaceManifestPaths(root: string): string[] {
824
+ const paths: string[] = []
825
+ const ignored = new Set(['.git', 'node_modules', '.next', 'dist', 'build', WORKSPACE_DIR_NAME])
826
+ const rootManifest = join(root, 'orizu.team.json')
827
+ if (existsSync(rootManifest)) {
828
+ paths.push(rootManifest)
829
+ }
830
+
831
+ function walk(dir: string, depth: number) {
832
+ if (depth > 8 || !existsSync(dir)) {
833
+ return
834
+ }
835
+
836
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
837
+ if (ignored.has(entry.name)) {
838
+ continue
839
+ }
840
+ const path = join(dir, entry.name)
841
+ if (entry.isDirectory()) {
842
+ walk(path, depth + 1)
843
+ } else if (/^orizu\..+\.json$/.test(entry.name)) {
844
+ paths.push(path)
845
+ }
846
+ }
847
+ }
848
+
849
+ const projectsRoot = join(root, 'projects')
850
+ for (const projectName of projectDirectoryNames(projectsRoot)) {
851
+ const projectRoot = join(projectsRoot, projectName)
852
+ const projectManifest = join(projectRoot, 'orizu.project.json')
853
+ if (existsSync(projectManifest)) {
854
+ paths.push(projectManifest)
855
+ }
856
+
857
+ for (const primitiveDir of PROJECT_PRIMITIVE_DIRS) {
858
+ walk(join(projectRoot, primitiveDir), 0)
859
+ }
860
+ }
861
+
862
+ return paths
863
+ }
864
+
865
+ function collectNamedPaths(root: string, name: string): string[] {
866
+ const paths: string[] = []
867
+ const ignored = new Set(['.git', 'node_modules', '.next', 'dist', 'build', WORKSPACE_DIR_NAME])
868
+
869
+ function walk(dir: string, depth: number) {
870
+ if (depth > 8 || !existsSync(dir)) {
871
+ return
872
+ }
873
+
874
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
875
+ if (ignored.has(entry.name)) {
876
+ continue
877
+ }
878
+ const path = join(dir, entry.name)
879
+ if (entry.name === name) {
880
+ paths.push(path)
881
+ }
882
+ if (entry.isDirectory()) {
883
+ walk(path, depth + 1)
884
+ }
885
+ }
886
+ }
887
+
888
+ walk(root, 0)
889
+ return paths
890
+ }
891
+
892
+ function validateClaudeFile(root: string, noSymlinks: boolean, findings: WorkspaceFinding[]) {
893
+ const path = join(root, 'CLAUDE.md')
894
+ const stat = lstatIfExists(path)
895
+ if (!stat) {
896
+ return
897
+ }
898
+
899
+ if (stat.isSymbolicLink()) {
900
+ const target = readlinkSync(path)
901
+ if (target !== 'AGENTS.md') {
902
+ findings.push(finding('error', 'invalid_claude_symlink', 'CLAUDE.md must symlink to AGENTS.md.', path, true))
903
+ }
904
+ if (noSymlinks) {
905
+ findings.push(finding('warning', 'unexpected_claude_symlink', 'CLAUDE.md is a symlink but --no-symlinks expects a pointer file.', path, true))
906
+ }
907
+ return
908
+ }
909
+
910
+ const content = readFileSync(path, 'utf8')
911
+ if (!content.includes('AGENTS.md')) {
912
+ findings.push(finding('error', 'invalid_claude_pointer', 'CLAUDE.md must point readers to AGENTS.md when it is not a symlink.', path, false))
913
+ }
914
+ }
915
+
916
+ function validateAgentsFile(root: string, findings: WorkspaceFinding[]) {
917
+ const path = join(root, 'AGENTS.md')
918
+ if (!existsSync(path)) {
919
+ return
920
+ }
921
+
922
+ const content = readFileSync(path, 'utf8')
923
+ const hasSkillBlock = content.includes(ORIZU_CLI_AGENTS_START_MARKER) &&
924
+ content.includes(ORIZU_CLI_AGENTS_END_MARKER)
925
+ if (hasSkillBlock) {
926
+ findings.push(finding(
927
+ 'error',
928
+ 'embedded_skill_block',
929
+ 'AGENTS.md contains the full Orizu CLI skill block. Keep root AGENTS.md concise and move detailed CLI guidance to installed skills or `orizu --help`.',
930
+ path,
931
+ false
932
+ ))
933
+ }
934
+ }
935
+
936
+ function projectDirectoryNames(projectsRoot: string): string[] {
937
+ try {
938
+ return readdirSync(projectsRoot, { withFileTypes: true })
939
+ .filter(entry => entry.isDirectory())
940
+ .map(entry => entry.name)
941
+ .sort()
942
+ } catch {
943
+ return []
944
+ }
945
+ }
946
+
947
+ function validateProjectContract(root: string, options: WorkspaceInitOptions | undefined, findings: WorkspaceFinding[]) {
948
+ const projectsRoot = join(root, 'projects')
949
+ if (!existsSync(projectsRoot)) {
950
+ return
951
+ }
952
+
953
+ const expectedProjects = options?.projects?.length
954
+ ? options.projects.map(project => normalizeSlug(project.slug, 'local-project'))
955
+ : (options?.projectSlug ? [normalizeSlug(options.projectSlug, 'local-project')] : null)
956
+ const projectNames = expectedProjects || projectDirectoryNames(projectsRoot)
957
+ const canRepairProject = Boolean(expectedProjects?.length)
958
+
959
+ if (projectNames.length === 0) {
960
+ findings.push(finding(
961
+ 'error',
962
+ 'missing_project_directory',
963
+ 'Workspace must include at least one project directory under projects/.',
964
+ projectsRoot,
965
+ canRepairProject
966
+ ))
967
+ return
968
+ }
969
+
970
+ for (const name of projectNames) {
971
+ const projectRoot = join(projectsRoot, name)
972
+ if (!existsSync(projectRoot)) {
973
+ findings.push(finding('error', 'missing_project_directory', `Missing project directory projects/${name}/.`, projectRoot, canRepairProject))
974
+ continue
975
+ }
976
+
977
+ for (const file of PROJECT_ROOT_FILES) {
978
+ if (/^[A-Z]/.test(file)) {
979
+ checkProjectFileCanonicalCase(projectRoot, name, file, findings, canRepairProject)
980
+ } else {
981
+ const path = join(projectRoot, file)
982
+ if (!existsSync(path)) {
983
+ findings.push(finding('error', 'missing_project_file', `Missing project file projects/${name}/${file}.`, path, canRepairProject))
984
+ }
985
+ }
986
+ }
987
+
988
+ for (const dir of PROJECT_PRIMITIVE_DIRS) {
989
+ const path = join(projectRoot, dir)
990
+ if (!existsSync(path)) {
991
+ findings.push(finding('error', 'missing_project_dir', `Missing project directory projects/${name}/${dir}/.`, path, canRepairProject))
992
+ }
993
+ }
994
+
995
+ const projectGitignore = join(projectRoot, '.gitignore')
996
+ if (existsSync(projectGitignore) && missingGitignoreEntries(projectGitignore, [`${WORKSPACE_DIR_NAME}/`]).length > 0) {
997
+ findings.push(finding('error', 'missing_project_orizu_gitignore', `.orizu/ must be ignored by default in projects/${name}/.`, projectGitignore, canRepairProject))
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ export function validateWorkspaceContract(options?: WorkspaceInitOptions): WorkspaceFinding[] {
1003
+ const root = resolveWorkspaceRoot(options)
1004
+ const findings: WorkspaceFinding[] = []
1005
+
1006
+ if (!existsSync(root)) {
1007
+ findings.push(finding('error', 'missing_workspace_root', 'Workspace root does not exist.', root, true))
1008
+ return findings
1009
+ }
1010
+
1011
+ for (const file of WORKSPACE_ROOT_FILES) {
1012
+ if (/^[A-Z]/.test(file)) {
1013
+ checkCanonicalCase(root, file, findings)
1014
+ } else {
1015
+ const path = join(root, file)
1016
+ if (!existsSync(path)) {
1017
+ findings.push(finding('error', 'missing_required_file', `Missing required root file ${file}.`, path, true))
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ for (const dir of WORKSPACE_ROOT_DIRS) {
1023
+ const path = join(root, dir)
1024
+ if (!existsSync(path)) {
1025
+ findings.push(finding('error', 'missing_required_dir', `Missing required root directory ${dir}/.`, path, true))
1026
+ }
1027
+ }
1028
+
1029
+ const rootGitignore = join(root, '.gitignore')
1030
+ if (existsSync(rootGitignore) && missingGitignoreEntries(rootGitignore, [`${WORKSPACE_DIR_NAME}/`]).length > 0) {
1031
+ findings.push(finding('error', 'missing_orizu_gitignore', '.orizu/ must be ignored by default.', rootGitignore, true))
1032
+ }
1033
+
1034
+ validateClaudeFile(root, Boolean(options?.noSymlinks), findings)
1035
+ validateAgentsFile(root, findings)
1036
+ validateProjectContract(root, options, findings)
1037
+
1038
+ for (const path of collectManagedWorkspaceManifestPaths(root)) {
1039
+ validateManifest(path, findings)
1040
+ }
1041
+
1042
+ for (const path of collectNamedPaths(root, '.gitadd')) {
1043
+ findings.push(finding('error', 'gitadd_not_supported', '.gitadd is not part of the Orizu workspace contract.', path, false))
1044
+ }
1045
+
1046
+ return findings
1047
+ }
1048
+
1049
+ export function initOrizuWorkspace(options?: WorkspaceInitOptions): WorkspaceInitResult {
1050
+ const dryRun = Boolean(options?.dryRun)
1051
+ const validateOnly = Boolean(options?.validateOnly)
1052
+ const plan = buildSetupPlan(options, dryRun || validateOnly)
1053
+ const beforeExists = plan.exists
1054
+
82
1055
  if (dryRun) {
83
1056
  return {
84
- root,
85
- state: exists ? 'exists' : 'would-create',
1057
+ root: plan.root,
1058
+ state: beforeExists ? 'exists' : 'would-create',
86
1059
  gitignoreUpdated: false,
87
- actions,
1060
+ actions: plan.operations.map(op => operationLabel(plan.root, op)),
1061
+ operations: plan.operations,
1062
+ findings: plan.findings,
88
1063
  }
89
1064
  }
90
1065
 
91
- if (exists) {
92
- return { root, state: 'exists', gitignoreUpdated: false, actions: [] }
1066
+ if (validateOnly) {
1067
+ const hasErrors = plan.findings.some(item => item.severity === 'error')
1068
+ return {
1069
+ root: plan.root,
1070
+ state: hasErrors ? 'invalid' : 'validated',
1071
+ gitignoreUpdated: false,
1072
+ actions: [],
1073
+ operations: [],
1074
+ findings: plan.findings,
1075
+ }
93
1076
  }
94
1077
 
95
- mkdirSync(join(root, 'generated'), { recursive: true })
96
- writeFileSync(join(root, 'README.md'), WORKSPACE_README, 'utf8')
97
- writeFileSync(
98
- join(root, 'workspace.json'),
99
- `${JSON.stringify(
100
- {
101
- version: 1,
102
- server: options?.baseUrl || null,
103
- createdByCliVersion: options?.cliVersion || null,
104
- },
105
- null,
106
- 2
107
- )}\n`,
108
- 'utf8'
109
- )
1078
+ let applied = 0
1079
+ let gitignoreUpdated = false
1080
+ for (const op of plan.operations) {
1081
+ const didApply = applyOperation(op)
1082
+ if (didApply) {
1083
+ applied += 1
1084
+ if (op.action === 'append_gitignore') {
1085
+ gitignoreUpdated = true
1086
+ }
1087
+ }
1088
+ }
110
1089
 
111
- return { root, state: 'created', gitignoreUpdated: gitignore.updated, actions }
1090
+ const findings = validateWorkspaceContract({ ...options, workspaceRoot: plan.root })
1091
+ return {
1092
+ root: plan.root,
1093
+ state: beforeExists ? (applied > 0 ? 'repaired' : 'exists') : 'created',
1094
+ gitignoreUpdated,
1095
+ actions: plan.operations.map(op => operationLabel(plan.root, op)),
1096
+ operations: plan.operations,
1097
+ findings,
1098
+ }
112
1099
  }