@zq-silk/yui 0.15.8 → 0.15.9

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 (74) hide show
  1. package/ARCHITECTURE.md +2 -0
  2. package/ARCHITECTURE.zh-CN.md +151 -0
  3. package/README.md +211 -14
  4. package/dist/artifacts/artifactCapability.js +74 -0
  5. package/dist/artifacts/artifactCommitLock.js +249 -0
  6. package/dist/artifacts/artifactPaths.js +151 -0
  7. package/dist/artifacts/gitArtifactRef.js +146 -0
  8. package/dist/artifacts/managedGit.js +332 -0
  9. package/dist/artifacts/taskArtifactRepository.js +277 -0
  10. package/dist/cli/commandCatalog.js +14 -10
  11. package/dist/cli.js +67 -0
  12. package/dist/commands/operatorCommands.js +33 -2
  13. package/dist/commands/taskActivationCommands.js +22 -0
  14. package/dist/commands/taskCommands.js +342 -79
  15. package/dist/context/runContextPack.js +28 -16
  16. package/dist/context/taskContext.js +26 -3
  17. package/dist/controller/controller.js +8 -2
  18. package/dist/kernel/builtinCapabilities.js +32 -24
  19. package/dist/message/message.js +56 -0
  20. package/dist/plugins/pluginService.js +11 -3
  21. package/dist/resources/projectResource.js +0 -48
  22. package/dist/resources/projectResourceService.js +3 -81
  23. package/dist/setup/setupCommand.js +3 -8
  24. package/dist/storage/migrations/artifactsToGit.js +338 -0
  25. package/dist/storage/migrations/submitIntent.js +126 -0
  26. package/dist/storage/sqliteSchema.js +37 -3
  27. package/dist/storage/sqliteStore.js +1 -21
  28. package/dist/storage/storageVersions.js +1 -1
  29. package/dist/storage/storeRpc.js +1 -1
  30. package/dist/task/taskActivation.js +26 -0
  31. package/dist/task/taskActivationService.js +85 -69
  32. package/dist/task/taskSubmission.js +236 -0
  33. package/dist/web/assets/client/app.js +3 -2
  34. package/dist/web/assets/client/taskSurface.js +96 -7
  35. package/dist/web/webServer.js +18 -3
  36. package/dist/web/webTaskSurface.js +6 -6
  37. package/dist/workItem/workItem.js +14 -10
  38. package/docs/agent-result-consumption.md +2 -0
  39. package/docs/agent-result-consumption.zh-CN.md +81 -0
  40. package/docs/agent-runtime-drivers.md +2 -0
  41. package/docs/agent-runtime-drivers.zh-CN.md +77 -0
  42. package/docs/architecture/README.md +44 -32
  43. package/docs/architecture/README.zh-CN.md +43 -0
  44. package/docs/architecture/capabilities-and-resources.md +118 -79
  45. package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
  46. package/docs/managed-turn-and-session-runtime.md +2 -0
  47. package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
  48. package/docs/observability/README.md +2 -0
  49. package/docs/observability/README.zh-CN.md +71 -0
  50. package/docs/plugin-sdk.md +320 -217
  51. package/docs/plugin-sdk.zh-CN.md +293 -0
  52. package/docs/provider-runtime.md +2 -0
  53. package/docs/provider-runtime.zh-CN.md +132 -0
  54. package/docs/release-workflow.md +2 -0
  55. package/docs/release-workflow.zh-CN.md +237 -0
  56. package/docs/roles-and-configuration.md +2 -0
  57. package/docs/roles-and-configuration.zh-CN.md +96 -0
  58. package/docs/sqlite-control-plane-design.md +2 -0
  59. package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
  60. package/docs/task-dag-semantics.md +80 -57
  61. package/docs/task-dag-semantics.zh-CN.md +59 -0
  62. package/docs/task-delivery.md +2 -0
  63. package/docs/task-delivery.zh-CN.md +82 -0
  64. package/docs/task-local-identity.md +2 -0
  65. package/docs/task-local-identity.zh-CN.md +58 -0
  66. package/docs/testing/verification-levels.md +2 -0
  67. package/docs/testing/verification-levels.zh-CN.md +69 -0
  68. package/i18n/README.zh-CN.md +199 -10
  69. package/package.json +2 -1
  70. package/skills/yui-leader/SKILL.md +88 -331
  71. package/skills/yui-leader/references/execution.md +303 -0
  72. package/skills/yui-leader/references/planning.md +109 -0
  73. package/skills/yui-leader/references/task-plugins.md +8 -4
  74. package/skills/yui-operator/SKILL.md +16 -3
