orizu 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +248 -0
- package/dist/help.js +271 -16
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +855 -237
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +39 -2
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +396 -0
- package/src/help.ts +272 -16
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +987 -296
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +70 -2
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +3 -1
- package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
package/src/workspace.ts
CHANGED
|
@@ -1,112 +1,1099 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
|
|
142
|
+
function hasAllowedValue(values: readonly string[], value: unknown): value is string {
|
|
143
|
+
return typeof value === 'string' && values.includes(value)
|
|
144
|
+
}
|
|
21
145
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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()
|
|
181
|
+
return resolve(cwd || process.cwd())
|
|
35
182
|
}
|
|
36
183
|
|
|
37
184
|
export function workspaceExists(cwd?: string): boolean {
|
|
38
|
-
return existsSync(join(getWorkspaceRoot(cwd), '
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
504
|
+
function missingGitignoreEntries(path: string, entries: string[]): string[] {
|
|
505
|
+
if (!existsSync(path)) {
|
|
506
|
+
return entries
|
|
62
507
|
}
|
|
63
|
-
|
|
508
|
+
|
|
509
|
+
const content = readFileSync(path, 'utf8')
|
|
510
|
+
return entries.filter(entry => !gitignoreHasEntry(content, entry))
|
|
64
511
|
}
|
|
65
512
|
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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:
|
|
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 (
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
}
|