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/README.md CHANGED
@@ -35,23 +35,24 @@ Install the CLI globally with your package manager:
35
35
  | pnpm | `pnpm add -g orizu` |
36
36
  | Bun | `bun add -g orizu` |
37
37
 
38
- Then run the guided setup — it signs you in, sets up your coding agents,
39
- optionally creates a gitignored `.orizu/` workspace, and hands your agent a
40
- prompt to plan repo-specific Orizu adoption:
38
+ Then run the guided setup — it signs you in, initializes the local workspace
39
+ contract, installs global coding-agent skill symlinks, and shows the next
40
+ command for repo-specific Orizu adoption:
41
41
 
42
42
  ```bash
43
43
  orizu setup
44
44
  ```
45
45
 
46
- Or install just the companion coding-agent skill:
46
+ To repair or customize skill installs later, run the companion skill installer
47
+ directly:
47
48
 
48
49
  ```bash
49
50
  orizu install-skill --agent claude --agent codex --yes
50
51
  ```
51
52
 
52
- First-class plugin packages for Codex and Claude Code live under `plugins/`
53
- in this repository. Plugins bundle the same skill; the CLI remains the runtime
54
- either way.
53
+ Experimental plugin packages for Codex and Claude Code live under `plugins/`
54
+ in this repository. The CLI-installed skill remains the default onboarding path
55
+ for now; the CLI is the runtime either way.
55
56
 
56
57
  ```bash
57
58
  # Codex
@@ -63,11 +64,11 @@ codex plugin add orizu@orizu-plugins
63
64
  /plugin install orizu@orizu
64
65
  ```
65
66
 
66
- The npm package ships the skill alongside the CLI. Run `orizu install-skill` in
67
- a terminal for an interactive chooser (Claude Code and Codex preselected,
68
- user-level installs by default), or use `orizu install-skill --help` for
69
- project-scope installs, sync modes, and advanced targets including the managed
70
- `AGENTS.md` section. Keep installs in sync after CLI upgrades with
67
+ The npm package ships the skill alongside the CLI. Run
68
+ `orizu install-skill --agent claude --agent codex --yes` for an explicit global
69
+ install, or use `orizu install-skill --help` for project-scope installs, sync
70
+ modes, and advanced targets including the managed `AGENTS.md` section for
71
+ non-workspace repos. Keep installs in sync after CLI upgrades with
71
72
  `orizu skills status` and `orizu skills update`.
72
73
 
73
74
  Coding agents can also read the bundled skill directly without installing it:
@@ -121,7 +122,7 @@ orizu logout
121
122
  </tr>
122
123
  <tr>
123
124
  <td>Install the bundled agent skill</td>
124
- <td><code>orizu install-skill --target agent-user --yes</code></td>
125
+ <td><code>orizu install-skill --target codex-user --yes</code></td>
125
126
  </tr>
126
127
  <tr>
127
128
  <td>Locate the bundled skill (read-only)</td>
@@ -274,6 +275,14 @@ orizu logout
274
275
  <td>Publish a draft task</td>
275
276
  <td><code>orizu tasks publish --task &lt;taskId&gt; --assignees &lt;userId1,userId2&gt;</code></td>
276
277
  </tr>
278
+ <tr>
279
+ <td>Update a draft task</td>
280
+ <td><code>orizu tasks update --task &lt;taskId&gt; --title "Support QA Round 1b" --labels-per-item 2</code></td>
281
+ </tr>
282
+ <tr>
283
+ <td>Discard a draft task</td>
284
+ <td><code>orizu tasks discard --task &lt;taskId&gt; --yes</code></td>
285
+ </tr>
277
286
  <tr>
278
287
  <td>Check task status</td>
279
288
  <td><code>orizu tasks status --task &lt;taskId&gt;</code></td>
