@zq-silk/yui 2.23.0 → 2.25.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 (51) hide show
  1. package/README.md +9 -4
  2. package/dist/artifacts/artifactCapability.js +6 -6
  3. package/dist/artifacts/gitArtifactRef.js +10 -10
  4. package/dist/artifacts/jobArtifact.js +5 -5
  5. package/dist/artifacts/managedGit.js +15 -12
  6. package/dist/artifacts/taskArtifactRepository.js +35 -33
  7. package/dist/cli.js +9 -2
  8. package/dist/commands/configCommands.js +64 -1
  9. package/dist/commands/projectCommands.js +9 -9
  10. package/dist/commands/taskCompletionGate.js +1 -1
  11. package/dist/commands/taskIntegrationCommands.js +1 -1
  12. package/dist/commands/taskPublicationAdoptCommand.js +1 -1
  13. package/dist/commands/taskUpstreamCommands.js +1 -1
  14. package/dist/config/configCatalog.js +3 -0
  15. package/dist/config/gitConfig.js +20 -0
  16. package/dist/config/yuiConfig.js +1 -0
  17. package/dist/controlPlaneCli.js +4 -4
  18. package/dist/controller/jobArtifactControl.js +3 -3
  19. package/dist/controller/jobDefinitionControl.js +1 -1
  20. package/dist/integration/gitIntegrationService.js +1 -1
  21. package/dist/kernel/builtinCapabilities.js +3 -3
  22. package/dist/repository/gitCommand.js +153 -0
  23. package/dist/repository/gitCompatibility.js +3 -2
  24. package/dist/repository/gitWorkspace.js +579 -563
  25. package/dist/repository/gitWorktreeInventory.js +15 -19
  26. package/dist/repository/projectRefresh.js +1 -1
  27. package/dist/repository/taskBaseFreshness.js +1 -4
  28. package/dist/repository/taskWorkspacePreparer.js +1 -1
  29. package/dist/runtime/nativeExecutable.js +5 -2
  30. package/dist/runtime/platform.js +11 -0
  31. package/dist/storage/sqliteSchema.js +10 -0
  32. package/dist/storage/storageVersions.js +1 -1
  33. package/dist/storage/taskStore.js +7 -1
  34. package/dist/web/webConversation.js +2 -2
  35. package/dist/web/webMaterials.js +19 -19
  36. package/dist/web/webTaskSurface.js +3 -3
  37. package/dist/workspace/workItemChangeSetManager.js +3 -3
  38. package/docs/project-refresh.md +3 -2
  39. package/docs/project-refresh.zh-CN.md +2 -1
  40. package/docs/release-workflow.md +58 -2
  41. package/docs/release-workflow.zh-CN.md +45 -1
  42. package/docs/roles-and-configuration.md +34 -0
  43. package/docs/roles-and-configuration.zh-CN.md +27 -0
  44. package/docs/storage-baseline.md +5 -1
  45. package/docs/storage-baseline.zh-CN.md +3 -1
  46. package/docs/testing/verification-levels.md +7 -7
  47. package/docs/testing/verification-levels.zh-CN.md +6 -6
  48. package/i18n/README.zh-CN.md +8 -4
  49. package/package.json +1 -1
  50. package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
  51. package/dist/runtime/native/darwin-x64/process-identity +0 -0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Core CI](https://github.com/zhangqian-silk/yui/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/zhangqian-silk/yui/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  ![Node](https://img.shields.io/badge/node-20%20%7C%2022%20%7C%2024-brightgreen.svg)
8
- ![Platform](https://img.shields.io/badge/platform-Linux%20x64%20%28glibc%29%20%7C%20macOS%20x64%2Farm64-blue.svg)
8
+ ![Platform](https://img.shields.io/badge/platform-Linux%20x64%20%28glibc%29%20%7C%20macOS%20arm64-blue.svg)
9
9
  [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing)
10
10
 
11
11
  Give your Agents work to carry forward, not just another chat to answer.
@@ -46,15 +46,20 @@ For the self-built coding Agent (without a Controller or Yui Home), see
46
46
  [`yui agent`](docs/native-agent.md). Its explicit configuration and product state
47
47
  are separate from the orchestration setup below.
48
48
 
49
- You need Linux x64 (glibc) or macOS (x64 or Apple Silicon), Git 2.29.0 or newer, tmux, and
49
+ You need Linux x64 (glibc) or macOS Apple Silicon with native arm64 Node.js, Git 2.29.0 or newer, tmux, and
50
50
  Node.js `^20.17.0`, `^22.9.0`, `^24.0.0` or `^26.0.0`. On macOS install tmux with
51
51
  `brew install tmux`. For the simplest setup, have Codex CLI or Claude Code CLI
52
52
  installed and ready to use with your own account. Yui coordinates those
53
53
  Agents; it does not supply model access.
54
54
 
55
- The npm package includes prebuilt Yui helpers for Linux x64, Mac Intel and
56
- Apple Silicon. The same package is tested on all three platforms before
55
+ The npm package includes prebuilt Yui helpers for Linux x64 and macOS
56
+ Apple Silicon. The same package is tested on both platforms before
57
57
  publication; Yui selects only the helper for the running platform.
58
+ Starting with 2.25.0, macOS Intel and x64 Node.js under Rosetta are unsupported.
59
+ Intel Mac users must stay on 2.23.0; Apple Silicon users must switch to native
60
+ arm64 Node.js. npm's OS/CPU fields cannot express these platform pairs, so npm
61
+ installation may succeed on an unsupported combination, but Yui rejects it
62
+ before loading either CLI product or opening the Home. No install scripts are added.
58
63
 
59
64
  For Claude, Yui passes through your authentication environment and native
60
65
  configuration directory; Claude selects the API key or login method using its
@@ -32,13 +32,13 @@ function requireTextContent(content) {
32
32
  * `artifact.save`: write `content` to `relativePath` in the Task's local repo,
33
33
  * commit exactly that path, and return the resulting commit-pinned reference.
34
34
  */
35
- export async function saveArtifactCapability(home, taskId, input) {
35
+ export async function saveArtifactCapability(home, taskId, input, getConfig) {
36
36
  const bytes = artifactInputBytes(input);
37
37
  return saveArtifactFile(home, taskId, {
38
38
  relativePath: input.relativePath, bytes,
39
39
  ...(input.message === undefined ? {} : { message: input.message }),
40
40
  ...(input.expectedHead === undefined ? {} : { expectedHead: input.expectedHead })
41
- });
41
+ }, getConfig);
42
42
  }
43
43
  /** Shared bounded text/static-image input; never executes HTML or scripts. */
44
44
  export function artifactInputBytes(input) {
@@ -66,8 +66,8 @@ export function artifactInputBytes(input) {
66
66
  * frozen evidence. Images return explicit base64 and bounded raster metadata;
67
67
  * text returns UTF-8. Both carry the resolved commit and exact digest.
68
68
  */
69
- export async function readArtifactCapability(home, taskId, input) {
70
- const repo = openTaskArtifactRepository(home, taskId);
69
+ export async function readArtifactCapability(home, taskId, input, getConfig) {
70
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
71
71
  if (!repo.exists())
72
72
  throw new Error(`Artifact repository is unavailable for ${taskId}.`);
73
73
  const safeRelative = safeRelativeArtifactPath(input.relativePath);
@@ -101,8 +101,8 @@ export function presentArtifactContent(content) {
101
101
  * `artifact.list`: tracked artifacts at HEAD (empty when the Task has no repo
102
102
  * yet). This is an ordinary current read; it does not freeze anything.
103
103
  */
104
- export async function listArtifactsCapability(home, taskId) {
105
- const repo = openTaskArtifactRepository(home, taskId);
104
+ export async function listArtifactsCapability(home, taskId, getConfig) {
105
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
106
106
  if (!repo.exists())
107
107
  return [];
108
108
  return repo.list();
@@ -67,9 +67,9 @@ export function parseGitArtifactRef(value, taskId) {
67
67
  * though for a correctly pinned commit this can only happen if the recorded
68
68
  * digest was wrong, since the commit freezes the bytes.
69
69
  */
70
- export async function resolveGitArtifact(home, ref) {
70
+ export async function resolveGitArtifact(home, ref, getConfig) {
71
71
  const valid = validateGitArtifactRef(ref);
72
- const repo = openTaskArtifactRepository(home, valid.taskId);
72
+ const repo = openTaskArtifactRepository(home, valid.taskId, getConfig);
73
73
  if (!repo.exists()) {
74
74
  throw new Error(`Artifact repository is unavailable for ${valid.taskId}.`);
75
75
  }
@@ -88,8 +88,8 @@ export async function resolveGitArtifact(home, ref) {
88
88
  * for the resulting state. The returned reference is self-certifying, so a
89
89
  * caller can record it directly as frozen evidence.
90
90
  */
91
- export async function saveArtifactFile(home, taskId, input) {
92
- const repo = openTaskArtifactRepository(home, taskId);
91
+ export async function saveArtifactFile(home, taskId, input, getConfig) {
92
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
93
93
  const relativePath = safeRelativeArtifactPath(input.relativePath);
94
94
  const result = await repo.save({
95
95
  files: [{ relativePath, bytes: input.bytes }],
@@ -105,15 +105,15 @@ export async function saveArtifactFile(home, taskId, input) {
105
105
  digest: content.digest
106
106
  });
107
107
  }
108
- export async function readCurrentArtifact(home, taskId, relativePath) {
109
- const repo = openTaskArtifactRepository(home, taskId);
108
+ export async function readCurrentArtifact(home, taskId, relativePath, getConfig) {
109
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
110
110
  if (!repo.exists())
111
111
  throw new Error(`Artifact repository is unavailable for ${taskId}.`);
112
112
  return repo.read(safeRelativeArtifactPath(relativePath));
113
113
  }
114
114
  /** Ordinary listing: tracked artifacts at HEAD (ASYNC, not frozen). Empty when no repo yet. */
115
- export async function listCurrentArtifacts(home, taskId) {
116
- const repo = openTaskArtifactRepository(home, taskId);
115
+ export async function listCurrentArtifacts(home, taskId, getConfig) {
116
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
117
117
  if (!repo.exists())
118
118
  return [];
119
119
  return repo.list();
@@ -123,8 +123,8 @@ export async function listCurrentArtifacts(home, taskId) {
123
123
  * references (ASYNC). Used at Candidate submission / completion time to capture
124
124
  * exactly what exists now. Fails if any path is not tracked at HEAD.
125
125
  */
126
- export async function pinCurrentArtifacts(home, taskId, relativePaths) {
127
- const repo = openTaskArtifactRepository(home, taskId);
126
+ export async function pinCurrentArtifacts(home, taskId, relativePaths, getConfig) {
127
+ const repo = openTaskArtifactRepository(home, taskId, getConfig);
128
128
  if (!repo.exists())
129
129
  throw new Error(`Artifact repository is unavailable for ${taskId}.`);
130
130
  const head = await repo.head();
@@ -57,7 +57,7 @@ export function jobArtifactOwner(run) {
57
57
  }
58
58
  /** Each Run has a distinct working tree and commit fence even for identical paths.
59
59
  * The retained storage is unrelated to execution workspaces/logs/checkpoints. */
60
- export function openJobArtifactRepository(home, owner, sourceRun) {
60
+ export function openJobArtifactRepository(home, owner, sourceRun, getConfig) {
61
61
  validateScope(owner.scope);
62
62
  validateScope(sourceRun.scope);
63
63
  if (!isDeepStrictEqual(owner.scope, sourceRun.scope))
@@ -65,13 +65,13 @@ export function openJobArtifactRepository(home, owner, sourceRun) {
65
65
  const scopePath = owner.scope.kind === "global" ? ["global"] : ["task", owner.scope.taskId];
66
66
  const path = join(home, "job-artifacts", ...scopePath, identity(owner.jobId), identity(sourceRun.runId));
67
67
  const lockKey = `job-${createHash("sha256").update(JSON.stringify([jobScopeKey(owner.scope), owner.jobId, sourceRun.runId])).digest("hex")}`;
68
- return openVersionedArtifactRepository(home, lockKey, path);
68
+ return openVersionedArtifactRepository(home, lockKey, path, getConfig);
69
69
  }
70
- export async function readJobArtifactReference(home, ref) {
70
+ export async function readJobArtifactReference(home, ref, getConfig) {
71
71
  validateJobArtifactReference(ref);
72
72
  const repo = ref.owner.kind === "task"
73
- ? openTaskArtifactRepository(home, ref.owner.taskId)
74
- : openJobArtifactRepository(home, ref.owner, ref.sourceRun);
73
+ ? openTaskArtifactRepository(home, ref.owner.taskId, getConfig)
74
+ : openJobArtifactRepository(home, ref.owner, ref.sourceRun, getConfig);
75
75
  const content = await repo.read(ref.relativePath, ref.version);
76
76
  if (content.digest !== ref.digest)
77
77
  throw new Error("Artifact digest mismatch; preserved evidence was not replaced.");
@@ -1,7 +1,6 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
1
  import { supportedGit } from "../repository/gitCompatibility.js";
4
- const executeFile = promisify(execFile);
2
+ import { executeGitCommand, GitCommandError, LOCAL_GIT_TIMEOUT_MS } from "../repository/gitCommand.js";
3
+ import { resolveGitLocalTimeoutSeconds } from "../config/gitConfig.js";
5
4
  /**
6
5
  * Hardened, isolated Git runner for per-Task artifact repositories.
7
6
  *
@@ -65,7 +64,9 @@ export class ManagedGitError extends Error {
65
64
  stderr;
66
65
  args;
67
66
  constructor(args, stderr, cause) {
68
- super(stderr.length === 0 ? "Managed git command failed." : `Managed git command failed: ${stderr}`, { cause });
67
+ super(cause instanceof Error
68
+ ? cause.message.replace(/^Git command/u, "Managed git command")
69
+ : stderr.length === 0 ? "Managed git command failed." : `Managed git command failed: ${stderr}`, { cause });
69
70
  this.name = "ManagedGitError";
70
71
  this.stderr = stderr;
71
72
  this.args = args;
@@ -124,14 +125,14 @@ function assertSafeArguments(args) {
124
125
  */
125
126
  async function spawnManagedGit(repoPath, args, options) {
126
127
  assertSafeArguments(args);
127
- const program = supportedGit();
128
+ const timeoutMs = options?.timeoutMs ?? (options?.getConfig === undefined
129
+ ? LOCAL_GIT_TIMEOUT_MS : resolveGitLocalTimeoutSeconds(options.getConfig().gitLocalTimeoutSeconds) * 1_000);
130
+ const program = supportedGit(timeoutMs);
128
131
  try {
129
- const result = await executeFile(program.path, [...HARDENING_FLAGS, "-C", repoPath, ...args], {
130
- encoding: "buffer",
131
- env: managedGitEnvironment(),
132
+ const result = await executeGitCommand(program.path, [...HARDENING_FLAGS, "-C", repoPath, ...args], {
133
+ environment: managedGitEnvironment(),
132
134
  maxBuffer: options?.maxBuffer ?? 16 * 1024 * 1024,
133
- timeout: options?.timeoutMs ?? 30_000,
134
- windowsHide: true
135
+ budget: { totalMs: timeoutMs }
135
136
  });
136
137
  return { stdout: result.stdout, stderr: result.stderr };
137
138
  }
@@ -160,14 +161,16 @@ export async function managedGitBuffer(repoPath, args, options) {
160
161
  const { stdout } = await spawnManagedGit(repoPath, args, options);
161
162
  return stdout;
162
163
  }
163
- /** Run a managed Git command purely for its exit status; never throws on non-zero. */
164
+ /** Return false for a completed non-zero exit, but preserve timeouts/process failures. */
164
165
  export async function managedGitSucceeds(repoPath, args, options) {
165
166
  try {
166
167
  await spawnManagedGit(repoPath, args, options);
167
168
  return true;
168
169
  }
169
170
  catch (error) {
170
- if (error instanceof ManagedGitError)
171
+ if (error instanceof ManagedGitError && error.cause instanceof GitCommandError
172
+ && error.cause.timeoutKind === undefined && error.cause.signal === null
173
+ && typeof error.cause.code === "number")
171
174
  return false;
172
175
  throw error;
173
176
  }
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { lstat, mkdir, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, relative } from "node:path";
5
5
  import { supportedGit } from "../repository/gitCompatibility.js";
6
+ import { resolveGitLocalTimeoutSeconds } from "../config/gitConfig.js";
6
7
  import { acquireArtifactCommitLock } from "./artifactCommitLock.js";
7
8
  import { resolveContainedArtifactPath, safeRelativeArtifactPath, taskArtifactRepoPath } from "./artifactPaths.js";
8
9
  import { ManagedGitError, externalProgramConfigViolations, managedGit, managedGitBuffer, managedGitSucceeds, requireCommitId } from "./managedGit.js";
@@ -88,14 +89,14 @@ export class ArtifactHeadConflictError extends Error {
88
89
  * (e.g. `store.rootDirectory()`), so ownership is derived from the Task, never
89
90
  * from ambient state.
90
91
  */
91
- export function openTaskArtifactRepository(home, taskId) {
92
+ export function openTaskArtifactRepository(home, taskId, getConfig) {
92
93
  const repoPath = taskArtifactRepoPath(home, taskId);
93
- const repo = openVersionedArtifactRepository(home, taskId, repoPath);
94
+ const repo = openVersionedArtifactRepository(home, taskId, repoPath, getConfig);
94
95
  return { ...repo, taskId, save: async (input) => ({ ...await repo.save(input), taskId }) };
95
96
  }
96
97
  /** Shared local-only storage. Callers derive paths from validated owning identities,
97
98
  * never from a user-supplied filesystem location. Task layout/commits stay intact. */
98
- export function openVersionedArtifactRepository(home, key, repoPath) {
99
+ export function openVersionedArtifactRepository(home, key, repoPath, getConfig) {
99
100
  const taskId = key; // Existing error fields retain their public spelling.
100
101
  const checkBoundary = async () => {
101
102
  await resolveContainedArtifactPath(home, `${relative(home, repoPath).split("\\").join("/")}/boundary-check`);
@@ -115,21 +116,21 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
115
116
  await checkBoundary();
116
117
  if (!exists())
117
118
  return null;
118
- return currentHead(repoPath);
119
+ return currentHead(repoPath, getConfig);
119
120
  };
120
121
  const ensureUnlocked = async () => {
121
122
  await checkBoundary();
122
- supportedGit();
123
+ supportedGit(resolveGitLocalTimeoutSeconds(getConfig?.().gitLocalTimeoutSeconds) * 1_000);
123
124
  if (exists()) {
124
- await assertNoRemote(taskId, repoPath);
125
+ await assertNoRemote(taskId, repoPath, getConfig);
125
126
  return;
126
127
  }
127
128
  await mkdir(repoPath, { recursive: true, mode: 0o700 });
128
- await managedGit(repoPath, ["init", "-b", ARTIFACT_BRANCH]);
129
+ await managedGit(repoPath, ["init", "-b", ARTIFACT_BRANCH], { getConfig });
129
130
  // An empty root commit guarantees HEAD exists, so scoped per-path commits
130
131
  // and expected-HEAD checks work uniformly from the very first save.
131
- await managedGit(repoPath, ["commit", "--allow-empty", "-m", "init artifact repo"]);
132
- await assertNoRemote(taskId, repoPath);
132
+ await managedGit(repoPath, ["commit", "--allow-empty", "-m", "init artifact repo"], { getConfig });
133
+ await assertNoRemote(taskId, repoPath, getConfig);
133
134
  };
134
135
  const ensure = async () => {
135
136
  const release = await acquireArtifactCommitLock(home, key);
@@ -169,10 +170,10 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
169
170
  const release = await acquireArtifactCommitLock(home, key);
170
171
  try {
171
172
  await ensureUnlocked();
172
- await assertNoRemote(taskId, repoPath);
173
+ await assertNoRemote(taskId, repoPath, getConfig);
173
174
  // Expected-HEAD check under the lock: a conflict stops without writing.
174
175
  if (input.expectedHead !== undefined) {
175
- const actual = await currentHead(repoPath);
176
+ const actual = await currentHead(repoPath, getConfig);
176
177
  if (actual !== input.expectedHead) {
177
178
  throw new ArtifactHeadConflictError(taskId, input.expectedHead, actual);
178
179
  }
@@ -186,17 +187,17 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
186
187
  }
187
188
  const pathspecs = prepared.map((file) => file.relativePath);
188
189
  // Stage exactly the saved paths so new files become known to Git...
189
- await managedGit(repoPath, ["add", "--", ...pathspecs]);
190
+ await managedGit(repoPath, ["add", "--", ...pathspecs], { getConfig });
190
191
  // ...but if nothing actually changed, do not create an empty commit.
191
192
  const noChanges = await managedGitSucceeds(repoPath, [
192
193
  "diff", "--cached", "--quiet", "--", ...pathspecs
193
- ]);
194
+ ], { getConfig });
194
195
  if (noChanges) {
195
- return { commit: await currentHead(repoPath), savedPaths: pathspecs };
196
+ return { commit: await currentHead(repoPath, getConfig), savedPaths: pathspecs };
196
197
  }
197
198
  // `--only <paths>` records exactly these paths; any other WIP stays WIP.
198
- await managedGit(repoPath, ["commit", "--only", "-m", message, "--", ...pathspecs]);
199
- return { commit: await currentHead(repoPath), savedPaths: pathspecs };
199
+ await managedGit(repoPath, ["commit", "--only", "-m", message, "--", ...pathspecs], { getConfig });
200
+ return { commit: await currentHead(repoPath, getConfig), savedPaths: pathspecs };
200
201
  }
201
202
  finally {
202
203
  release();
@@ -208,30 +209,31 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
208
209
  // A pinned commit reads frozen evidence; otherwise read the current HEAD.
209
210
  // Both resolve `<commit>:<path>`, an OBJECT spec (never the working tree, so
210
211
  // a read never follows a symlink or sees unrelated WIP).
211
- const pinned = await artifactCommit(repoPath, commit);
212
+ const pinned = await artifactCommit(repoPath, commit, getConfig);
212
213
  const objectSpec = `${pinned}:${safeRelative}`;
213
214
  // `<commit>:<path>` for a DIRECTORY resolves to a tree, and `git show` would
214
215
  // print a tree listing rather than fail — silently returning directory
215
216
  // metadata as if it were file bytes. Verify the object is a blob first, so a
216
217
  // read only ever yields real file content.
217
- const objectType = (await managedGit(repoPath, ["cat-file", "-t", objectSpec]).catch((error) => {
218
+ const objectType = (await managedGit(repoPath, ["cat-file", "-t", objectSpec], { getConfig }).catch((error) => {
218
219
  if (error instanceof ManagedGitError) {
219
- throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}.`, { cause: error });
220
+ throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}: ${error.message}`, { cause: error });
220
221
  }
221
222
  throw error;
222
223
  })).trim();
223
224
  if (objectType !== "blob") {
224
225
  throw new Error(`Artifact ${safeRelative} at ${pinned} is not a file (${objectType}).`);
225
226
  }
226
- const size = Number((await managedGit(repoPath, ["cat-file", "-s", objectSpec])).trim());
227
+ const size = Number((await managedGit(repoPath, ["cat-file", "-s", objectSpec], { getConfig })).trim());
227
228
  if (!Number.isSafeInteger(size) || size > MAX_ARTIFACT_BYTES) {
228
229
  throw new Error(`Artifact ${safeRelative} exceeds the ${MAX_ARTIFACT_BYTES}-byte limit.`);
229
230
  }
230
231
  const bytes = await managedGitBuffer(repoPath, ["cat-file", "blob", objectSpec], {
232
+ getConfig,
231
233
  maxBuffer: MAX_ARTIFACT_BYTES + 4096
232
234
  }).catch((error) => {
233
235
  if (error instanceof ManagedGitError) {
234
- throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}.`, { cause: error });
236
+ throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}: ${error.message}`, { cause: error });
235
237
  }
236
238
  throw error;
237
239
  });
@@ -246,10 +248,10 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
246
248
  await checkBoundary();
247
249
  if (!exists())
248
250
  return [];
249
- const pinned = await artifactCommit(repoPath, commit);
251
+ const pinned = await artifactCommit(repoPath, commit, getConfig);
250
252
  const output = await managedGit(repoPath, [
251
253
  "ls-tree", "-r", "-z", "--long", "--full-tree", pinned
252
- ]);
254
+ ], { getConfig });
253
255
  const entries = [];
254
256
  for (const record of output.split("\0").filter(Boolean)) {
255
257
  // Format: "<mode> <type> <object> <size>\t<path>"
@@ -271,7 +273,7 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
271
273
  return [];
272
274
  const output = await managedGit(repoPath, [
273
275
  "status", "--porcelain=v1", "--untracked-files=all", "-z"
274
- ]);
276
+ ], { getConfig });
275
277
  // Porcelain v1 -z records: "XY <path>\0" (rename adds a second \0 field).
276
278
  const changes = [];
277
279
  for (const record of output.split("\0").filter(Boolean)) {
@@ -283,16 +285,16 @@ export function openVersionedArtifactRepository(home, key, repoPath) {
283
285
  };
284
286
  return { repoPath, ensure, exists, head, save, read, list, workingChanges };
285
287
  }
286
- async function currentHead(repoPath) {
287
- return requireCommitId(await managedGit(repoPath, ["rev-parse", "HEAD^{commit}"]));
288
+ async function currentHead(repoPath, getConfig) {
289
+ return requireCommitId(await managedGit(repoPath, ["rev-parse", "HEAD^{commit}"], { getConfig }));
288
290
  }
289
- async function artifactCommit(repoPath, value) {
291
+ async function artifactCommit(repoPath, value, getConfig) {
290
292
  if (value === undefined)
291
- return currentHead(repoPath);
293
+ return currentHead(repoPath, getConfig);
292
294
  const commit = requireCommitId(value);
293
- const type = await managedGit(repoPath, ["cat-file", "-t", commit]).catch(error => {
295
+ const type = await managedGit(repoPath, ["cat-file", "-t", commit], { getConfig }).catch(error => {
294
296
  if (error instanceof ManagedGitError)
295
- throw new Error(`Artifact commit ${commit} is unavailable.`, { cause: error });
297
+ throw new Error(`Artifact commit ${commit} is unavailable: ${error.message}`, { cause: error });
296
298
  throw error;
297
299
  });
298
300
  if (type.trim() !== "commit") {
@@ -306,8 +308,8 @@ async function artifactCommit(repoPath, value) {
306
308
  * program on add/diff/commit. Both are external violations — we report and stop,
307
309
  * never silently remove them.
308
310
  */
309
- async function assertNoRemote(taskId, repoPath) {
310
- const output = await managedGit(repoPath, ["remote"]);
311
+ async function assertNoRemote(taskId, repoPath, getConfig) {
312
+ const output = await managedGit(repoPath, ["remote"], { getConfig });
311
313
  const remotes = output.split("\n").map((line) => line.trim()).filter(Boolean);
312
314
  if (remotes.length > 0) {
313
315
  throw new ArtifactRemoteViolationError(taskId, remotes);
@@ -315,7 +317,7 @@ async function assertNoRemote(taskId, repoPath) {
315
317
  // A repo's own `.git/config` is read even with global/system config disabled,
316
318
  // so a committed `.gitattributes` filter paired with a repo-local
317
319
  // `filter.<n>.clean = <cmd>` would run `<cmd>` on `git add`. Detect and stop.
318
- const configListZ = await managedGit(repoPath, ["config", "--local", "--list", "-z"]);
320
+ const configListZ = await managedGit(repoPath, ["config", "--local", "--list", "-z"], { getConfig });
319
321
  const violations = externalProgramConfigViolations(configListZ);
320
322
  if (violations.length > 0) {
321
323
  throw new ArtifactManagedConfigViolationError(taskId, violations);
package/dist/cli.js CHANGED
@@ -1,11 +1,19 @@
1
1
  #!/usr/bin/env node
2
+ import { unsupportedPlatformMessage } from "./runtime/platform.js";
2
3
  // No static control-plane imports: the product never resolves/initializes Yui Home.
3
4
  const arguments_ = process.argv.slice(2);
4
5
  let commandOffset = 0;
5
6
  while (arguments_[commandOffset] === '--json')
6
7
  commandOffset++;
7
8
  const commandArguments = arguments_.slice(commandOffset);
8
- if (commandArguments[0] === 'agent' || (commandArguments[0] === 'help' && commandArguments[1] === 'agent')) {
9
+ const platformFailure = unsupportedPlatformMessage();
10
+ if (platformFailure !== undefined) {
11
+ process.stderr.write(`${arguments_.includes("--json")
12
+ ? JSON.stringify({ ok: false, code: "unsupported_platform", message: platformFailure })
13
+ : platformFailure}\n`);
14
+ process.exitCode = 1;
15
+ }
16
+ else if (commandArguments[0] === 'agent' || (commandArguments[0] === 'help' && commandArguments[1] === 'agent')) {
9
17
  let outputFailed = false;
10
18
  // Cover early help/config writes as well as execution UI disconnection.
11
19
  process.stdout.on('error', () => { outputFailed = true; process.exitCode = 1; });
@@ -31,4 +39,3 @@ if (commandArguments[0] === 'agent' || (commandArguments[0] === 'help' && comman
31
39
  else {
32
40
  await import('./controlPlaneCli.js');
33
41
  }
34
- export {};
@@ -2,7 +2,7 @@ import { mkdirSync, realpathSync } from "node:fs";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { CONFIG_DEFINITIONS, CONFIG_KEYS, configDefinition, configDefinitionsForDomain } from "../config/configCatalog.js";
4
4
  import { resolveTimeZone } from "../config/timeZone.js";
5
- import { DEFAULT_AGENT_LAUNCH_INACTIVITY_TIMEOUT_SECONDS, DEFAULT_CONTROLLER_TASK_CONCURRENCY, DEFAULT_DELIVERY_TIMEOUT_SECONDS, DEFAULT_RECONCILIATION_INTERVAL_SECONDS, DEFAULT_RESOURCES_GC_MODE, DEFAULT_RESOURCES_QUARANTINE_TTL_HOURS, DEFAULT_TMUX_HISTORY_LIMIT, reconciliationIntervalMilliseconds, resolveAgentLaunchInactivityTimeoutSeconds, resolveControllerTaskConcurrency, resolveDeliveryTimeoutSeconds, resolveResourcesGcAutoQuarantine, resolveResourcesGcMode, resolveResourcesQuarantineTtlHours, resolveRuntimeHealth, resolveTelemetryEnabled, resolveTelemetryRunCap, resolveTelemetryTerminalKeep, resolveTmuxBin, resolveTmuxHistoryLimit } from "../config/yuiConfig.js";
5
+ import { DEFAULT_AGENT_LAUNCH_INACTIVITY_TIMEOUT_SECONDS, DEFAULT_CONTROLLER_TASK_CONCURRENCY, DEFAULT_DELIVERY_TIMEOUT_SECONDS, DEFAULT_RECONCILIATION_INTERVAL_SECONDS, DEFAULT_RESOURCES_GC_MODE, DEFAULT_RESOURCES_QUARANTINE_TTL_HOURS, DEFAULT_TMUX_HISTORY_LIMIT, reconciliationIntervalMilliseconds, resolveAgentLaunchInactivityTimeoutSeconds, resolveControllerTaskConcurrency, resolveDeliveryTimeoutSeconds, resolveGitLocalTimeoutSeconds, resolveGitRemoteInactivityTimeoutSeconds, resolveGitRemoteTimeoutSeconds, resolveResourcesGcAutoQuarantine, resolveResourcesGcMode, resolveResourcesQuarantineTtlHours, resolveRuntimeHealth, resolveTelemetryEnabled, resolveTelemetryRunCap, resolveTelemetryTerminalKeep, resolveTmuxBin, resolveTmuxHistoryLimit } from "../config/yuiConfig.js";
6
6
  import { usageError } from "../errors/cliError.js";
7
7
  import { defaultTableWidth, renderTable } from "../output/table.js";
8
8
  import { REVIEW_TRIGGERS } from "../review/reviewConfig.js";
@@ -75,6 +75,9 @@ export function effectiveConfigData(config, domain) {
75
75
  controllerTaskConcurrency: resolveControllerTaskConcurrency(config.controllerTaskConcurrency),
76
76
  agentLaunchInactivityTimeoutSeconds: resolveAgentLaunchInactivityTimeoutSeconds(config.agentLaunchInactivityTimeoutSeconds),
77
77
  deliveryTimeoutSeconds: resolveDeliveryTimeoutSeconds(config.deliveryTimeoutSeconds),
78
+ gitLocalTimeoutSeconds: resolveGitLocalTimeoutSeconds(config.gitLocalTimeoutSeconds),
79
+ gitRemoteInactivityTimeoutSeconds: resolveGitRemoteInactivityTimeoutSeconds(config.gitRemoteInactivityTimeoutSeconds),
80
+ gitRemoteTimeoutSeconds: resolveGitRemoteTimeoutSeconds(config.gitRemoteTimeoutSeconds),
78
81
  tmuxBin: resolveTmuxBin(config.tmuxBin),
79
82
  tmuxHistoryLimit: resolveTmuxHistoryLimit(config.tmuxHistoryLimit),
80
83
  telemetryEnabled: resolveTelemetryEnabled(config.telemetryEnabled),
@@ -393,6 +396,66 @@ const CONFIG_KEY_HANDLERS = [
393
396
  return `Delivery timeout reset to ${DEFAULT_DELIVERY_TIMEOUT_SECONDS} seconds\n`;
394
397
  }
395
398
  },
399
+ {
400
+ key: "git-local-timeout-seconds",
401
+ showLabel: "Git local total timeout",
402
+ showValue: (config) => `${resolveGitLocalTimeoutSeconds(config.gitLocalTimeoutSeconds)} seconds`,
403
+ set(args, store) {
404
+ const usage = "Tools config set usage: yui config tools set git-local-timeout-seconds <1-86400>.";
405
+ if (args.length !== 1)
406
+ throw usageError(usage);
407
+ const gitLocalTimeoutSeconds = validatedConfigValue(() => resolveGitLocalTimeoutSeconds(Number(args[0])), usage);
408
+ saveConfigKey(store, (config) => ({ ...config, gitLocalTimeoutSeconds }));
409
+ return `Git local total timeout set to ${gitLocalTimeoutSeconds} seconds\n`;
410
+ },
411
+ clear(store) {
412
+ saveConfigKey(store, (config) => {
413
+ const { gitLocalTimeoutSeconds: _removed, ...rest } = config;
414
+ return rest;
415
+ });
416
+ return "Git local total timeout reset to default\n";
417
+ }
418
+ },
419
+ {
420
+ key: "git-remote-inactivity-timeout-seconds",
421
+ showLabel: "Git remote inactivity timeout",
422
+ showValue: (config) => `${resolveGitRemoteInactivityTimeoutSeconds(config.gitRemoteInactivityTimeoutSeconds)} seconds`,
423
+ set(args, store) {
424
+ const usage = "Tools config set usage: yui config tools set git-remote-inactivity-timeout-seconds <1-86400>.";
425
+ if (args.length !== 1)
426
+ throw usageError(usage);
427
+ const gitRemoteInactivityTimeoutSeconds = validatedConfigValue(() => resolveGitRemoteInactivityTimeoutSeconds(Number(args[0])), usage);
428
+ saveConfigKey(store, (config) => ({ ...config, gitRemoteInactivityTimeoutSeconds }));
429
+ return `Git remote inactivity timeout set to ${gitRemoteInactivityTimeoutSeconds} seconds\n`;
430
+ },
431
+ clear(store) {
432
+ saveConfigKey(store, (config) => {
433
+ const { gitRemoteInactivityTimeoutSeconds: _removed, ...rest } = config;
434
+ return rest;
435
+ });
436
+ return "Git remote inactivity timeout reset to default\n";
437
+ }
438
+ },
439
+ {
440
+ key: "git-remote-timeout-seconds",
441
+ showLabel: "Git remote total timeout",
442
+ showValue: (config) => `${resolveGitRemoteTimeoutSeconds(config.gitRemoteTimeoutSeconds)} seconds`,
443
+ set(args, store) {
444
+ const usage = "Tools config set usage: yui config tools set git-remote-timeout-seconds <1-86400>.";
445
+ if (args.length !== 1)
446
+ throw usageError(usage);
447
+ const gitRemoteTimeoutSeconds = validatedConfigValue(() => resolveGitRemoteTimeoutSeconds(Number(args[0])), usage);
448
+ saveConfigKey(store, (config) => ({ ...config, gitRemoteTimeoutSeconds }));
449
+ return `Git remote total timeout set to ${gitRemoteTimeoutSeconds} seconds\n`;
450
+ },
451
+ clear(store) {
452
+ saveConfigKey(store, (config) => {
453
+ const { gitRemoteTimeoutSeconds: _removed, ...rest } = config;
454
+ return rest;
455
+ });
456
+ return "Git remote total timeout reset to default\n";
457
+ }
458
+ },
396
459
  {
397
460
  key: "tmux-bin",
398
461
  showLabel: "Tmux bin",
@@ -109,7 +109,7 @@ async function diagnoseProject(args, store, options) {
109
109
  data: { project, diagnosis: "no-remote" }
110
110
  };
111
111
  }
112
- const git = options.git ?? new NodeGitWorkspace();
112
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
113
113
  const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
114
114
  try {
115
115
  requireUnchangedMaintenanceProject(store, project, "diagnose");
@@ -153,7 +153,7 @@ async function discoverProjects(args, store, options) {
153
153
  const workspace = store.getConfig().defaultWorkspace;
154
154
  if (workspace === undefined)
155
155
  throw usageError("No default workspace is configured; run yui setup.");
156
- const git = options.git ?? new NodeGitWorkspace();
156
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
157
157
  const entries = await readdir(workspace, { withFileTypes: true });
158
158
  const discovered = [];
159
159
  for (const entry of entries) {
@@ -195,7 +195,7 @@ function renderDiscoveredProjects(projects) {
195
195
  async function cloneProject(args, store, options) {
196
196
  const usage = "Project clone usage: yui project clone <name> <remote> [--alias <name> ...] [--stable <ref>] [--development <ref>] [--external].";
197
197
  const parsed = parseCloneArguments(args, usage);
198
- const git = options.git ?? new NodeGitWorkspace();
198
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
199
199
  const name = validateProjectName(parsed.name);
200
200
  const stableRef = parsed.stable ?? "HEAD";
201
201
  const developmentRef = parsed.development ?? stableRef;
@@ -282,7 +282,7 @@ async function migrateProject(args, store, options) {
282
282
  if (project.remoteUrl === undefined) {
283
283
  throw usageError(`Project migrate requires a remote URL: ${project.id}.`);
284
284
  }
285
- const git = options.git ?? new NodeGitWorkspace();
285
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
286
286
  const destination = managedProjectPath(store.rootDirectory(), project.id);
287
287
  // Migration rewrites the Project's Git repository: hold the per-Project
288
288
  // maintenance fence so no rebuild/archive/cleanup (or a second migrate)
@@ -444,7 +444,7 @@ async function addProject(args, store, options) {
444
444
  const usage = "Project add usage: yui project add <name> <path> [--alias <name> ...] [--remote <url>] [--stable <ref>] [--development <ref>].";
445
445
  const parsed = parseAddArguments(args, usage);
446
446
  assertOutsideManagedWorktrees(parsed.path, store.rootDirectory());
447
- const git = options.git ?? new NodeGitWorkspace();
447
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
448
448
  const head = await git.inspect(parsed.path, "HEAD");
449
449
  assertOutsideManagedWorktrees(head.root, store.rootDirectory());
450
450
  if (!await git.isClean(head.root)) {
@@ -483,7 +483,7 @@ async function updateProject(args, store, options) {
483
483
  : { developmentBranch: parsed.development })
484
484
  };
485
485
  assertProjectAvailable(store, updateProjectMetadata(current, patch, now), current.id);
486
- const git = options.git ?? new NodeGitWorkspace();
486
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
487
487
  if (parsed.stable !== undefined) {
488
488
  const head = await git.inspect(current.path, "HEAD");
489
489
  if (!await git.isClean(head.root)) {
@@ -568,7 +568,7 @@ async function resetProject(args, store, options) {
568
568
  if (project.stableBranch !== project.developmentBranch) {
569
569
  throw usageError(`Project reset requires matching stable and development branches: ${project.id}.`);
570
570
  }
571
- const git = options.git ?? new NodeGitWorkspace();
571
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
572
572
  const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
573
573
  try {
574
574
  // Re-read under the fence so a concurrent catalog change can never drive
@@ -693,7 +693,7 @@ async function replaceProject(args, store, options) {
693
693
  throw usageError(`Project replace discards the current checkout and re-clones from ${project.remoteUrl}. `
694
694
  + "Re-run with --discard-local to acknowledge that the checkout and any uncommitted state will be discarded.");
695
695
  }
696
- const git = options.git ?? new NodeGitWorkspace();
696
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
697
697
  const releaseMaintenance = await acquireProjectMaintenanceLock(store.rootDirectory(), project.id, { signal: options.signal });
698
698
  try {
699
699
  const current = requireProject(store, project.id);
@@ -909,7 +909,7 @@ async function deleteProjectCommand(args, store, options) {
909
909
  // Heal a crashed earlier attempt before the prechecks turn.
910
910
  await healCheckoutSwap({ currentPath: project.path, backupPath: tombstone });
911
911
  if (existsSync(project.path)) {
912
- const git = options.git ?? new NodeGitWorkspace();
912
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
913
913
  const worktrees = await git.listWorktrees(project.path);
914
914
  const linked = worktrees.filter((path) => path !== project.path);
915
915
  if (linked.length > 0) {
@@ -27,7 +27,7 @@ export async function verifyTaskCompletionPublishedTree(taskId, publicationId, s
27
27
  throw usageError(`Publication ${publication.id} must record exact local and remote commits.`);
28
28
  }
29
29
  const workspace = requireTaskWorkspace(store, task);
30
- const git = options.git ?? new NodeGitWorkspace();
30
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
31
31
  const actualHeads = new Map();
32
32
  for (const taskBinding of task.projectBindings) {
33
33
  const entry = workspaceProjectEntry(workspace, taskBinding.projectId);
@@ -123,7 +123,7 @@ async function start(args, store, home, now, options) {
123
123
  throw usageError(`Task main worktree is not ready; reconcile the Task first: ${task.id}.`);
124
124
  }
125
125
  taskIntegrationTargetCheckout(store, options.environment, task.id, project.id, targetRef);
126
- const expectedHead = (await new NodeGitWorkspace().inspect(mainEntry.path, targetRef)).baseCommit;
126
+ const expectedHead = (await new NodeGitWorkspace(() => store.getConfig()).inspect(mainEntry.path, targetRef)).baseCommit;
127
127
  const snapshotEntry = candidate.workspace.entries.find(({ projectId }) => projectId === project.id);
128
128
  const resultCommit = candidate.gitSnapshot.projects.find(({ projectId }) => projectId === project.id)?.commit;
129
129
  if (snapshotEntry === undefined || resultCommit === undefined) {
@@ -74,7 +74,7 @@ export async function runTaskPublicationAdoptCommand(args, store, options = {})
74
74
  return { task, publication, completion, acceptedCommit, workspace, entry, integration, actor };
75
75
  };
76
76
  const facts = store.transaction(read);
77
- const git = options.git ?? new NodeGitWorkspace();
77
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
78
78
  const localCommit = facts.publication.localCommit;
79
79
  const [acceptedTree, candidateTree, diff] = await Promise.all([
80
80
  git.resolveTree(facts.entry.path, facts.acceptedCommit),
@@ -48,7 +48,7 @@ async function integrateUpstream(args, store, home, options) {
48
48
  throw usageError(`Task must be active to integrate upstream: ${taskId}/${task.status}.`);
49
49
  }
50
50
  taskLocalActor(store, options.environment, task.id);
51
- const git = options.git ?? new NodeGitWorkspace();
51
+ const git = options.git ?? new NodeGitWorkspace(() => store.getConfig());
52
52
  const now = options.now ?? (() => new Date());
53
53
  await new FileTaskWorkspacePreparer(home, store, git, now)
54
54
  .prepareTaskWorkspace(task.id);