@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,338 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, rmSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { externalProgramConfigViolations, managedGitSync, managedGitSyncSucceeds, requireCommitId } from "../../artifacts/managedGit.js";
5
+ import { safePathSegment, safeRelativeArtifactPath, taskArtifactRepoPath, taskArtifactsRoot } from "../../artifacts/artifactPaths.js";
6
+ import { migrateSubmitIntent } from "./submitIntent.js";
7
+ /**
8
+ * Storage 18 -> 19: retire the DB-owned immutable Artifact table and move every
9
+ * historical Artifact into its Task's LOCAL Git artifact repository, then
10
+ * rewrite the Candidate/completion references that pointed at them so they pin
11
+ * an exact `commit + relativePath` instead of a mutable DB id.
12
+ *
13
+ * Two-class ownership (§3.1/§3.7): after v19 the DB is no longer an authority
14
+ * for file/directory artifacts — the per-Task repo at
15
+ * `<YUI_HOME>/task-artifacts/<task-id>/` is. This migration is the ONE-TIME
16
+ * bridge: it reads the soon-to-be-dropped `artifacts` table, reconstructs the
17
+ * bytes/descriptor as deterministic Git history, and leaves NO dual read/write
18
+ * surface behind (the table is dropped in the same transaction).
19
+ *
20
+ * Determinism & crash-safety. `migrateData` runs INSIDE the outer
21
+ * `db.transaction`, which better-sqlite3 requires to be synchronous, so this
22
+ * builds repositories with the synchronous managed-Git runner. Every commit
23
+ * (including the empty root) is pinned to a date derived purely from the
24
+ * Artifact data, and identity/message/tree are fixed, so rebuilding the same
25
+ * table produces byte-identical commit ids. The DB half is atomic (the upgrade
26
+ * orchestrator backs up `yui.db` and rolls back on any throw); the Git repos
27
+ * are the only non-transactional side effect, so each Task repo is built in a
28
+ * staging directory and atomically renamed into place, and a leftover from a
29
+ * previously rolled-back attempt is replaced by an identical rebuild.
30
+ *
31
+ * Boundaries. Managed Git is fully isolated (no remote, no hooks/filters, no
32
+ * network/file transport, no ambient config — see managedGit). This migration
33
+ * NEVER re-accesses a Job receipt tool and NEVER fetches a reference locator: it
34
+ * moves exactly the bytes/descriptor already frozen in the DB. Reference-kind
35
+ * Artifacts are preserved as history but are never a reference target (they were
36
+ * never fixed results). Completion strings that are not Artifact ids (e.g.
37
+ * `turn:<id>` or an explicit URL) are preserved verbatim.
38
+ */
39
+ /** The single managed branch, mirroring taskArtifactRepository. */
40
+ const ARTIFACT_BRANCH = "main";
41
+ /** Staging root for in-progress repository builds (hidden; never a Task id). */
42
+ const MIGRATION_STAGING = ".yui-artifact-migration";
43
+ /** Deterministic key for the id->reference map (task id + Artifact id). */
44
+ function refKey(taskId, artifactId) {
45
+ return `${taskId}\u0000${artifactId}`;
46
+ }
47
+ /** Stable JSON: object keys sorted at every depth, so bytes (and thus commit ids) are deterministic. */
48
+ function stableJson(value) {
49
+ const canonical = (input) => {
50
+ if (Array.isArray(input))
51
+ return input.map(canonical);
52
+ if (input !== null && typeof input === "object") {
53
+ const source = input;
54
+ const result = {};
55
+ for (const key of Object.keys(source).sort())
56
+ result[key] = canonical(source[key]);
57
+ return result;
58
+ }
59
+ return input;
60
+ };
61
+ return `${JSON.stringify(canonical(value), null, 2)}\n`;
62
+ }
63
+ /** The metadata record written for every migrated Artifact (content lives beside it, if any). */
64
+ function artifactRecord(artifact) {
65
+ const { content: _content, ...rest } = artifact;
66
+ return { ...rest, migratedFrom: { store: "artifacts", storageVersion: 18 } };
67
+ }
68
+ export function migrateArtifactsToGit(db) {
69
+ const home = dirname(db.name);
70
+ // A pure in-memory database (tests) has no Home to host repositories; there
71
+ // are no rows to move in that case, so treat it as an empty migration.
72
+ const rows = db.prepare("SELECT task_id, id, payload FROM artifacts").all();
73
+ const migrated = new Map();
74
+ if (rows.length > 0) {
75
+ if (db.name === ":memory:" || db.name.length === 0) {
76
+ throw new Error("Cannot migrate Artifacts to Git without an on-disk Home.");
77
+ }
78
+ const byTask = new Map();
79
+ for (const row of rows) {
80
+ const artifact = JSON.parse(row.payload);
81
+ const list = byTask.get(row.task_id) ?? [];
82
+ list.push(artifact);
83
+ byTask.set(row.task_id, list);
84
+ }
85
+ // Deterministic Task order, then deterministic Artifact order within a Task.
86
+ for (const taskId of [...byTask.keys()].sort()) {
87
+ const artifacts = byTask.get(taskId).slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
88
+ buildTaskRepository(home, taskId, artifacts, migrated);
89
+ }
90
+ // Every Task repo has been atomically renamed out of staging; remove the now
91
+ // empty staging root so it never masquerades as an entry under task-artifacts.
92
+ rmSync(join(taskArtifactsRoot(home), MIGRATION_STAGING), { recursive: true, force: true });
93
+ }
94
+ rewriteCandidateRefs(db, migrated);
95
+ rewriteCompletionRefs(db, migrated);
96
+ // No dual surface: the DB is no longer an authority for file artifacts.
97
+ db.exec("DROP TABLE artifacts");
98
+ // Requirement A's independent, deterministic, idempotent backfill runs LAST,
99
+ // inside this same outer transaction. It touches only events + id_sequences.
100
+ migrateSubmitIntent(db);
101
+ }
102
+ /**
103
+ * Build one Task's artifact repository from its historical Artifacts and record
104
+ * a frozen reference for each. Built in staging, then atomically swapped in.
105
+ */
106
+ function buildTaskRepository(home, taskId, artifacts, migrated) {
107
+ const root = taskArtifactsRoot(home);
108
+ mkdirSync(root, { recursive: true, mode: 0o700 });
109
+ const stagingRoot = join(root, MIGRATION_STAGING);
110
+ mkdirSync(stagingRoot, { recursive: true, mode: 0o700 });
111
+ const staging = join(stagingRoot, safePathSegment(taskId, "Task id"));
112
+ // Discard any partial staging from a previously interrupted attempt.
113
+ rmSync(staging, { recursive: true, force: true });
114
+ mkdirSync(staging, { recursive: true, mode: 0o700 });
115
+ // Root commit date: the earliest Artifact timestamp, so the whole history is a
116
+ // pure function of the data (no wall-clock, reproducible commit ids).
117
+ const rootDate = artifacts.reduce((earliest, artifact) => (artifact.createdAt < earliest ? artifact.createdAt : earliest), artifacts[0].createdAt);
118
+ managedGitSync(staging, ["init", "-b", ARTIFACT_BRANCH]);
119
+ managedGitSync(staging, ["commit", "--allow-empty", "-m", "init artifact repo"], {
120
+ commitDates: { author: rootDate, committer: rootDate }
121
+ });
122
+ assertManagedBoundary(taskId, staging);
123
+ for (const artifact of artifacts) {
124
+ const ref = commitArtifact(staging, artifact);
125
+ migrated.set(refKey(taskId, artifact.id), ref);
126
+ }
127
+ const builtHead = requireCommitId(managedGitSync(staging, ["rev-parse", "HEAD^{commit}"]));
128
+ // Adopt the freshly built repository WITHOUT ever destroying unknown data.
129
+ const finalPath = taskArtifactRepoPath(home, taskId);
130
+ adoptBuiltRepository(taskId, staging, finalPath, builtHead);
131
+ }
132
+ /**
133
+ * Move the freshly built repository into place WITHOUT ever clearing an unknown
134
+ * existing directory (fail-closed; no auto-repair).
135
+ *
136
+ * - No existing dir: atomic rename — there is nothing to lose.
137
+ * - Existing dir that is PROVABLY this exact build: a remnant of a previously
138
+ * rolled-back attempt. Keep it and discard the redundant staging build. Proof
139
+ * is its HEAD commit id (a hash over the whole tree AND commit history,
140
+ * reproducible only by this deterministic builder from this exact data), plus
141
+ * a clean working tree and the managed boundary (no remote / no
142
+ * external-program config) — so a dir with extra or modified files is NOT a
143
+ * remnant even if HEAD coincides.
144
+ * - Anything else: STOP. Preserve the existing directory and the staging build
145
+ * untouched and throw, so the outer transaction rolls the DB back and an
146
+ * operator can inspect. Never overwrite, clear, or auto-repair.
147
+ */
148
+ function adoptBuiltRepository(taskId, staging, finalPath, builtHead) {
149
+ if (!existsSync(finalPath)) {
150
+ renameSync(staging, finalPath);
151
+ assertManagedBoundary(taskId, finalPath);
152
+ return;
153
+ }
154
+ if (isProvenIdenticalRemnant(finalPath, builtHead)) {
155
+ // The existing repo already IS the intended, verified result; drop the build.
156
+ rmSync(staging, { recursive: true, force: true });
157
+ return;
158
+ }
159
+ throw new Error(`Refusing to overwrite existing artifact directory for ${taskId} at ${finalPath}: it is not a ` +
160
+ `proven byte-identical remnant of this migration (different HEAD, working-tree changes, or an ` +
161
+ `external remote/config). No data was modified; resolve it manually and re-run the upgrade.`);
162
+ }
163
+ /**
164
+ * True ONLY if `finalPath` is a managed Git repo whose HEAD equals `builtHead`,
165
+ * whose working tree is clean, and which is within the managed boundary. Any
166
+ * failure (not a repo, differing HEAD, dirty tree, remote/external config) means
167
+ * "not a remnant", so the caller fails closed rather than deleting it. Read-only.
168
+ *
169
+ * ORDER MATTERS: the managed-boundary check (remote + config scan — reads that
170
+ * never run a filter) runs BEFORE `git status`. `git status` re-hashes a
171
+ * same-size modified file through a `clean` filter to decide if it really
172
+ * differs, so running status first on a tampered repo could execute an external
173
+ * program before we ever reject it. The boundary scan also rejects config
174
+ * INCLUSION entry points, so a filter hidden behind `include.*` is refused here
175
+ * too — before status can follow the include.
176
+ */
177
+ function isProvenIdenticalRemnant(finalPath, builtHead) {
178
+ if (!managedGitSyncSucceeds(finalPath, ["rev-parse", "--is-inside-work-tree"]))
179
+ return false;
180
+ // Fail closed on any remote or external-program/inclusion config BEFORE the
181
+ // first working-tree inspection, so no `clean`/`smudge` filter can run.
182
+ if (!isWithinManagedBoundary(finalPath))
183
+ return false;
184
+ let head;
185
+ try {
186
+ head = requireCommitId(managedGitSync(finalPath, ["rev-parse", "HEAD^{commit}"]));
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ // A matching HEAD proves the entire committed history; a clean tree proves
192
+ // nothing was added or modified on top of that history. Only now that the
193
+ // boundary is proven is it safe to let `git status` touch the working tree.
194
+ if (head !== builtHead)
195
+ return false;
196
+ if (managedGitSync(finalPath, ["status", "--porcelain=v1", "--untracked-files=all", "-z"]).length > 0) {
197
+ return false;
198
+ }
199
+ return true;
200
+ }
201
+ /** Write one Artifact's files, commit exactly them, and return its frozen reference. */
202
+ function commitArtifact(repoPath, artifact) {
203
+ const idSegment = safePathSegment(artifact.id, "Artifact id");
204
+ const base = `migrated/${idSegment}`;
205
+ const recordPath = safeRelativeArtifactPath(`${base}/record.json`);
206
+ const files = [
207
+ { relativePath: recordPath, bytes: Buffer.from(stableJson(artifactRecord(artifact)), "utf8") }
208
+ ];
209
+ let target = recordPath;
210
+ let digest;
211
+ if ((artifact.kind === "content" || artifact.kind === "receipt") && typeof artifact.content === "string") {
212
+ const contentPath = safeRelativeArtifactPath(`${base}/content`);
213
+ const bytes = Buffer.from(artifact.content, "utf8");
214
+ files.push({ relativePath: contentPath, bytes });
215
+ target = contentPath;
216
+ // sha256 of the exact committed bytes; equals the old ref's content digest.
217
+ digest = createHash("sha256").update(bytes).digest("hex");
218
+ }
219
+ for (const file of files) {
220
+ const absolute = join(repoPath, file.relativePath);
221
+ mkdirSync(dirname(absolute), { recursive: true, mode: 0o700 });
222
+ writeFileSync(absolute, file.bytes, { mode: 0o600 });
223
+ }
224
+ const pathspecs = files.map((file) => file.relativePath);
225
+ managedGitSync(repoPath, ["add", "--", ...pathspecs]);
226
+ managedGitSync(repoPath, ["commit", "--only", "-m", `migrate artifact ${artifact.id}`, "--", ...pathspecs], {
227
+ commitDates: { author: artifact.createdAt, committer: artifact.createdAt }
228
+ });
229
+ const commit = requireCommitId(managedGitSync(repoPath, ["rev-parse", "HEAD^{commit}"]));
230
+ // A reference target is never a reference-kind Artifact (those were never
231
+ // fixed results), but such Artifacts are still preserved above as history.
232
+ return artifact.kind === "reference"
233
+ ? { commit, relativePath: recordPath }
234
+ : { commit, relativePath: target, ...(digest === undefined ? {} : { digest }) };
235
+ }
236
+ /** Verify a freshly built repository is within the managed boundary; throw otherwise. */
237
+ function assertManagedBoundary(taskId, repoPath) {
238
+ const remotes = managedGitSync(repoPath, ["remote"]).split("\n").map((line) => line.trim()).filter(Boolean);
239
+ if (remotes.length > 0) {
240
+ throw new Error(`Migrated artifact repository for ${taskId} unexpectedly has a remote: ${remotes.join(", ")}.`);
241
+ }
242
+ const configViolations = externalProgramConfigViolations(managedGitSync(repoPath, ["config", "--local", "--list", "-z"]));
243
+ if (configViolations.length > 0) {
244
+ throw new Error(`Migrated artifact repository for ${taskId} has repo-local config that can run external ` +
245
+ `programs: ${configViolations.join(", ")}.`);
246
+ }
247
+ // A managed repo must respond to a trivial plumbing query; fail closed otherwise.
248
+ if (!managedGitSyncSucceeds(repoPath, ["rev-parse", "--is-inside-work-tree"])) {
249
+ throw new Error(`Migrated artifact repository for ${taskId} did not initialize correctly.`);
250
+ }
251
+ }
252
+ /** Read-only managed-boundary predicate (no remote, no external-program config). Never throws. */
253
+ function isWithinManagedBoundary(repoPath) {
254
+ const remotes = managedGitSync(repoPath, ["remote"]).split("\n").map((line) => line.trim()).filter(Boolean);
255
+ if (remotes.length > 0)
256
+ return false;
257
+ return externalProgramConfigViolations(managedGitSync(repoPath, ["config", "--local", "--list", "-z"])).length === 0;
258
+ }
259
+ /** An upgradable Candidate reference resolved to its exact commit and path. */
260
+ function upgradeCandidateRef(taskId, ref, migrated) {
261
+ if (ref === null || typeof ref !== "object")
262
+ return ref;
263
+ const source = ref;
264
+ // Already a commit-pinned Git reference (defensive; a fresh v18 row is never this).
265
+ if (typeof source.commit === "string" && typeof source.relativePath === "string")
266
+ return ref;
267
+ const refTaskId = typeof source.taskId === "string" ? source.taskId : taskId;
268
+ const artifactId = source.artifactId;
269
+ if (typeof artifactId !== "string") {
270
+ throw new Error("Candidate artifact reference has no artifact id to migrate.");
271
+ }
272
+ const found = migrated.get(refKey(refTaskId, artifactId));
273
+ if (found === undefined) {
274
+ throw new Error(`Candidate artifact reference has no migrated Artifact: ${refTaskId}/${artifactId}.`);
275
+ }
276
+ return {
277
+ taskId: refTaskId,
278
+ commit: found.commit,
279
+ relativePath: found.relativePath,
280
+ ...(found.digest === undefined ? {} : { digest: found.digest })
281
+ };
282
+ }
283
+ /** Rewrite Candidate artifactRefs in work_items payloads (and the defensive candidate table). */
284
+ function rewriteCandidateRefs(db, migrated) {
285
+ for (const row of db.prepare("SELECT task_id, work_item_id, payload FROM work_items").all()) {
286
+ const item = JSON.parse(row.payload);
287
+ if (!Array.isArray(item.candidates))
288
+ continue;
289
+ item.candidates = item.candidates.map((candidate) => {
290
+ if (candidate === null || typeof candidate !== "object")
291
+ return candidate;
292
+ const record = candidate;
293
+ if (!Array.isArray(record.artifactRefs))
294
+ return candidate;
295
+ return {
296
+ ...record,
297
+ artifactRefs: record.artifactRefs.map((ref) => upgradeCandidateRef(row.task_id, ref, migrated))
298
+ };
299
+ });
300
+ const next = JSON.stringify(item);
301
+ if (next !== row.payload) {
302
+ db.prepare("UPDATE work_items SET payload = ? WHERE task_id = ? AND work_item_id = ?")
303
+ .run(next, row.task_id, row.work_item_id);
304
+ }
305
+ }
306
+ // The dedicated candidate table is not written by the current runtime, but a
307
+ // historical row must still migrate rather than be left with a stale shape.
308
+ for (const row of db.prepare("SELECT task_id, candidate_id, payload FROM work_item_candidates").all()) {
309
+ const candidate = JSON.parse(row.payload);
310
+ if (!Array.isArray(candidate.artifactRefs))
311
+ continue;
312
+ candidate.artifactRefs = candidate.artifactRefs.map((ref) => upgradeCandidateRef(row.task_id, ref, migrated));
313
+ const next = JSON.stringify(candidate);
314
+ if (next !== row.payload) {
315
+ db.prepare("UPDATE work_item_candidates SET payload = ? WHERE task_id = ? AND candidate_id = ?")
316
+ .run(next, row.task_id, row.candidate_id);
317
+ }
318
+ }
319
+ }
320
+ /** Rewrite completion artifact refs (string ids) into commit-pinned Git strings. */
321
+ function rewriteCompletionRefs(db, migrated) {
322
+ for (const row of db.prepare("SELECT task_id, payload FROM task_records").all()) {
323
+ const task = JSON.parse(row.payload);
324
+ if (!Array.isArray(task.completionArtifactRefs))
325
+ continue;
326
+ task.completionArtifactRefs = task.completionArtifactRefs.map((ref) => {
327
+ if (typeof ref !== "string" || ref.startsWith("git:"))
328
+ return ref;
329
+ const found = migrated.get(refKey(row.task_id, ref));
330
+ // Preserve non-Artifact completion strings (e.g. turn:<id> or a URL) verbatim.
331
+ return found === undefined ? ref : `git:${found.commit}:${found.relativePath}`;
332
+ });
333
+ const next = JSON.stringify(task);
334
+ if (next !== row.payload) {
335
+ db.prepare("UPDATE task_records SET payload = ? WHERE task_id = ?").run(next, row.task_id);
336
+ }
337
+ }
338
+ }
@@ -0,0 +1,126 @@
1
+ import { createTaskEvent } from "../../event/taskEvent.js";
2
+ /**
3
+ * Storage 18 -> 19 data step for task-32 Requirement A (submit intent & Draft
4
+ * auto-routing).
5
+ *
6
+ * This helper is INDEPENDENT, deterministic and idempotent. The unified 18 -> 19
7
+ * migration owned by the artifacts WorkItem REGISTERS it (as its `migrateData`)
8
+ * and bumps `CURRENT_STORAGE_VERSION`; this module neither registers a second
9
+ * migration nor changes any schema.
10
+ *
11
+ * Requirement A adds three optional fields inside `messages.payload`, all part of
12
+ * this same 18 -> 19 contract even though each is optional — "optional" governs a
13
+ * single record's shape, not the Home compatibility line, so the version
14
+ * transition is still declared here and carried by the unified migration:
15
+ *
16
+ * - `intent` (record | discuss | develop): an absent value already reads as
17
+ * `discuss` (never `develop`), so historical Messages need no rewrite.
18
+ * - `submissionKey`: the client-chosen idempotency key. Only ever set going
19
+ * forward; NO historical key is fabricated (task-32 §2.3, message review), so
20
+ * old Messages stay keyless and non-idempotent exactly as before.
21
+ * - `submissionReceipt`: the frozen disposition a keyed submission replays from.
22
+ * Written only alongside a new `submissionKey`, so it too never appears on
23
+ * historical data.
24
+ *
25
+ * Because none of the three needs a value backfilled onto old rows, the only data
26
+ * step this migration performs is the planning-entered reconstruction below.
27
+ *
28
+ * The one fact old data cannot express is "this Draft already entered planning".
29
+ * Before Requirement A a Leader-waking user/operator submission on a Draft WAS
30
+ * the planning discussion, but it left no `task.planning-entered` fact because
31
+ * that event type did not exist. After the upgrade `develop` auto-activates an
32
+ * *unplanned* Draft; without this step a Draft that was mid-discussion would be
33
+ * mis-read as unplanned and snatched into auto-activation (task-32 §2.2, §4).
34
+ *
35
+ * So for every Draft that received at least one legitimate historical planning
36
+ * submission this appends exactly one `task.planning-entered` event, which makes
37
+ * {@link draftHasEnteredPlanning} derive `true` and routes a later develop to
38
+ * manual activation. A qualifying submission is an *unaddressed* user/operator
39
+ * Message that was not save-only (`wakePolicy !== "none"`) — byte-for-byte the
40
+ * same shape the new discuss path routes through the shared submission service.
41
+ *
42
+ * This is deliberately the conservative direction: it can only ever make develop
43
+ * MORE cautious, never activate a Draft it should not. Save-only Messages (old
44
+ * `wakePolicy === "none"`, the shape `record` now names) are excluded, so a Draft
45
+ * that only ever recorded context stays legitimately unplanned and its first
46
+ * develop may still auto-activate. Non-Draft Tasks are skipped because
47
+ * {@link draftHasEnteredPlanning} answers only about Drafts, and Activation
48
+ * requests are left untouched — a legacy request's absent `origin` is the
49
+ * truthful marker that it stays behind explicit activation.
50
+ */
51
+ export function migrateSubmitIntent(db) {
52
+ const drafts = db.prepare("SELECT task_id FROM tasks_catalog WHERE status = 'draft'").all();
53
+ if (drafts.length === 0)
54
+ return;
55
+ const listMessages = db.prepare("SELECT payload FROM messages WHERE task_id = ? ORDER BY seq ASC");
56
+ const listEventRows = db.prepare("SELECT event_id, type FROM events WHERE task_id = ?");
57
+ const readHighWater = db.prepare("SELECT high_water FROM id_sequences WHERE task_id = ? AND kind = 'event'");
58
+ // Advance the per-Task event high-water to exactly the id we just consumed, so
59
+ // the runtime keeps allocating strictly above every migrated event (mirrors
60
+ // sqliteStore's id_sequences bookkeeping).
61
+ const upsertHighWater = db.prepare(`INSERT INTO id_sequences (task_id, kind, high_water) VALUES (?, 'event', ?)
62
+ ON CONFLICT(task_id, kind) DO UPDATE SET high_water = ?`);
63
+ const insertEvent = db.prepare("INSERT INTO events (task_id, event_id, type, occurred_at, payload) VALUES (?, ?, ?, ?, ?)");
64
+ for (const { task_id: taskId } of drafts) {
65
+ const eventRows = listEventRows.all(taskId);
66
+ // Idempotent: a Draft that already carries the fact (from a prior run of this
67
+ // migration, or from a real runtime planning entry) is left untouched.
68
+ if (eventRows.some((row) => row.type === PLANNING_ENTERED_EVENT_TYPE))
69
+ continue;
70
+ const entry = firstPlanningSubmission(listMessages.all(taskId));
71
+ if (entry === undefined)
72
+ continue;
73
+ // Allocate the next event id from the greater of the recorded high-water and
74
+ // the largest event id actually present, so a fixture or historical database
75
+ // whose id_sequences lags the events table can never produce a colliding id.
76
+ const highWater = readHighWater.get(taskId)?.high_water ?? 0;
77
+ const maxEventSeq = eventRows.reduce((max, row) => Math.max(max, eventSequence(row.event_id)), 0);
78
+ const seq = Math.max(highWater, maxEventSeq) + 1;
79
+ // Build through the shared constructor so a migrated event is byte-identical
80
+ // in shape to one the shared submission service writes at runtime, and dated
81
+ // to the historical submission rather than the wall-clock upgrade moment.
82
+ const event = createTaskEvent(`event-${seq}`, taskId, PLANNING_ENTERED_EVENT_TYPE, { messageId: entry.id, intent: "discuss", derivedBy: MIGRATION_PROVENANCE }, new Date(entry.createdAt));
83
+ insertEvent.run(taskId, event.id, event.type, event.createdAt, JSON.stringify(event));
84
+ upsertHighWater.run(taskId, seq, seq);
85
+ }
86
+ }
87
+ /**
88
+ * The never-compacted event type recording a Draft entered planning. Inlined as a
89
+ * literal rather than imported so this released migration stays frozen against a
90
+ * future runtime rename of the constant; `TASK_PLANNING_ENTERED_EVENT` is the
91
+ * runtime owner, and a tripwire test asserts the two never diverge.
92
+ */
93
+ const PLANNING_ENTERED_EVENT_TYPE = "task.planning-entered";
94
+ /** Marks the event as reconstructed by this migration, for later audit. */
95
+ const MIGRATION_PROVENANCE = "submit-intent-migration";
96
+ /**
97
+ * The earliest Message on a Draft that represents a historical planning
98
+ * submission, or `undefined` if the Draft only ever recorded save-only context.
99
+ *
100
+ * A qualifying Message is an unaddressed (`recipient === undefined`) user or
101
+ * operator Message whose wake policy was not `none`. An absent wake policy
102
+ * qualifies: before Requirement A that was the default Leader-waking submission,
103
+ * exactly the shape an omitted intent normalizes to `discuss` today.
104
+ */
105
+ function firstPlanningSubmission(rows) {
106
+ for (const row of rows) {
107
+ const message = JSON.parse(row.payload);
108
+ if (message.kind !== "user" && message.kind !== "operator")
109
+ continue;
110
+ if (message.recipient !== undefined)
111
+ continue; // an addressed continuation, not a submission
112
+ if (message.wakePolicy === "none")
113
+ continue; // old save-only == record, never planning
114
+ if (typeof message.id !== "string")
115
+ continue;
116
+ if (typeof message.createdAt !== "string")
117
+ continue;
118
+ return { id: message.id, createdAt: message.createdAt };
119
+ }
120
+ return undefined;
121
+ }
122
+ /** The numeric suffix of an `event-<n>` id, or 0 for any unexpected shape. */
123
+ function eventSequence(eventId) {
124
+ const match = /^event-(\d+)$/.exec(eventId);
125
+ return match === null ? 0 : Number.parseInt(match[1], 10);
126
+ }
@@ -23,6 +23,7 @@
23
23
  import { createHash } from "node:crypto";
24
24
  import { REMOVE_RUNTIME_GENERATION_SQL, removeRuntimeGenerationRecords } from "./migrations/removeRuntimeGeneration.js";
25
25
  import { migrateAgentRunContract } from "./migrations/agentRunContract.js";
26
+ import { migrateArtifactsToGit } from "./migrations/artifactsToGit.js";
26
27
  import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "./storageVersions.js";
27
28
  /** Telemetry retention bounds (§4.4). Open question 3 in §11; defaults from the design. */
28
29
  export const TELEMETRY_KEEP_PER_RUN = 200;
@@ -1117,6 +1118,31 @@ UPDATE review_rounds SET payload = json_set(payload, '$.executionGroup.lanes', j
1117
1118
  // now retain explicit replacement intent; OS owner records may identify
1118
1119
  // the dedicated execution child, independently of a disposable Host.
1119
1120
  sql: "SELECT 1; -- Replacement intent, independent retained native control evidence/process custody, and optional fixed TaskWake refs"
1121
+ },
1122
+ {
1123
+ version: 19,
1124
+ name: "task-artifacts-local-git",
1125
+ introducedIn: "0.16.0",
1126
+ // Retire the DB-owned immutable Artifact store: file/directory artifacts now
1127
+ // live in a per-Task local Git repository, referenced by a self-certifying
1128
+ // `commit + relativePath`. The whole rewrite is payload work that must READ
1129
+ // the `artifacts` table before it is dropped, so it runs entirely in
1130
+ // `migrateData` (which executes after this `sql`) — the table is dropped
1131
+ // there, last, once its rows have been moved. Requirement A's independent,
1132
+ // deterministic submit-intent backfill (events + id_sequences only) is the
1133
+ // final step inside that same transaction. This `sql` is intentionally a
1134
+ // no-op: dropping the table here would destroy the rows before they move.
1135
+ //
1136
+ // The 18->19 contract also DECLARES three optional `messages.payload` fields
1137
+ // added by Requirement A — `intent` (record|discuss|develop), an optional
1138
+ // client idempotency `submissionKey`, and a frozen `submissionReceipt`
1139
+ // disposition. They add no column and no table and are only ever written
1140
+ // going forward (absent `intent` reads as discuss; absent key is keyless;
1141
+ // absent receipt is a pre-19 message), so NO historical row is rewritten and
1142
+ // no key or receipt is ever fabricated for old data — declaring them here
1143
+ // satisfies "optional still requires a migration declaration".
1144
+ sql: "SELECT 1; -- artifacts move to per-Task local Git; see migrateArtifactsToGit",
1145
+ migrateData: migrateArtifactsToGit
1120
1146
  }
1121
1147
  ]);