@@ -0,0 +1,152 @@
1
+ import { createHash } from 'crypto';
2
+ import { readdirSync, readFileSync, statSync } from 'fs';
3
+ import { join } from 'path';
4
+ const ARTIFACT_EXCLUDED_PATH_NAMES = new Set([
5
+ '.git',
6
+ '.DS_Store',
7
+ '__pycache__',
8
+ '.pytest_cache',
9
+ '.orizu',
10
+ ]);
11
+ function isNodeError(error) {
12
+ return error instanceof Error;
13
+ }
14
+ function expandHomePath(path) {
15
+ if (path.startsWith('~/')) {
16
+ const home = process.env.HOME || '';
17
+ return `${home}/${path.slice(2)}`;
18
+ }
19
+ return path;
20
+ }
21
+ /**
22
+ * Archives must be byte-identical for identical logical content across machines
23
+ * because content-addressed deduplication depends on deterministic inputs.
24
+ *
25
+ * Tradeoff: runner artifacts are downloaded, unzipped, and executed directly
26
+ * (there is no install step), so vendored dependency trees such as
27
+ * node_modules, .venv/venv, and build outputs are legitimate runtime payload
28
+ * and are deliberately NOT excluded — hash reproducibility therefore depends
29
+ * on the author shipping identical vendored trees across machines. Only true
30
+ * junk (.git, .DS_Store, __pycache__, .pytest_cache, .orizu) and secrets
31
+ * (.env, .env.*) are stripped: secrets must never ship, because runners
32
+ * receive their configuration at exec time.
33
+ */
34
+ export function shouldExcludeArtifactPath(relativePath) {
35
+ const parts = relativePath.split('/');
36
+ return parts.some(part => ARTIFACT_EXCLUDED_PATH_NAMES.has(part) ||
37
+ part === '.env' ||
38
+ part.startsWith('.env.'));
39
+ }
40
+ export function collectArtifactFiles(sourceDir, relativeDir = '') {
41
+ const absoluteDir = relativeDir ? join(sourceDir, relativeDir) : sourceDir;
42
+ return readdirSync(absoluteDir, { withFileTypes: true })
43
+ .flatMap(entry => {
44
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
45
+ if (shouldExcludeArtifactPath(relativePath)) {
46
+ return [];
47
+ }
48
+ if (entry.isDirectory()) {
49
+ return collectArtifactFiles(sourceDir, relativePath);
50
+ }
51
+ return entry.isFile() ? [relativePath] : [];
52
+ })
53
+ .sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
54
+ }
55
+ export const ZIP_GENERAL_PURPOSE_UTF8 = 0x0800;
56
+ export const ZIP_DOS_TIME_2000_01_01 = 0;
57
+ export const ZIP_DOS_DATE_2000_01_01 = ((2000 - 1980) << 9) | (1 << 5) | 1;
58
+ const CRC32_TABLE = Array.from({ length: 256 }, (_, index) => {
59
+ let value = index;
60
+ for (let bit = 0; bit < 8; bit++) {
61
+ value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
62
+ }
63
+ return value >>> 0;
64
+ });
65
+ function crc32(bytes) {
66
+ let crc = 0xffffffff;
67
+ for (const byte of bytes) {
68
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
69
+ }
70
+ return (crc ^ 0xffffffff) >>> 0;
71
+ }
72
+ export function createStoredZip(entries) {
73
+ const localParts = [];
74
+ const centralParts = [];
75
+ let offset = 0;
76
+ for (const entry of entries) {
77
+ const name = Buffer.from(entry.path, 'utf8');
78
+ const crc = crc32(entry.data);
79
+ const localHeader = Buffer.alloc(30);
80
+ localHeader.writeUInt32LE(0x04034b50, 0);
81
+ localHeader.writeUInt16LE(10, 4);
82
+ localHeader.writeUInt16LE(ZIP_GENERAL_PURPOSE_UTF8, 6);
83
+ localHeader.writeUInt16LE(0, 8);
84
+ localHeader.writeUInt16LE(ZIP_DOS_TIME_2000_01_01, 10);
85
+ localHeader.writeUInt16LE(ZIP_DOS_DATE_2000_01_01, 12);
86
+ localHeader.writeUInt32LE(crc, 14);
87
+ localHeader.writeUInt32LE(entry.data.length, 18);
88
+ localHeader.writeUInt32LE(entry.data.length, 22);
89
+ localHeader.writeUInt16LE(name.length, 26);
90
+ localHeader.writeUInt16LE(0, 28);
91
+ localParts.push(localHeader, name, entry.data);
92
+ const centralHeader = Buffer.alloc(46);
93
+ centralHeader.writeUInt32LE(0x02014b50, 0);
94
+ centralHeader.writeUInt16LE(20, 4);
95
+ centralHeader.writeUInt16LE(10, 6);
96
+ centralHeader.writeUInt16LE(ZIP_GENERAL_PURPOSE_UTF8, 8);
97
+ centralHeader.writeUInt16LE(0, 10);
98
+ centralHeader.writeUInt16LE(ZIP_DOS_TIME_2000_01_01, 12);
99
+ centralHeader.writeUInt16LE(ZIP_DOS_DATE_2000_01_01, 14);
100
+ centralHeader.writeUInt32LE(crc, 16);
101
+ centralHeader.writeUInt32LE(entry.data.length, 20);
102
+ centralHeader.writeUInt32LE(entry.data.length, 24);
103
+ centralHeader.writeUInt16LE(name.length, 28);
104
+ centralHeader.writeUInt16LE(0, 30);
105
+ centralHeader.writeUInt16LE(0, 32);
106
+ centralHeader.writeUInt16LE(0, 34);
107
+ centralHeader.writeUInt16LE(0, 36);
108
+ centralHeader.writeUInt32LE(0, 38);
109
+ centralHeader.writeUInt32LE(offset, 42);
110
+ centralParts.push(centralHeader, name);
111
+ offset += localHeader.length + name.length + entry.data.length;
112
+ }
113
+ const centralDirectoryOffset = offset;
114
+ const centralDirectory = Buffer.concat(centralParts);
115
+ const endOfCentralDirectory = Buffer.alloc(22);
116
+ endOfCentralDirectory.writeUInt32LE(0x06054b50, 0);
117
+ endOfCentralDirectory.writeUInt16LE(0, 4);
118
+ endOfCentralDirectory.writeUInt16LE(0, 6);
119
+ endOfCentralDirectory.writeUInt16LE(entries.length, 8);
120
+ endOfCentralDirectory.writeUInt16LE(entries.length, 10);
121
+ endOfCentralDirectory.writeUInt32LE(centralDirectory.length, 12);
122
+ endOfCentralDirectory.writeUInt32LE(centralDirectoryOffset, 16);
123
+ endOfCentralDirectory.writeUInt16LE(0, 20);
124
+ return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory]);
125
+ }
126
+ export function zipDirectoryToBase64(dirArg) {
127
+ const sourceDir = expandHomePath(dirArg);
128
+ try {
129
+ const stats = statSync(sourceDir);
130
+ if (!stats.isDirectory()) {
131
+ throw new Error(`${sourceDir} is not a directory`);
132
+ }
133
+ }
134
+ catch (error) {
135
+ if (isNodeError(error) && error.code === 'ENOENT') {
136
+ throw new Error(`Directory not found: ${sourceDir}`);
137
+ }
138
+ throw error;
139
+ }
140
+ const files = collectArtifactFiles(sourceDir);
141
+ if (files.length === 0) {
142
+ throw new Error(`Directory contains no artifact files: ${sourceDir}`);
143
+ }
144
+ const bytes = createStoredZip(files.map(relativePath => ({
145
+ path: relativePath,
146
+ data: readFileSync(join(sourceDir, relativePath)),
147
+ })));
148
+ return {
149
+ zipBase64: bytes.toString('base64'),
150
+ contentSha256: createHash('sha256').update(bytes).digest('hex'),
151
+ };
152
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Connector readiness CLI (ALI-903). Thin, read-only consumer of the
3
+ * project-scoped `/api/cli/connectors` endpoint. Pure command logic with an
4
+ * injected fetcher and project resolver; the entry point owns argument parsing
5
+ * and human/JSON output. Secret material is never printed (the endpoint already
6
+ * redacts config and surfaces credentials only as a boolean).
7
+ */
8
+ import { authedFetch } from './http.js';
9
+ async function responseMessage(response) {
10
+ try {
11
+ const data = (await response.clone().json());
12
+ const error = typeof data.error === 'string' ? data.error : null;
13
+ const message = typeof data.message === 'string' ? data.message : null;
14
+ return error || message || response.statusText || String(response.status);
15
+ }
16
+ catch {
17
+ return response.statusText || String(response.status);
18
+ }
19
+ }
20
+ export async function fetchConnectors(opts) {
21
+ const fetcher = opts.fetcher ?? authedFetch;
22
+ const response = await fetcher(`/api/cli/connectors?project=${encodeURIComponent(opts.projectSlug)}`);
23
+ if (!response.ok) {
24
+ throw new Error(`Connector status failed (${response.status}): ${await responseMessage(response)}`);
25
+ }
26
+ const data = (await response.json());
27
+ return { connectors: Array.isArray(data.connectors) ? data.connectors : [] };
28
+ }
29
+ function argValue(args, flag) {
30
+ const index = args.indexOf(flag);
31
+ if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
32
+ return null;
33
+ }
34
+ return args[index + 1];
35
+ }
36
+ // Positional tokens only: skips flags AND their values, so
37
+ // `orizu connectors --project team/slug` never misreads the value.
38
+ function positionalArgs(args) {
39
+ const values = [];
40
+ for (let i = 0; i < args.length; i += 1) {
41
+ const arg = args[i];
42
+ if (arg.startsWith('--')) {
43
+ if (argValue(args, arg) !== null) {
44
+ i += 1;
45
+ }
46
+ continue;
47
+ }
48
+ values.push(arg);
49
+ }
50
+ return values;
51
+ }
52
+ function formatConnector(connector) {
53
+ const creds = connector.hasCredentials ? 'credentials:yes' : 'credentials:no';
54
+ return `${connector.type} ${connector.status} ${creds}`;
55
+ }
56
+ export async function connectorsCommand(args, io) {
57
+ const subcommand = positionalArgs(args)[0];
58
+ if (subcommand && subcommand !== 'status') {
59
+ io.print('Usage: orizu connectors [status] [--project <team/project>] [--json]');
60
+ return 1;
61
+ }
62
+ const resolveProjectSlug = io.resolveProjectSlug;
63
+ if (!resolveProjectSlug) {
64
+ throw new Error('Project resolver unavailable');
65
+ }
66
+ const projectSlug = await resolveProjectSlug(argValue(args, '--project'));
67
+ const result = await fetchConnectors({ fetcher: io.fetcher, projectSlug });
68
+ if (io.json) {
69
+ io.print(JSON.stringify(result));
70
+ return 0;
71
+ }
72
+ if (result.connectors.length === 0) {
73
+ io.print('No connectors known for this project.');
74
+ return 0;
75
+ }
76
+ for (const connector of result.connectors) {
77
+ io.print(formatConnector(connector));
78
+ }
79
+ return 0;
80
+ }
@@ -2,6 +2,9 @@ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readF
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { randomBytes } from 'crypto';
5
+ function isNodeError(error) {
6
+ return error instanceof Error;
7
+ }
5
8
  function getConfigDir() {
6
9
  if (process.env.ORIZU_CONFIG_DIR) {
7
10
  return process.env.ORIZU_CONFIG_DIR;
@@ -68,7 +71,7 @@ function writeCredentials(config) {
68
71
  renameSync(tempPath, path);
69
72
  }
70
73
  catch (error) {
71
- if (process.platform === 'win32' && error?.code === 'EEXIST') {
74
+ if (process.platform === 'win32' && isNodeError(error) && error.code === 'EEXIST') {
72
75
  rmSync(path, { force: true });
73
76
  renameSync(tempPath, path);
74
77
  }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Self-contained rehearsal harness for the Daytona slice (ALI-973 / WS-F).
3
+ *
4
+ * Extends the smoke test's in-memory fake-server technique to the WS-F broker /
5
+ * session-branch / finish-branch / apply contracts, and backs it with a REAL
6
+ * local bare git repo so the whole flow (clone → commit → push → merge) runs
7
+ * against genuine git. Consumed by BOTH the unit test and the local-sim
8
+ * rehearsal in `scripts/daytona-workbench-slice.mjs`, so the two never drift.
9
+ *
10
+ * This is the piece that makes `--provider local-sim` a rehearsal, not a mock:
11
+ * `finish-branch` and `apply` shell out to git on the bare repo (rev-list /
12
+ * update-ref), so a session branch really is merged into `main`.
13
+ */
14
+ import { spawnSync } from 'child_process';
15
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
16
+ import { tmpdir } from 'os';
17
+ import { join } from 'path';
18
+ import { WORKSPACE_SCHEMA_VERSION } from './workspace.js';
19
+ import { createLocalSimProvider } from './sandbox-provider.js';
20
+ function git(args, cwd) {
21
+ // Strip git's repo-context env vars before spawning. When this code runs
22
+ // inside a git hook (e.g. the pre-push hook running `bun test`), git sets
23
+ // GIT_DIR in the environment — and `git init --bare <dir>` with GIT_DIR set
24
+ // REINITIALIZES the repo GIT_DIR points at as bare (core.bare=true in the
25
+ // shared config), breaking every linked worktree. Scope every child git
26
+ // strictly to its cwd/args instead.
27
+ const env = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' };
28
+ delete env.GIT_DIR;
29
+ delete env.GIT_WORK_TREE;
30
+ delete env.GIT_INDEX_FILE;
31
+ delete env.GIT_OBJECT_DIRECTORY;
32
+ delete env.GIT_COMMON_DIR;
33
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8', env });
34
+ return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', status: res.status ?? 1 };
35
+ }
36
+ function gitOrThrow(args, cwd) {
37
+ const r = git(args, cwd);
38
+ if (r.status !== 0) {
39
+ throw new Error(`git ${args.join(' ')} failed: ${r.stderr.trim() || `exit ${r.status}`}`);
40
+ }
41
+ return r.stdout;
42
+ }
43
+ /**
44
+ * Create a bare git repo seeded with the Phase-1 workbench contract (matching
45
+ * `lib/workbench-repo-contract.ts`) on a `main` branch: root manifests, a
46
+ * `.gitignore` that ignores `.orizu/`, and a project stub.
47
+ */
48
+ export function seedLocalBareRepo(baseDir, config) {
49
+ const gitDir = join(baseDir, 'workbench.git');
50
+ gitOrThrow(['init', '--bare', '--initial-branch=main', gitDir]);
51
+ const work = mkdtempSync(join(baseDir, 'seed-'));
52
+ gitOrThrow(['init', '--initial-branch=main', work]);
53
+ const ident = ['-c', 'user.email=seed@orizu.local', '-c', 'user.name=Seed'];
54
+ const teamManifest = {
55
+ schemaVersion: WORKSPACE_SCHEMA_VERSION,
56
+ kind: 'team',
57
+ slug: config.teamSlug,
58
+ canonical: { owner: 'repo', repoState: 'source', objectRef: null },
59
+ };
60
+ const projectManifest = {
61
+ schemaVersion: WORKSPACE_SCHEMA_VERSION,
62
+ kind: 'project',
63
+ slug: config.projectSlug,
64
+ canonical: { owner: 'repo', repoState: 'draft', objectRef: null },
65
+ };
66
+ writeFileSync(join(work, 'orizu.team.json'), `${JSON.stringify(teamManifest, null, 2)}\n`);
67
+ writeFileSync(join(work, '.gitignore'), '# Orizu workspace policy\n.orizu/\n');
68
+ writeFileSync(join(work, 'AGENTS.md'), '# Workbench\n');
69
+ const projectDir = join(work, 'projects', config.projectSlug);
70
+ mkdirSync(projectDir, { recursive: true });
71
+ writeFileSync(join(projectDir, 'orizu.project.json'), `${JSON.stringify(projectManifest, null, 2)}\n`);
72
+ writeFileSync(join(projectDir, '.gitkeep'), '');
73
+ gitOrThrow([...ident, 'add', '-A'], work);
74
+ gitOrThrow([...ident, 'commit', '-m', 'seed workbench contract'], work);
75
+ gitOrThrow(['remote', 'add', 'origin', gitDir], work);
76
+ gitOrThrow(['push', 'origin', 'main'], work);
77
+ rmSync(work, { recursive: true, force: true });
78
+ return { gitDir, repoFullName: `orizu-sim/orizu-workbench-${config.teamSlug}`, defaultBranch: 'main' };
79
+ }
80
+ function jsonResponse(body, status = 200) {
81
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
82
+ }
83
+ /**
84
+ * In-memory WS-F control plane over a real bare repo. Session-start cuts a real
85
+ * branch; finish-branch diffs it; apply fast-forwards `main`. Tracks token mints
86
+ * so revocation is observable and asserts tokens never leak into stored state.
87
+ */
88
+ export class FakeWorkbenchApi {
89
+ repo;
90
+ workspaces = new Map();
91
+ sessions = new Map();
92
+ mints = new Map();
93
+ manifests = new Map();
94
+ tokensIssued = [];
95
+ counter = 0;
96
+ constructor(repo) {
97
+ this.repo = repo;
98
+ }
99
+ id(prefix) {
100
+ this.counter += 1;
101
+ return `${prefix}-${this.counter}`;
102
+ }
103
+ branchHead(branch) {
104
+ return gitOrThrow(['--git-dir', this.repo.gitDir, 'rev-parse', branch]).trim();
105
+ }
106
+ newFetcher() {
107
+ return async (path, init = {}) => {
108
+ const method = (init.method || 'GET').toUpperCase();
109
+ const [rawPath] = path.split('?');
110
+ const body = typeof init.body === 'string' ? JSON.parse(init.body) : {};
111
+ // Workspace create-or-attach (idempotent on teamSlug + slug).
112
+ if (method === 'POST' && rawPath === '/api/cli/workspaces') {
113
+ const key = `${body.teamSlug}/${body.slug}`;
114
+ const existing = this.workspaces.get(key);
115
+ if (existing)
116
+ return jsonResponse({ workspace: { ...existing } }, 200);
117
+ const workspace = { id: this.id('ws'), teamSlug: String(body.teamSlug), slug: String(body.slug) };
118
+ this.workspaces.set(key, workspace);
119
+ return jsonResponse({ workspace }, 201);
120
+ }
121
+ // Session start — cut a real `orizu/session-<id>` branch off main.
122
+ const sessMatch = rawPath.match(/^\/api\/cli\/workspaces\/([^/]+)\/sessions$/);
123
+ if (method === 'POST' && sessMatch) {
124
+ const sessionId = this.id('sess');
125
+ const stored = {
126
+ id: sessionId,
127
+ workspaceId: sessMatch[1],
128
+ branch: null,
129
+ headSha: null,
130
+ projectSlug: typeof body.projectSlug === 'string' ? body.projectSlug : null,
131
+ };
132
+ if (body.repoBranch !== false) {
133
+ const branch = `orizu/session-${sessionId}`;
134
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', branch, this.repo.defaultBranch]);
135
+ stored.branch = branch;
136
+ stored.headSha = this.branchHead(branch);
137
+ }
138
+ this.sessions.set(sessionId, stored);
139
+ return jsonResponse({ session: { id: sessionId, repoBranch: stored.branch, repoHeadSha: stored.headSha, status: 'active' } }, 201);
140
+ }
141
+ // Repo-token mint.
142
+ const tokenMatch = rawPath.match(/^\/api\/cli\/workspaces\/([^/]+)\/repo-token$/);
143
+ if (method === 'POST' && tokenMatch) {
144
+ const purpose = String(body.purpose);
145
+ const mintId = this.id('mint');
146
+ const token = `ghs_sim_${purpose}_${mintId}_${Math.random().toString(36).slice(2)}`;
147
+ this.mints.set(mintId, { id: mintId, workspaceId: tokenMatch[1], purpose, revoked: false });
148
+ this.tokensIssued.push(token);
149
+ return jsonResponse({
150
+ token,
151
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
152
+ repo: this.repo.repoFullName,
153
+ mintId,
154
+ });
155
+ }
156
+ // Repo-token revoke.
157
+ if (method === 'DELETE' && tokenMatch) {
158
+ const mintId = String(body.mintId);
159
+ const mint = this.mints.get(mintId);
160
+ if (!mint)
161
+ return jsonResponse({ error: 'Token mint not found' }, 404);
162
+ mint.revoked = true;
163
+ return jsonResponse({ revoked: true, mintId });
164
+ }
165
+ // Finish-branch — compare the session branch against main.
166
+ const finishMatch = rawPath.match(/^\/api\/cli\/sessions\/([^/]+)\/finish-branch$/);
167
+ if (method === 'POST' && finishMatch) {
168
+ const session = this.sessions.get(finishMatch[1]);
169
+ if (!session || !session.branch)
170
+ return jsonResponse({ error: 'Session has no repo branch' }, 400);
171
+ const ahead = Number.parseInt(gitOrThrow(['--git-dir', this.repo.gitDir, 'rev-list', '--count', `${this.repo.defaultBranch}..${session.branch}`]).trim(), 10);
172
+ if (ahead === 0) {
173
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', session.branch]);
174
+ return jsonResponse({ outcome: 'no-changes', branch: session.branch });
175
+ }
176
+ const headSha = this.branchHead(session.branch);
177
+ const manifestId = this.id('man');
178
+ this.manifests.set(manifestId, {
179
+ id: manifestId,
180
+ status: 'pending_approval',
181
+ branch: session.branch,
182
+ headSha,
183
+ outcome: {},
184
+ });
185
+ return jsonResponse({ outcome: 'manifest', manifest: { id: manifestId, status: 'pending_approval', actionType: 'repo_merge' } }, 201);
186
+ }
187
+ // Manifest approve / apply / reject.
188
+ const manifestMatch = rawPath.match(/^\/api\/cli\/promotion-manifests\/([^/]+)$/);
189
+ if (method === 'POST' && manifestMatch) {
190
+ const manifest = this.manifests.get(manifestMatch[1]);
191
+ if (!manifest)
192
+ return jsonResponse({ error: 'Promotion manifest not found' }, 404);
193
+ const action = String(body.action);
194
+ if (action === 'approve') {
195
+ if (manifest.status === 'pending_approval' || manifest.status === 'draft')
196
+ manifest.status = 'approved';
197
+ return jsonResponse({ manifest: { id: manifest.id, status: manifest.status } });
198
+ }
199
+ if (action === 'apply') {
200
+ if (manifest.status === 'applied') {
201
+ return jsonResponse({ manifest: { id: manifest.id, status: 'applied', outcome: manifest.outcome } });
202
+ }
203
+ if (manifest.status !== 'approved') {
204
+ return jsonResponse({ error: 'Manifest must be approved before it can be applied' }, 400);
205
+ }
206
+ // Re-verify the branch head, then fast-forward main to it (real merge).
207
+ const head = this.branchHead(manifest.branch);
208
+ if (head !== manifest.headSha)
209
+ return jsonResponse({ error: 'branch moved' }, 409);
210
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'update-ref', `refs/heads/${this.repo.defaultBranch}`, head]);
211
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', manifest.branch]);
212
+ manifest.status = 'applied';
213
+ manifest.outcome = { merged: true, mergeSha: head };
214
+ return jsonResponse({ manifest: { id: manifest.id, status: 'applied', outcome: manifest.outcome } });
215
+ }
216
+ if (action === 'reject') {
217
+ manifest.status = 'rejected';
218
+ gitOrThrow(['--git-dir', this.repo.gitDir, 'branch', '-D', manifest.branch]);
219
+ return jsonResponse({ manifest: { id: manifest.id, status: 'rejected' } });
220
+ }
221
+ return jsonResponse({ error: "action must be one of 'approve', 'reject', 'apply'" }, 400);
222
+ }
223
+ throw new Error(`FakeWorkbenchApi: unexpected fetch ${method} ${path}`);
224
+ };
225
+ }
226
+ }
227
+ /**
228
+ * Wire a full self-contained rehearsal: seed a bare repo, stand up the fake
229
+ * control plane, and hand back a local-sim provider + resolveCloneUrl that maps
230
+ * the broker's repo name to the bare repo path.
231
+ */
232
+ export function createSliceRehearsal(options = {}) {
233
+ const config = {
234
+ teamSlug: options.config?.teamSlug ?? 'acme',
235
+ projectSlug: options.config?.projectSlug ?? 'support-bot',
236
+ workspaceSlug: options.config?.workspaceSlug ?? 'support-bot-slice',
237
+ };
238
+ const base = mkdtempSync(join(tmpdir(), 'orizu-slice-rehearsal-'));
239
+ const repo = seedLocalBareRepo(base, config);
240
+ const api = new FakeWorkbenchApi(repo);
241
+ const provider = createLocalSimProvider({ rootDir: join(base, 'sandboxes'), keepOnDestroy: Boolean(options.keep) });
242
+ return {
243
+ config,
244
+ api,
245
+ provider,
246
+ makeFetcher: () => api.newFetcher(),
247
+ resolveCloneUrl: () => repo.gitDir,
248
+ bareRepoDir: repo.gitDir,
249
+ defaultBranch: () => gitOrThrow(['--git-dir', repo.gitDir, 'rev-parse', repo.defaultBranch]).trim(),
250
+ cleanup: () => {
251
+ if (!options.keep)
252
+ rmSync(base, { recursive: true, force: true });
253
+ },
254
+ };
255
+ }