@@ -0,0 +1,332 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const executeFile = promisify(execFile);
4
+ /**
5
+ * Hardened, isolated Git runner for per-Task artifact repositories.
6
+ *
7
+ * Artifact repositories are local-only working stores for product files
8
+ * (plans, prototypes, charts, reports). Their contents are DATA authored by
9
+ * agents and, indirectly, by product material — never trusted to configure or
10
+ * drive the Git process. Every managed invocation therefore runs with a
11
+ * scrubbed environment and a fixed set of hardening options that:
12
+ *
13
+ * - block ALL network/file transports (`protocol.allow=never`, and an
14
+ * explicit `protocol.file.allow=never`), so no `fetch`/`clone`/`push`
15
+ * can move bytes even if a rogue remote is configured;
16
+ * - disable hooks (`core.hooksPath` → null device) so a committed hook
17
+ * script can never execute;
18
+ * - refuse to read user/system config or attributes
19
+ * (`GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` → null device,
20
+ * `GIT_CONFIG_NOSYSTEM`, `GIT_ATTR_NOSYSTEM`), so external filters,
21
+ * signing programs, pagers and editors cannot be injected;
22
+ * - never prompt (`GIT_TERMINAL_PROMPT=0`, askpass disabled) and fail
23
+ * closed on any transport helper (`GIT_SSH_COMMAND=false`);
24
+ * - pin a fixed committer/author identity so commits succeed without
25
+ * inheriting ambient user config.
26
+ *
27
+ * The runner also refuses caller-supplied `-c`/`-C`/`--exec`/`--upload-pack`/
28
+ * `--receive-pack` arguments: hardening flags are prepended here and callers
29
+ * pass only fixed subcommands with data after `--`, so there is no channel to
30
+ * pass through options that could re-enable transport or run a program.
31
+ */
32
+ const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
33
+ /**
34
+ * Fixed option flags prepended to every managed invocation. These are applied
35
+ * before the subcommand and can never be overridden by repository-local config,
36
+ * because global/system config is disabled and no caller `-c` is accepted.
37
+ */
38
+ const HARDENING_FLAGS = Object.freeze([
39
+ // Identity — commits succeed without ambient user config.
40
+ "-c", "user.name=Yui",
41
+ "-c", "user.email=yui@local",
42
+ // Transport — nothing may move over the network or the filesystem.
43
+ "-c", "protocol.allow=never",
44
+ "-c", "protocol.file.allow=never",
45
+ // Hooks / filters / external programs — no committed script may execute.
46
+ "-c", `core.hooksPath=${NULL_DEVICE}`,
47
+ "-c", "core.fsmonitor=false",
48
+ "-c", "core.pager=cat",
49
+ "-c", "core.editor=false",
50
+ // Signing — never shell out to a signing program.
51
+ "-c", "commit.gpgsign=false",
52
+ "-c", "tag.gpgsign=false",
53
+ // Determinism / quiet.
54
+ "-c", "core.autocrlf=false",
55
+ "-c", "gc.auto=0",
56
+ "-c", "advice.detachedHead=false"
57
+ ]);
58
+ /** Arguments a caller may never pass: they could re-enable transport or run a program. */
59
+ const FORBIDDEN_ARGUMENTS = Object.freeze([
60
+ "-c", "-C", "--exec-path", "--upload-pack", "--receive-pack", "--exec"
61
+ ]);
62
+ /** Raised when a managed Git command exits non-zero. Carries the exact stderr. */
63
+ export class ManagedGitError extends Error {
64
+ stderr;
65
+ args;
66
+ constructor(args, stderr, cause) {
67
+ super(stderr.length === 0 ? "Managed git command failed." : `Managed git command failed: ${stderr}`, { cause });
68
+ this.name = "ManagedGitError";
69
+ this.stderr = stderr;
70
+ this.args = args;
71
+ }
72
+ }
73
+ /** Build the scrubbed environment for a managed Git process. Never inherits ambient Git config. */
74
+ function managedGitEnvironment() {
75
+ return {
76
+ // A minimal, deterministic PATH so `git` (and only trusted helpers) resolve.
77
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
78
+ LANG: "C.UTF-8",
79
+ LC_ALL: "C.UTF-8",
80
+ // A neutral HOME keeps any accidental lookup inside a controlled area; config
81
+ // lookups are already redirected to the null device below.
82
+ HOME: process.platform === "win32" ? (process.env.USERPROFILE ?? "") : "/nonexistent",
83
+ GIT_CONFIG_NOSYSTEM: "1",
84
+ GIT_CONFIG_GLOBAL: NULL_DEVICE,
85
+ GIT_CONFIG_SYSTEM: NULL_DEVICE,
86
+ GIT_ATTR_NOSYSTEM: "1",
87
+ GIT_TERMINAL_PROMPT: "0",
88
+ GIT_ASKPASS: "",
89
+ SSH_ASKPASS: "",
90
+ GIT_SSH_COMMAND: "false",
91
+ GIT_PROTOCOL_FROM_USER: "0",
92
+ GIT_OPTIONAL_LOCKS: "0",
93
+ // Every pathspec is a literal path, never a glob. A file legitimately named
94
+ // `*.md` or `a?.md` must commit exactly itself and never sweep in undeclared
95
+ // sibling changes; this makes `add`/`diff`/`commit --only` scope uniformly
96
+ // and can never re-enable transport or run a program.
97
+ GIT_LITERAL_PATHSPECS: "1",
98
+ GIT_PAGER: "cat",
99
+ GIT_EDITOR: "false",
100
+ // Fixed identity as a second guarantee alongside the -c flags.
101
+ GIT_AUTHOR_NAME: "Yui",
102
+ GIT_AUTHOR_EMAIL: "yui@local",
103
+ GIT_COMMITTER_NAME: "Yui",
104
+ GIT_COMMITTER_EMAIL: "yui@local"
105
+ };
106
+ }
107
+ function assertSafeArguments(args) {
108
+ for (const arg of args) {
109
+ if (typeof arg !== "string")
110
+ throw new Error("Managed git argument must be a string.");
111
+ if (arg.includes("\0"))
112
+ throw new Error("Managed git argument contains a NUL byte.");
113
+ if (FORBIDDEN_ARGUMENTS.includes(arg)) {
114
+ throw new Error(`Managed git argument is not permitted: ${arg}.`);
115
+ }
116
+ }
117
+ }
118
+ /**
119
+ * Core managed invocation. `-C repoPath` and the hardening flags are supplied
120
+ * here; `args` carries only the subcommand and its data. Returns raw stdout and
121
+ * stderr buffers; throws {@link ManagedGitError} on non-zero exit.
122
+ */
123
+ async function spawnManagedGit(repoPath, args, options) {
124
+ assertSafeArguments(args);
125
+ try {
126
+ const result = await executeFile("git", [...HARDENING_FLAGS, "-C", repoPath, ...args], {
127
+ encoding: "buffer",
128
+ env: managedGitEnvironment(),
129
+ maxBuffer: options?.maxBuffer ?? 16 * 1024 * 1024,
130
+ timeout: options?.timeoutMs ?? 30_000,
131
+ windowsHide: true
132
+ });
133
+ return { stdout: result.stdout, stderr: result.stderr };
134
+ }
135
+ catch (error) {
136
+ const stderr = readErrorStream(error);
137
+ throw new ManagedGitError([...args], stderr, error);
138
+ }
139
+ }
140
+ function readErrorStream(error) {
141
+ if (typeof error === "object" && error !== null && "stderr" in error) {
142
+ const stderr = error.stderr;
143
+ if (Buffer.isBuffer(stderr))
144
+ return stderr.toString("utf8").trim();
145
+ if (typeof stderr === "string")
146
+ return stderr.trim();
147
+ }
148
+ return "";
149
+ }
150
+ /** Run a managed Git command and return trimmed UTF-8 stdout. Throws on failure. */
151
+ export async function managedGit(repoPath, args, options) {
152
+ const { stdout } = await spawnManagedGit(repoPath, args, options);
153
+ return stdout.toString("utf8");
154
+ }
155
+ /** Run a managed Git command and return raw stdout bytes (for reading blob content). Throws on failure. */
156
+ export async function managedGitBuffer(repoPath, args, options) {
157
+ const { stdout } = await spawnManagedGit(repoPath, args, options);
158
+ return stdout;
159
+ }
160
+ /** Run a managed Git command purely for its exit status; never throws on non-zero. */
161
+ export async function managedGitSucceeds(repoPath, args, options) {
162
+ try {
163
+ await spawnManagedGit(repoPath, args, options);
164
+ return true;
165
+ }
166
+ catch (error) {
167
+ if (error instanceof ManagedGitError)
168
+ return false;
169
+ throw error;
170
+ }
171
+ }
172
+ /** Validate and normalize a Git object id from managed output (40- or 64-hex). */
173
+ export function requireCommitId(value) {
174
+ const commit = value.trim();
175
+ if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(commit)) {
176
+ throw new Error("Managed git returned an invalid commit id.");
177
+ }
178
+ return commit;
179
+ }
180
+ /**
181
+ * Repo-local config keys that can make Git execute an EXTERNAL PROGRAM during an
182
+ * ordinary `add`/`diff`/`commit`/`show` — the vector env-scrubbing does not close.
183
+ *
184
+ * Disabling global/system config (see {@link managedGitEnvironment}) stops
185
+ * ambient filters/hooks, but a repository's OWN `.git/config` is always read, and
186
+ * a `[filter "x"] clean = <cmd>` there (paired with a worktree `.gitattributes`
187
+ * `* filter=x`) runs `<cmd>` on `git add`. Some keys (`core.pager`, hooks, signing)
188
+ * are already forced to safe values by the `-c` {@link HARDENING_FLAGS}, but an
189
+ * arbitrarily NAMED filter/alias/tool driver cannot be overridden by a fixed `-c`.
190
+ *
191
+ * Yui configures NONE of these on a managed artifact repository, so any that
192
+ * appear in repo-local config are external tampering. We DETECT and STOP (like an
193
+ * unexpected remote); we never silently delete the config or try to out-configure
194
+ * an arbitrary command. Keys are matched case-insensitively (Git config is).
195
+ *
196
+ * The direct scan sees only repo-local keys, so it also rejects the config
197
+ * INCLUSION entry points (`include.*`, `includeIf.*`, `extensions.worktreeConfig`)
198
+ * that could otherwise hide any of the below in a file this scan never opens —
199
+ * see {@link isExternalProgramConfigKey}.
200
+ */
201
+ const EXTERNAL_PROGRAM_CONFIG_SUFFIXES = Object.freeze([
202
+ ".clean", ".smudge", ".process", // filter.<name>.*
203
+ ".command", ".textconv", ".cmd", // diff/difftool/mergetool.<name>.*
204
+ ".driver", // merge.<name>.driver
205
+ ".helper" // credential[.<url>].helper
206
+ ]);
207
+ const EXTERNAL_PROGRAM_CONFIG_PREFIXES = Object.freeze([
208
+ "filter.", "alias.", "difftool.", "mergetool.", "pager.",
209
+ "sendemail.", "instaweb.", "guitool.", "credential.", "url.",
210
+ "browser.", "man.", "hooks."
211
+ ]);
212
+ const EXTERNAL_PROGRAM_CONFIG_KEYS = new Set([
213
+ "diff.external",
214
+ "core.pager", "core.editor", "core.sshcommand", "core.askpass",
215
+ "core.gitproxy", "core.fsmonitor", "core.hookspath", "core.alternaterefscommand",
216
+ "gpg.program", "gpg.openpgp.program", "gpg.x509.program", "gpg.ssh.program",
217
+ "sequence.editor", "uploadpack.packobjectshook",
218
+ "web.browser", "help.browser"
219
+ ]);
220
+ /**
221
+ * Config keys that IMPORT or ENABLE another config scope. `git config --local
222
+ * --list -z` lists these keys but does NOT expand them, so a `filter.*.clean`
223
+ * hidden inside an included file (or the per-worktree config) is invisible to
224
+ * the direct external-program scan above — yet an ordinary `git add`/`status`
225
+ * DOES follow includes and would run that hidden filter. A managed artifact
226
+ * repository never needs an include or a per-worktree config, so we reject the
227
+ * ENTRY POINT itself rather than parsing the (arbitrary, possibly nested)
228
+ * included files: `include.path`, any `includeIf.<condition>.path`, and the
229
+ * `extensions.worktreeConfig` switch that activates `.git/config.worktree`.
230
+ * Keys are already lower-cased by Git config.
231
+ */
232
+ const CONFIG_INCLUSION_CONFIG_PREFIXES = Object.freeze([
233
+ "include.", "includeif."
234
+ ]);
235
+ const CONFIG_INCLUSION_CONFIG_KEYS = new Set([
236
+ "extensions.worktreeconfig"
237
+ ]);
238
+ function isExternalProgramConfigKey(key) {
239
+ if (EXTERNAL_PROGRAM_CONFIG_KEYS.has(key))
240
+ return true;
241
+ for (const prefix of EXTERNAL_PROGRAM_CONFIG_PREFIXES)
242
+ if (key.startsWith(prefix))
243
+ return true;
244
+ for (const suffix of EXTERNAL_PROGRAM_CONFIG_SUFFIXES)
245
+ if (key.endsWith(suffix))
246
+ return true;
247
+ // A config-inclusion / alternate-scope entry point can smuggle any of the
248
+ // above in a file this direct scan never opens; reject the entry point itself.
249
+ if (CONFIG_INCLUSION_CONFIG_KEYS.has(key))
250
+ return true;
251
+ for (const prefix of CONFIG_INCLUSION_CONFIG_PREFIXES)
252
+ if (key.startsWith(prefix))
253
+ return true;
254
+ return false;
255
+ }
256
+ /**
257
+ * Given the raw stdout of `git config --local --list -z` (records of the form
258
+ * `key\nvalue\0`, keys already lower-cased by Git), return the sorted, de-duped
259
+ * list of repo-local keys that can execute an external program. Empty means the
260
+ * repository's own config is within the managed boundary.
261
+ *
262
+ * PURE: no I/O. Callers (sync migration and async runtime) read the config with
263
+ * their own managed runner and pass the bytes here, so the security policy lives
264
+ * in exactly one testable place. Reading `--local` deliberately excludes the
265
+ * command-line `-c` hardening flags (which are safe and not persisted) and the
266
+ * null-device'd global/system config; it sees only what is written in the repo.
267
+ */
268
+ export function externalProgramConfigViolations(localConfigListZ) {
269
+ const offending = new Set();
270
+ for (const record of localConfigListZ.split("\0")) {
271
+ if (record.length === 0)
272
+ continue;
273
+ const newline = record.indexOf("\n");
274
+ const key = (newline < 0 ? record : record.slice(0, newline)).toLowerCase();
275
+ if (isExternalProgramConfigKey(key))
276
+ offending.add(key);
277
+ }
278
+ return [...offending].sort();
279
+ }
280
+ /**
281
+ * SYNCHRONOUS managed invocation, used ONLY where an async runner is
282
+ * structurally impossible: a storage `migrateData(db)` step runs inside
283
+ * `db.transaction(...)`, which better-sqlite3 requires to be synchronous, yet
284
+ * the 18->19 migration must build per-Task artifact repositories on disk. This
285
+ * shares the SAME hardening as {@link spawnManagedGit} — identical argument
286
+ * refusal, identical scrubbed environment, identical prepended flags — so the
287
+ * synchronous path never weakens the trust boundary. It is not exported for
288
+ * ordinary runtime use; the async runner remains the only production write path.
289
+ */
290
+ function spawnManagedGitSync(repoPath, args, options) {
291
+ assertSafeArguments(args);
292
+ const env = managedGitEnvironment();
293
+ if (options?.commitDates !== undefined) {
294
+ // Deterministic history: fixed author/committer dates make a rebuild of the
295
+ // same source data reproduce the same commit ids. This only sets metadata.
296
+ env.GIT_AUTHOR_DATE = options.commitDates.author;
297
+ env.GIT_COMMITTER_DATE = options.commitDates.committer;
298
+ }
299
+ try {
300
+ const stdout = execFileSync("git", [...HARDENING_FLAGS, "-C", repoPath, ...args], {
301
+ encoding: "buffer",
302
+ env,
303
+ maxBuffer: options?.maxBuffer ?? 16 * 1024 * 1024,
304
+ timeout: options?.timeoutMs ?? 30_000,
305
+ windowsHide: true
306
+ });
307
+ return { stdout: stdout, stderr: Buffer.alloc(0) };
308
+ }
309
+ catch (error) {
310
+ throw new ManagedGitError([...args], readErrorStream(error), error);
311
+ }
312
+ }
313
+ /** Synchronous counterpart to {@link managedGit}; returns trimmed UTF-8 stdout. */
314
+ export function managedGitSync(repoPath, args, options) {
315
+ return spawnManagedGitSync(repoPath, args, options).stdout.toString("utf8");
316
+ }
317
+ /** Synchronous counterpart to {@link managedGitBuffer}; returns raw stdout bytes. */
318
+ export function managedGitSyncBuffer(repoPath, args, options) {
319
+ return spawnManagedGitSync(repoPath, args, options).stdout;
320
+ }
321
+ /** Synchronous counterpart to {@link managedGitSucceeds}; never throws on non-zero exit. */
322
+ export function managedGitSyncSucceeds(repoPath, args, options) {
323
+ try {
324
+ spawnManagedGitSync(repoPath, args, options);
325
+ return true;
326
+ }
327
+ catch (error) {
328
+ if (error instanceof ManagedGitError)
329
+ return false;
330
+ throw error;
331
+ }
332
+ }
@@ -0,0 +1,277 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { dirname, join } from "node:path";
5
+ import { ManagedGitError, externalProgramConfigViolations, managedGit, managedGitBuffer, managedGitSucceeds, requireCommitId } from "./managedGit.js";
6
+ import { acquireArtifactCommitLock } from "./artifactCommitLock.js";
7
+ import { resolveContainedArtifactPath, safeRelativeArtifactPath, taskArtifactRepoPath } from "./artifactPaths.js";
8
+ /**
9
+ * Per-Task local-only Git artifact repository.
10
+ *
11
+ * This is the authority for a Task's file/directory artifacts: complete plans,
12
+ * prototypes, charts, reports and other multi-file material. It is a plain
13
+ * local Git repository under `<YUI_HOME>/task-artifacts/<task-id>/` with:
14
+ *
15
+ * - NO remote of any kind (network or file). A remote appearing in the repo
16
+ * is treated as an external violation: {@link assertNoRemote} reports it and
17
+ * stops managed writes; the configuration is never silently removed.
18
+ * - a hardened, isolated Git subprocess (see {@link managedGit}) that cannot
19
+ * reach the network, run hooks/filters, or read ambient user/system config.
20
+ * - fixed committer identity and an empty root commit, so HEAD always exists
21
+ * and per-path scoped commits work uniformly from the first real save.
22
+ *
23
+ * Save model (§3.4): write file(s) → validate scope → local commit → saved.
24
+ * A successful commit IS the save; there is no DB round-trip on the write path.
25
+ * Uncommitted changes are work-in-progress. A commit failure preserves the
26
+ * files exactly and surfaces the precise error — nothing is reset or cleaned.
27
+ * One meaningful update is one commit. A short per-Task lock plus an
28
+ * expected-HEAD check make concurrent saves safe; a conflict STOPS (no
29
+ * overwrite, no auto-merge). Branches/PRs/rebase are never exposed.
30
+ *
31
+ * Artifacts are savable from the Draft stage: this store depends only on the
32
+ * Home, never on an active delivery workspace or Project checkout.
33
+ */
34
+ /** The single managed branch. Never surfaced to callers as a Git concept. */
35
+ const ARTIFACT_BRANCH = "main";
36
+ /** Upper bound for a single artifact file, mirroring the immutable-artifact cap. */
37
+ const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024;
38
+ /** Raised when an artifact repository has a remote configured — an external violation. */
39
+ export class ArtifactRemoteViolationError extends Error {
40
+ taskId;
41
+ remotes;
42
+ constructor(taskId, remotes) {
43
+ super(`Artifact repository for ${taskId} has an external remote configured (${remotes.join(", ")}); ` +
44
+ `managed writes are stopped. Remove the remote manually to resume.`);
45
+ this.name = "ArtifactRemoteViolationError";
46
+ this.taskId = taskId;
47
+ this.remotes = [...remotes];
48
+ }
49
+ }
50
+ /**
51
+ * Raised when an artifact repository's OWN config declares a filter/alias/tool
52
+ * that would run an external program during add/diff/commit. Env-scrubbing hides
53
+ * ambient config, but a repo's `.git/config` is always read; Yui configures none
54
+ * of these, so any that appear are external tampering. Like a remote: report and
55
+ * STOP managed writes, never silently delete the config.
56
+ */
57
+ export class ArtifactManagedConfigViolationError extends Error {
58
+ taskId;
59
+ keys;
60
+ constructor(taskId, keys) {
61
+ super(`Artifact repository for ${taskId} has repo-local Git config that can run external ` +
62
+ `programs (${keys.join(", ")}); managed writes are stopped. Remove it manually to resume.`);
63
+ this.name = "ArtifactManagedConfigViolationError";
64
+ this.taskId = taskId;
65
+ this.keys = [...keys];
66
+ }
67
+ }
68
+ /** Raised when a save's expected HEAD no longer matches — a concurrent update won. */
69
+ export class ArtifactHeadConflictError extends Error {
70
+ taskId;
71
+ expectedHead;
72
+ actualHead;
73
+ retryable = true;
74
+ constructor(taskId, expectedHead, actualHead) {
75
+ super(`Artifact repository for ${taskId} advanced since it was read ` +
76
+ `(expected ${expectedHead}, found ${actualHead}); no changes were made. Re-read and retry.`);
77
+ this.name = "ArtifactHeadConflictError";
78
+ this.taskId = taskId;
79
+ this.expectedHead = expectedHead;
80
+ this.actualHead = actualHead;
81
+ }
82
+ }
83
+ /**
84
+ * Open (not necessarily create) the artifact repository for a Task. The repo
85
+ * path is derived directly from the Home; call {@link TaskArtifactRepository.ensure}
86
+ * before the first save. `home` MUST be the resolved YUI_HOME
87
+ * (e.g. `store.rootDirectory()`), so ownership is derived from the Task, never
88
+ * from ambient state.
89
+ */
90
+ export function openTaskArtifactRepository(home, taskId) {
91
+ const repoPath = taskArtifactRepoPath(home, taskId);
92
+ const exists = () => existsSync(join(repoPath, ".git"));
93
+ const head = async () => {
94
+ if (!exists())
95
+ return null;
96
+ return currentHead(repoPath);
97
+ };
98
+ const ensure = async () => {
99
+ if (exists()) {
100
+ await assertNoRemote(taskId, repoPath);
101
+ return;
102
+ }
103
+ await mkdir(repoPath, { recursive: true, mode: 0o700 });
104
+ await managedGit(repoPath, ["init", "-b", ARTIFACT_BRANCH]);
105
+ // An empty root commit guarantees HEAD exists, so scoped per-path commits
106
+ // and expected-HEAD checks work uniformly from the very first save.
107
+ await managedGit(repoPath, ["commit", "--allow-empty", "-m", "init artifact repo"]);
108
+ await assertNoRemote(taskId, repoPath);
109
+ };
110
+ const save = async (input) => {
111
+ if (input.files.length === 0) {
112
+ throw new Error("An artifact save must include at least one file.");
113
+ }
114
+ // Validate the whole request up front (fail-fast): a caller error must never
115
+ // leave half-written files behind. Only genuine Git failures after this
116
+ // point exercise the preserve-on-failure path.
117
+ const message = requireMessage(input.message);
118
+ const prepared = input.files.map((file) => {
119
+ const relativePath = safeRelativeArtifactPath(file.relativePath);
120
+ if (!Buffer.isBuffer(file.bytes)) {
121
+ throw new Error(`Artifact ${relativePath} bytes must be a Buffer.`);
122
+ }
123
+ if (file.bytes.byteLength > MAX_ARTIFACT_BYTES) {
124
+ throw new Error(`Artifact ${relativePath} exceeds the ${MAX_ARTIFACT_BYTES}-byte limit.`);
125
+ }
126
+ return { relativePath, bytes: file.bytes };
127
+ });
128
+ // Reject duplicate targets in one save — ambiguous intent.
129
+ const seen = new Set();
130
+ for (const file of prepared) {
131
+ if (seen.has(file.relativePath)) {
132
+ throw new Error(`Artifact ${file.relativePath} appears more than once in one save.`);
133
+ }
134
+ seen.add(file.relativePath);
135
+ }
136
+ await ensure();
137
+ const release = acquireArtifactCommitLock(home, taskId);
138
+ try {
139
+ await assertNoRemote(taskId, repoPath);
140
+ // Expected-HEAD check under the lock: a conflict stops without writing.
141
+ if (input.expectedHead !== undefined) {
142
+ const actual = await currentHead(repoPath);
143
+ if (actual !== input.expectedHead) {
144
+ throw new ArtifactHeadConflictError(taskId, input.expectedHead, actual);
145
+ }
146
+ }
147
+ // Write files first. A commit failure below preserves exactly these bytes;
148
+ // nothing is reset or cleaned, so a caller can inspect and retry.
149
+ for (const file of prepared) {
150
+ const absolute = await resolveContainedArtifactPath(repoPath, file.relativePath);
151
+ await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
152
+ await writeFile(absolute, file.bytes, { mode: 0o600 });
153
+ }
154
+ const pathspecs = prepared.map((file) => file.relativePath);
155
+ // Stage exactly the saved paths so new files become known to Git...
156
+ await managedGit(repoPath, ["add", "--", ...pathspecs]);
157
+ // ...but if nothing actually changed, do not create an empty commit.
158
+ const noChanges = await managedGitSucceeds(repoPath, [
159
+ "diff", "--cached", "--quiet", "--", ...pathspecs
160
+ ]);
161
+ if (noChanges) {
162
+ return { taskId, commit: await currentHead(repoPath), savedPaths: pathspecs };
163
+ }
164
+ // `--only <paths>` records exactly these paths; any other WIP stays WIP.
165
+ await managedGit(repoPath, ["commit", "--only", "-m", message, "--", ...pathspecs]);
166
+ return { taskId, commit: await currentHead(repoPath), savedPaths: pathspecs };
167
+ }
168
+ finally {
169
+ release();
170
+ }
171
+ };
172
+ const read = async (relativePath, commit) => {
173
+ const safeRelative = safeRelativeArtifactPath(relativePath);
174
+ // A pinned commit reads frozen evidence; otherwise read the current HEAD.
175
+ // Both resolve `<commit>:<path>`, an OBJECT spec (never the working tree, so
176
+ // a read never follows a symlink or sees unrelated WIP).
177
+ const pinned = commit === undefined ? await currentHead(repoPath) : requireCommitId(commit);
178
+ const objectSpec = `${pinned}:${safeRelative}`;
179
+ // `<commit>:<path>` for a DIRECTORY resolves to a tree, and `git show` would
180
+ // print a tree listing rather than fail — silently returning directory
181
+ // metadata as if it were file bytes. Verify the object is a blob first, so a
182
+ // read only ever yields real file content.
183
+ const objectType = (await managedGit(repoPath, ["cat-file", "-t", objectSpec]).catch((error) => {
184
+ if (error instanceof ManagedGitError) {
185
+ throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}.`, { cause: error });
186
+ }
187
+ throw error;
188
+ })).trim();
189
+ if (objectType !== "blob") {
190
+ throw new Error(`Artifact ${safeRelative} at ${pinned} is not a file (${objectType}).`);
191
+ }
192
+ const bytes = await managedGitBuffer(repoPath, ["cat-file", "blob", objectSpec], {
193
+ maxBuffer: MAX_ARTIFACT_BYTES + 4096
194
+ }).catch((error) => {
195
+ if (error instanceof ManagedGitError) {
196
+ throw new Error(`Artifact ${safeRelative} is unavailable at ${pinned}.`, { cause: error });
197
+ }
198
+ throw error;
199
+ });
200
+ return {
201
+ relativePath: safeRelative,
202
+ commit: pinned,
203
+ bytes,
204
+ digest: createHash("sha256").update(bytes).digest("hex")
205
+ };
206
+ };
207
+ const list = async (commit) => {
208
+ if (!exists())
209
+ return [];
210
+ const pinned = commit === undefined ? await currentHead(repoPath) : requireCommitId(commit);
211
+ const output = await managedGit(repoPath, [
212
+ "ls-tree", "-r", "-z", "--long", "--full-tree", pinned
213
+ ]);
214
+ const entries = [];
215
+ for (const record of output.split("\0").filter(Boolean)) {
216
+ // Format: "<mode> <type> <object> <size>\t<path>"
217
+ const tab = record.indexOf("\t");
218
+ if (tab < 0)
219
+ continue;
220
+ const meta = record.slice(0, tab).split(/\s+/u);
221
+ const path = record.slice(tab + 1);
222
+ const size = Number.parseInt(meta[3] ?? "", 10);
223
+ if (path === ".gitignore")
224
+ continue;
225
+ entries.push({ relativePath: path, size: Number.isFinite(size) ? size : 0 });
226
+ }
227
+ return entries;
228
+ };
229
+ const workingChanges = async () => {
230
+ if (!exists())
231
+ return [];
232
+ const output = await managedGit(repoPath, [
233
+ "status", "--porcelain=v1", "--untracked-files=all", "-z"
234
+ ]);
235
+ // Porcelain v1 -z records: "XY <path>\0" (rename adds a second \0 field).
236
+ const changes = [];
237
+ for (const record of output.split("\0").filter(Boolean)) {
238
+ const path = record.slice(3);
239
+ if (path.length > 0)
240
+ changes.push(path);
241
+ }
242
+ return changes;
243
+ };
244
+ return { taskId, repoPath, ensure, exists, head, save, read, list, workingChanges };
245
+ }
246
+ async function currentHead(repoPath) {
247
+ return requireCommitId(await managedGit(repoPath, ["rev-parse", "HEAD^{commit}"]));
248
+ }
249
+ /**
250
+ * Verify the repository is within the managed boundary before a write: NO remote
251
+ * (network or file sync) AND no repo-local config that can execute an external
252
+ * program on add/diff/commit. Both are external violations — we report and stop,
253
+ * never silently remove them.
254
+ */
255
+ async function assertNoRemote(taskId, repoPath) {
256
+ const output = await managedGit(repoPath, ["remote"]);
257
+ const remotes = output.split("\n").map((line) => line.trim()).filter(Boolean);
258
+ if (remotes.length > 0) {
259
+ throw new ArtifactRemoteViolationError(taskId, remotes);
260
+ }
261
+ // A repo's own `.git/config` is read even with global/system config disabled,
262
+ // so a committed `.gitattributes` filter paired with a repo-local
263
+ // `filter.<n>.clean = <cmd>` would run `<cmd>` on `git add`. Detect and stop.
264
+ const configListZ = await managedGit(repoPath, ["config", "--local", "--list", "-z"]);
265
+ const violations = externalProgramConfigViolations(configListZ);
266
+ if (violations.length > 0) {
267
+ throw new ArtifactManagedConfigViolationError(taskId, violations);
268
+ }
269
+ }
270
+ function requireMessage(message) {
271
+ const trimmed = typeof message === "string" ? message.trim() : "";
272
+ if (trimmed.length === 0)
273
+ throw new Error("An artifact save requires a commit message.");
274
+ if (trimmed.startsWith("-"))
275
+ throw new Error("An artifact commit message may not begin with a dash.");
276
+ return trimmed;
277
+ }
@@ -380,12 +380,12 @@ const taskChildren = [
380
380
  { name: "show", summary: "Show a Task.", usage: "yui task show <id>" },
381
381
  {
382
382
  name: "artifact",
383
- summary: "Save fixed Task results and read history without the original Runtime.",
384
- sections: [{ id: "manage", title: "Commands", entries: ["list", "show", "save"] }],
383
+ summary: "Save Task files in local Git and read current or commit-pinned content.",
384
+ sections: [{ id: "manage", title: "Commands", entries: ["list", "read", "save"] }],
385
385
  children: [
386
386
  { name: "list", summary: "List saved Task artifacts.", usage: "yui task artifact list <task>" },
387
- { name: "show", summary: "Read one saved artifact.", usage: "yui task artifact show <task> <artifact-id>" },
388
- { name: "save", summary: "Save content, a version, receipt, or reference.", usage: "yui task artifact save <task> <artifact-json>" }
387
+ { name: "read", summary: "Read a file at HEAD or an exact commit.", usage: "yui task artifact read <task> <relative-path> [<commit>]" },
388
+ { name: "save", summary: "Save and locally commit one file.", usage: "yui task artifact save <task> <relative-path> <content> [--message <text>] [--expected-head <commit>]", options: ["--message", "--expected-head"] }
389
389
  ]
390
390
  },
391
391
  {
@@ -445,10 +445,11 @@ const taskChildren = [
445
445
  },
446
446
  {
447
447
  name: "send",
448
- summary: "Send a Task message.",
449
- usage: "yui task message send <id> (<body>|--body-file <path|->) [--wake-policy leader|none] [--to <role> --work-item <id>|--review-round <id>]",
450
- options: ["--body-file", "--wake-policy", "--to", "--work-item", "--review-round"],
448
+ summary: "Send a Task message. An unaddressed user/operator message carries a submission intent (record|discuss|develop) and an optional idempotency key.",
449
+ usage: "yui task message send <id> (<body>|--body-file <path|->) [--intent record|discuss|develop] [--request-id <key>] [--wake-policy leader|none] [--to <role> --work-item <id>|--review-round <id>]",
450
+ options: ["--body-file", "--intent", "--request-id", "--wake-policy", "--to", "--work-item", "--review-round"],
451
451
  optionValues: {
452
+ "--intent": ["record", "discuss", "develop"],
452
453
  "--wake-policy": ["leader", "none"]
453
454
  },
454
455
  fileOptions: ["--body-file"]
@@ -1286,9 +1287,12 @@ export const ROOT_COMMAND = buildNode({
1286
1287
  },
1287
1288
  {
1288
1289
  name: "submit",
1289
- summary: "Submit work through the Operator.",
1290
- usage: "yui operator submit (<body>|--body-file <path|->) [--task <id>]",
1291
- options: ["--task", "--body-file"],
1290
+ summary: "Submit work through the Operator with a submission intent (record|discuss|develop); a task-less submit opens a Draft. An optional idempotency key makes a retry safe.",
1291
+ usage: "yui operator submit (<body>|--body-file <path|->) [--task <id>] [--intent record|discuss|develop] [--request-id <key>]",
1292
+ options: ["--task", "--body-file", "--intent", "--request-id"],
1293
+ optionValues: {
1294
+ "--intent": ["record", "discuss", "develop"]
1295
+ },
1292
1296
  fileOptions: ["--body-file"]
1293
1297
  }
1294
1298
  ]