1122
1148
  for (let index = 0; index < MIGRATIONS.length; index += 1) {
@@ -1350,7 +1376,12 @@ export function migrateSqliteSchema(db, options) {
1350
1376
  throw new SqliteSchemaMigrationError(`storage version ${applied.currentVersion} is older than the minimum supported `
1351
1377
  + `${MIN_SUPPORTED_STORAGE_VERSION}`, "admission");
1352
1378
  }
1353
- const pending = MIGRATIONS.filter((migration) => !applied.versions.has(migration.version));
1379
+ // Production callers omit `throughVersion`, so the effective target is head
1380
+ // and behavior is unchanged; a partial target is honored only in apply mode.
1381
+ const effectiveTarget = options.mode === "apply" && options.throughVersion !== undefined
1382
+ ? options.throughVersion
1383
+ : CURRENT_STORAGE_VERSION;
1384
+ const pending = MIGRATIONS.filter((migration) => !applied.versions.has(migration.version) && migration.version <= effectiveTarget);
1354
1385
  if (!ledgerWasCreated && pending.length > 0 && options.mode === "validate") {
1355
1386
  throw new SqliteSchemaMigrationError(`Storage version ${applied.currentVersion} requires an explicit upgrade to `
1356
1387
  + `${CURRENT_STORAGE_VERSION}`, "admission");
@@ -1364,7 +1395,11 @@ export function migrateSqliteSchema(db, options) {
1364
1395
  VALUES (?, ?, ?, ?)`).run(migration.version, migration.name, appliedAt, checksum(migration.sql));
1365
1396
  newlyApplied.push(migration.version);
1366
1397
  }
1367
- validateSchemaObjects(db);
1398
+ // The table/index inventory describes the HEAD shape; only assert it once the
1399
+ // migration has actually advanced to head (a deliberate partial target stops
1400
+ // earlier and is validated when the real upgrade later completes it).
1401
+ if (effectiveTarget >= CURRENT_STORAGE_VERSION)
1402
+ validateSchemaObjects(db);
1368
1403
  return newlyApplied;
1369
1404
  };
1370
1405
  const newlyApplied = options.mode === "apply" && !db.inTransaction
@@ -1376,7 +1411,6 @@ export function migrateSqliteSchema(db, options) {
1376
1411
  export const SQLITE_SCHEMA_TABLES = [
1377
1412
  "plugin_intents",
1378
1413
  "plugin_validations",
1379
- "artifacts",
1380
1414
  "local_resources",
1381
1415
  "environment_preparations",
1382
1416
  "schema_migrations",
@@ -40,7 +40,7 @@ import { isDeepStrictEqual } from "node:util";
40
40
  import Database from "better-sqlite3";
41
41
  import { validatePluginValidation } from "../plugins/pluginPackage.js";
42
42
  import { validatePluginIntent, validatePluginIntentFailure } from "../plugins/pluginIntent.js";
43
- import { validateArtifact, validateLocalResource, validateEnvironmentPreparation } from "../resources/projectResource.js";
43
+ import { validateLocalResource, validateEnvironmentPreparation } from "../resources/projectResource.js";
44
44
  import { validateConfiguredAgent } from "../agent/agent.js";
45
45
  import { validateTaskBrief } from "../brief/taskBrief.js";
46
46
  import { consumePendingBatch, mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
@@ -614,26 +614,6 @@ export class SqliteTaskStore {
614
614
  });
615
615
  }
616
616
  // -- projects ---------------------------------------------------------------
617
- saveArtifact(artifact) {
618
- validateArtifact(artifact);
619
- this.#mutate(() => {
620
- const previous = this.getArtifact(artifact.taskId, artifact.id);
621
- if (previous !== null) {
622
- if (!isDeepStrictEqual(previous, artifact))
623
- throw new StorageRecordError("Artifacts are immutable.");
624
- return;
625
- }
626
- this.#db.prepare("INSERT INTO artifacts (task_id, id, payload) VALUES (?, ?, ?)")
627
- .run(artifact.taskId, artifact.id, this.#json(artifact));
628
- });
629
- }
630
- getArtifact(taskId, artifactId) {
631
- const artifact = this.#getPayload("artifacts", "task_id = ? AND id = ?", [taskId, artifactId]);
632
- return artifact === null ? null : validateArtifact(artifact);
633
- }
634
- listArtifacts(taskId) {
635
- return this.#listPayload("artifacts", "task_id = ?", [taskId]).map(validateArtifact);
636
- }
637
617
  savePluginValidation(validation) {
638
618
  validatePluginValidation(validation);
639
619
  this.#mutate(() => {
@@ -13,4 +13,4 @@
13
13
  * intermediate Yui releases.
14
14
  */
15
15
  export const MIN_SUPPORTED_STORAGE_VERSION = 1;
16
- export const CURRENT_STORAGE_VERSION = 18;
16
+ export const CURRENT_STORAGE_VERSION = 19;
@@ -29,7 +29,7 @@ import { StorageCancelledError, StorageConflictError, StorageRecordError } from
29
29
  */
30
30
  const READ_ONLY_STORE_METHODS = new Set([
31
31
  "getPluginIntent", "listPluginIntents",
32
- "getPluginValidation", "getArtifact", "listArtifacts", "getLocalResource", "listLocalResources",
32
+ "getPluginValidation", "getLocalResource", "listLocalResources",
33
33
  "getEnvironmentPreparation", "listEnvironmentPreparations",
34
34
  "rootDirectory",
35
35
  "getConfig",
@@ -22,6 +22,7 @@ export const TASK_ACTIVATION_EVENT = Object.freeze({
22
22
  adopted: "task.activation-adopted",
23
23
  failed: "task.activation-failed"
24
24
  });
25
+ export const TASK_ACTIVATION_ORIGINS = Object.freeze(["explicit", "submit-develop"]);
25
26
  /**
26
27
  * The digest that decides whether a repeated requestId is the same request.
27
28
  *
@@ -63,6 +64,7 @@ export function createTaskActivationRequest(taskId, input, now) {
63
64
  partialResultRefs: []
64
65
  },
65
66
  startMode: input.startMode,
67
+ ...(input.origin === undefined ? {} : { origin: input.origin }),
66
68
  ...(input.afterPlanningRun === undefined
67
69
  ? {}
68
70
  : { afterPlanningRun: input.afterPlanningRun }),
@@ -194,6 +196,26 @@ export function admitTaskActivationRequest(task, request, planningRunIsActive) {
194
196
  }
195
197
  return { disposition: "ready", request };
196
198
  }
199
+ /**
200
+ * Whether the Controller may continue (auto-adopt) a released request without an
201
+ * explicit activation boundary call (task-32 §2.4).
202
+ *
203
+ * A deferred request already holds activation authority and is released by its
204
+ * planning Turn ending, so it is adoptable regardless of origin. An immediate
205
+ * request is auto-continued only when it carries a recognised provable origin:
206
+ * an explicit activation action, or a develop submission the shared transaction
207
+ * accepted while the Task was unplanned. A request with no origin — every one
208
+ * migrated from before the field existed — is deliberately held back so an
209
+ * upgrade cannot turn a historical pending request into a silent activation; it
210
+ * still adopts through the explicit `yui task activate` boundary, which does not
211
+ * consult origin. This replaces the old actor heuristic that both dropped legal
212
+ * Operator/user immediate requests and could not distinguish provenance.
213
+ */
214
+ export function activationRequestIsControllerAdoptable(request) {
215
+ if (request.startMode === "after-planning-turn")
216
+ return true;
217
+ return request.origin !== undefined;
218
+ }
197
219
  /**
198
220
  * The durable terminal outcome for a requestId, read from the activation event
199
221
  * ledger.
@@ -236,6 +258,10 @@ export function validateTaskActivationRequest(request) {
236
258
  if (!["immediate", "after-planning-turn"].includes(request.startMode)) {
237
259
  throw new Error(`Activation start mode is invalid: ${String(request.startMode)}.`);
238
260
  }
261
+ if (request.origin !== undefined
262
+ && !TASK_ACTIVATION_ORIGINS.includes(request.origin)) {
263
+ throw new Error(`Activation origin is invalid: ${String(request.origin)}.`);
264
+ }
239
265
  if ((request.startMode === "after-planning-turn")
240
266
  !== (request.afterPlanningRun !== undefined)) {
241
267
  throw new Error("Activation deferral must name exactly the planning Turn it waits for.");