@loomhelm/multi-task-flow 0.2.8 → 0.2.10

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 (62) hide show
  1. package/README.md +14 -14
  2. package/package.json +2 -2
  3. package/src/cli/flow.ts +13 -4
  4. package/src/cli/service.ts +26 -0
  5. package/src/cli.ts +3 -1
  6. package/toolkit/artifact-manifest.json +69 -73
  7. package/toolkit/package.json +1 -1
  8. package/toolkit/skills/bin/check_source_policy.ts +13 -7
  9. package/toolkit/skills/bin/multi_task_platform_gate.ts +2 -2
  10. package/toolkit/skills/bin/tests/core/package_scripts.test.ts +1 -1
  11. package/toolkit/skills/bin/tests/plan/multi-task-flow.test.ts +3 -3
  12. package/toolkit/skills/catalog.json +0 -1
  13. package/toolkit/skills/docs/vendor/dot-codex/design/multi-task-flow/data-and-scripts.md +8 -6
  14. package/toolkit/skills/docs/vendor/dot-codex/design/multi-task-flow/execution.md +1 -1
  15. package/toolkit/skills/docs/vendor/dot-codex/design/multi-task-flow/plan-revision-and-change-audit.md +1 -1
  16. package/toolkit/skills/docs/vendor/dot-codex/design/multi-task-flow/web-template.md +1 -1
  17. package/toolkit/skills/docs/vendor/dot-codex/specs/a4ge9-stable-preview-runtime/IMPLEMENTATION.md +0 -2
  18. package/toolkit/skills/docs/vendor/dot-codex/specs/a4ge9-stable-preview-runtime/SPEC.md +0 -1
  19. package/toolkit/skills/docs/vendor/dot-codex/specs/dev-plan-standalone-typescript-finalizer/IMPLEMENTATION.md +1 -1
  20. package/toolkit/skills/docs/vendor/dot-codex/specs/multi-task-flow/IMPLEMENTATION.md +3 -3
  21. package/toolkit/skills/docs/vendor/dot-codex/specs/multi-task-flow/SCHEMA.md +4 -4
  22. package/toolkit/skills/docs/vendor/dot-codex/specs/stable-preview-runtime/IMPLEMENTATION.md +0 -2
  23. package/toolkit/skills/docs/vendor/dot-codex/specs/stable-preview-runtime/SPEC.md +0 -1
  24. package/toolkit/skills/multi-task-flow/SKILL.md +65 -47
  25. package/toolkit/skills/multi-task-flow/dist/app.css +1 -1
  26. package/toolkit/skills/multi-task-flow/dist/app.js +74 -74
  27. package/toolkit/skills/multi-task-flow/dist/index.html +3 -3
  28. package/toolkit/skills/multi-task-flow/lib/atomic-orchestration.ts +1 -0
  29. package/toolkit/skills/multi-task-flow/lib/engine.ts +65 -7
  30. package/toolkit/skills/multi-task-flow/lib/package-source.ts +5 -5
  31. package/toolkit/skills/multi-task-flow/lib/schema.ts +82 -2
  32. package/toolkit/skills/multi-task-flow/lib/service-pages.ts +1 -26
  33. package/toolkit/skills/multi-task-flow/lib/view.ts +16 -0
  34. package/toolkit/skills/multi-task-flow/references/shared-viewer.md +1 -1
  35. package/toolkit/skills/multi-task-flow/scripts/check-client-skills.ts +7 -4
  36. package/toolkit/skills/multi-task-flow/scripts/check-skills-installation.ts +14 -8
  37. package/toolkit/skills/multi-task-flow/scripts/import-client-skills.ts +1 -1
  38. package/toolkit/skills/multi-task-flow/scripts/package-client-skills.ts +4 -4
  39. package/toolkit/skills/multi-task-flow/scripts/test-viewer-source.ts +1 -1
  40. package/toolkit/skills/multi-task-flow/tests/atomic-orchestration-schema.test.ts +5 -0
  41. package/toolkit/skills/multi-task-flow/tests/browser/README.md +1 -1
  42. package/toolkit/skills/multi-task-flow/tests/check-skills-installation.test.ts +12 -38
  43. package/toolkit/skills/multi-task-flow/tests/delivery-topology.test.ts +33 -0
  44. package/toolkit/skills/multi-task-flow/tests/orchestrate.test.ts +3 -5
  45. package/toolkit/skills/multi-task-flow/tests/service-fixture.ts +10 -2
  46. package/toolkit/skills/multi-task-flow/tests/shared-service.test.ts +13 -0
  47. package/toolkit/skills/multi-task-flow/tests/ui/details-content.test.ts +9 -0
  48. package/toolkit/skills/multi-task-flow/tests/ui/responsive-width-contract.test.ts +25 -1
  49. package/toolkit/skills/multi-task-flow/tests/ui/task-context-menu.test.ts +2 -1
  50. package/toolkit/skills/multi-task-flow/web/app.css +64 -9
  51. package/toolkit/skills/multi-task-flow/web/app.ts +33 -14
  52. package/toolkit/skills/multi-task-flow/web/atomic-graph-view.tsx +1 -1
  53. package/toolkit/skills/multi-task-flow/web/details-content.ts +49 -38
  54. package/toolkit/skills/multi-task-flow/web/graph-view.tsx +1 -1
  55. package/toolkit/skills/multi-task-flow/web/index.html +3 -3
  56. package/toolkit/skills/multi-task-flow/web/model.ts +1 -1
  57. package/toolkit/skills/multi-task-flow/web/timeline.ts +11 -8
  58. package/toolkit/skills/package.json +2 -2
  59. package/toolkit/skills/source-manifest.json +693 -400
  60. package/toolkit/skills/support-manifest.json +1 -0
  61. package/toolkit/skills/multi-task-flow/scripts/serve.ts +0 -218
  62. package/toolkit/skills/multi-task-flow/tests/serve.test.ts +0 -194
@@ -50,6 +50,7 @@
50
50
  <button id="tab-tasks" type="button" role="tab" aria-selected="false" aria-controls="tasks-view" data-tab="tasks">任务</button>
51
51
  </div>
52
52
  <label class="search input"><i data-lucide="search" aria-hidden="true"></i><span class="sr-only">搜索任务</span><input id="search" type="search" placeholder="搜索任务"></label>
53
+ <div id="task-view-tools" class="task-view-tools" aria-label="任务视图工具"><span id="task-count" class="section-count"></span><div id="task-layout-switch" class="layout-switch" role="group" aria-label="对象布局"><button id="task-layout-board" type="button" class="icon-button layout-option" aria-label="看板布局" aria-describedby="task-layout-board-tooltip" aria-pressed="true"><i data-lucide="columns-3"></i><span id="task-layout-board-tooltip" class="layout-tooltip" role="tooltip">看板布局:按交付单元分列展示任务</span></button><button id="task-layout-grid" type="button" class="icon-button layout-option" aria-label="网格布局" aria-describedby="task-layout-grid-tooltip" aria-pressed="false"><i data-lucide="layout-grid"></i><span id="task-layout-grid-tooltip" class="layout-tooltip" role="tooltip">网格布局:平铺展示任务</span></button></div></div>
53
54
  </div>
54
55
  <div class="filters">
55
56
  <span class="filter-caption">筛选视图</span>
@@ -70,7 +71,6 @@
70
71
  <div id="connection-notice" class="notice" role="status" hidden></div>
71
72
  <div id="phase-notice" class="notice notice-neutral" role="status" hidden><span id="phase-notice-text"></span><button type="button" id="phase-jump" class="text-button">查看实际进度</button></div>
72
73
  <div id="operation-warning" class="notice" role="status" hidden></div>
73
- <div id="plan-state" class="notice plan-state" aria-label="计划与运行状态" hidden></div>
74
74
  <div id="plan-warning" class="notice notice-warning" role="status" hidden></div>
75
75
  <div id="load-error" class="notice notice-error" role="alert" hidden></div>
76
76
  <div id="graph-view" class="canvas-view" role="tabpanel" aria-labelledby="tab-graph"><div id="graph-projection-warning" class="notice notice-warning" role="status" hidden></div><div id="graph" aria-label="任务依赖图"></div><div id="atomic-graph" aria-label="原子任务依赖图" hidden></div><div id="graph-a11y" class="graph-a11y" role="list" aria-label="依赖图节点列表"></div><div id="graph-empty" class="empty" hidden>暂无任务</div></div>
@@ -79,11 +79,11 @@
79
79
  </div>
80
80
  <div id="tasks-view" class="workspace-tasks" role="tabpanel" aria-labelledby="tab-tasks" hidden>
81
81
  <section id="group-section" class="task-list-section" aria-labelledby="group-heading"><div class="section-heading"><h2 id="group-heading">交付单元</h2></div><div id="group-list" class="task-list" role="list"></div></section>
82
- <section id="task-section" class="task-list-section" aria-labelledby="task-heading"><div class="section-heading"><h2 id="task-heading">任务</h2><div class="section-heading-actions"><span id="task-count" class="section-count"></span><div id="task-layout-switch" class="layout-switch" role="group" aria-label="对象布局"><button id="task-layout-board" type="button" class="icon-button layout-option" aria-label="看板布局" aria-describedby="task-layout-board-tooltip" aria-pressed="true"><i data-lucide="columns-3"></i><span id="task-layout-board-tooltip" class="layout-tooltip" role="tooltip">看板布局:按交付单元分列展示任务</span></button><button id="task-layout-grid" type="button" class="icon-button layout-option" aria-label="网格布局" aria-describedby="task-layout-grid-tooltip" aria-pressed="false"><i data-lucide="layout-grid"></i><span id="task-layout-grid-tooltip" class="layout-tooltip" role="tooltip">网格布局:平铺展示任务</span></button></div></div></div><div id="task-list" class="task-list" role="list"></div></section>
82
+ <section id="task-section" class="task-list-section"><div id="task-list" class="task-list" role="list"></div></section>
83
83
  </div>
84
84
  </div>
85
85
  <div id="details-backdrop" class="details-backdrop" hidden></div>
86
- <div class="details-slot" hidden><aside id="details" class="details" aria-label="详情" aria-hidden="true" tabindex="-1"><div class="details-heading"><button type="button" id="close-details" class="icon-button mobile-close" data-tooltip="关闭详情" aria-label="关闭详情"><i data-lucide="x"></i></button><div id="details-summary"></div></div><div id="details-content" class="details-empty"><span class="empty-rule" aria-hidden="true"></span><strong>选择交付单元或任务</strong><span>查看依赖、运行时间和原生会话链接</span></div></aside></div>
86
+ <div class="details-slot" hidden><div id="details-resize" class="details-resize" role="separator" aria-orientation="horizontal" aria-label="调整详情高度" tabindex="0"></div><aside id="details" class="details" aria-label="详情" aria-hidden="true" tabindex="-1"><div class="details-heading"><button type="button" id="close-details" class="icon-button mobile-close" data-tooltip="关闭详情" aria-label="关闭详情"><i data-lucide="x"></i></button><div id="details-summary"></div></div><div id="details-content" class="details-empty"><span class="empty-rule" aria-hidden="true"></span><strong>选择交付单元或任务</strong><span>查看依赖、运行时间和原生会话链接</span></div></aside></div>
87
87
  </section>
88
88
  </main>
89
89
  </div>
@@ -77,6 +77,7 @@ export const atomicEdge = z
77
77
 
78
78
  export const taskPrBinding = z.discriminatedUnion("state", [
79
79
  z.object({ state: z.literal("unbound") }).strict(),
80
+ z.object({ state: z.literal("shared_delivery_unit"), delivery_unit_id: id }).strict(),
80
81
  z.object({ state: z.literal("open"), pr: atomicExternalRef, branch: text, base_branch: text }).strict(),
81
82
  z.object({ state: z.literal("merged"), pr: atomicExternalRef, branch: text, merge_sha: text }).strict(),
82
83
  z.object({ state: z.literal("closed"), pr: atomicExternalRef, branch: text, closed_at: timestamp, closed_reason: z.enum(["abandoned", "superseded", "provider_closed", "replaced"]) }).strict(),
@@ -32,6 +32,8 @@ import {
32
32
  validateLedger,
33
33
  validateSchedulablePlan,
34
34
  validatePlan,
35
+ deliveryTopologyForGroup,
36
+ validateDeliveryTopologies,
35
37
  defaultGroupAtoms,
36
38
  executionProfile,
37
39
  recommendedExecutionProfile,
@@ -192,6 +194,15 @@ function taskPrRef(task: Ledger["tasks"][number]): ExternalRef | null {
192
194
  if (task.pr && "state" in task.pr) return "pr" in task.pr ? task.pr.pr : null;
193
195
  return task.pr;
194
196
  }
197
+ function isSharedDeliveryTask(
198
+ ledger: Ledger,
199
+ task: Ledger["tasks"][number],
200
+ ): boolean {
201
+ return (
202
+ isContributionKind(task.kind) &&
203
+ deliveryTopologyForGroup(ledger.tasks, task.group_id) === "shared_delivery"
204
+ );
205
+ }
195
206
  function requireContributionDispatchGate(
196
207
  ledger: Ledger,
197
208
  owner: Ledger["groups"][number],
@@ -240,6 +251,8 @@ function ensureSchedulable(ledger: Ledger, candidate: Plan): void {
240
251
  const contributionTasks = ledger.tasks.filter(
241
252
  (item) => item.disposition === "active" && isContributionKind(item.kind),
242
253
  );
254
+ if (ledger.contract_version >= 3)
255
+ validateDeliveryTopologies(contributionTasks, true);
243
256
  const groupIds = new Set(contributionTasks.map((item) => item.group_id));
244
257
  const missing: string[] = [];
245
258
  for (const groupId of groupIds) {
@@ -254,9 +267,19 @@ function ensureSchedulable(ledger: Ledger, candidate: Plan): void {
254
267
  }
255
268
  }
256
269
  for (const task of contributionTasks) {
270
+ const topology = deliveryTopologyForGroup(contributionTasks, task.group_id);
257
271
  const hasTaskPublication = candidate.actions.some(
258
272
  (action) => action.task_id === task.id,
259
273
  );
274
+ if (topology === "shared_delivery") {
275
+ requireCondition(
276
+ !hasTaskPublication,
277
+ "PLAN_NOT_DISPATCHABLE",
278
+ `Shared delivery task ${task.id} must not declare a task-level PR action`,
279
+ 3,
280
+ );
281
+ continue;
282
+ }
260
283
  if (!hasTaskPublication) continue;
261
284
  for (const kind of ["publish_branch", "publish_pr"] as const) {
262
285
  const planned = candidate.actions.some(
@@ -1595,6 +1618,15 @@ export function execute(
1595
1618
  (item) => item.id === target.group_id,
1596
1619
  "Group",
1597
1620
  );
1621
+ requireCondition(
1622
+ !isSharedDeliveryTask(ledger, target),
1623
+ "SHARED_DELIVERY_UNIT_DISPATCH",
1624
+ "A single-contribution delivery unit is dispatched through its delivery-unit conversation; do not create a task conversation",
1625
+ 3,
1626
+ {
1627
+ recovery: ["dispatch-group-claim", "dispatch-resolve", "task-update"],
1628
+ },
1629
+ );
1598
1630
  requireCondition(
1599
1631
  groupOwnerThread(ledger, owner),
1600
1632
  "GROUP_NOT_READY",
@@ -2401,6 +2433,15 @@ export function execute(
2401
2433
  const targetId = parse(id, payload.target_id);
2402
2434
  requireOwner(ledger, request, targetType, targetId);
2403
2435
  requireDevelopmentTarget(ledger, targetType, targetId);
2436
+ if (targetType === "task") {
2437
+ const taskTarget = find(ledger.tasks, (item) => item.id === targetId, "Task");
2438
+ requireCondition(
2439
+ !isSharedDeliveryTask(ledger, taskTarget),
2440
+ "SHARED_DELIVERY_UNIT_PR",
2441
+ "A shared delivery task reuses the delivery-unit PR and cannot publish a task PR",
2442
+ 3,
2443
+ );
2444
+ }
2404
2445
  requireLifecycle(ledger, targetType, targetId, "workspace_ready");
2405
2446
  const target = lifecycleTarget(ledger, targetType, targetId) as any;
2406
2447
  const branch = string(payload.branch);
@@ -2816,7 +2857,10 @@ export function execute(
2816
2857
  case "task-runtime-observe": {
2817
2858
  onlyMain(request.actor);
2818
2859
  const target = find(ledger.tasks, item => item.id === parse(id, payload.task_id), "Task");
2819
- requireCondition(target.thread_id === payload.thread_id, "RESOURCE_MISMATCH", "Runtime observation thread does not match task", 4);
2860
+ const observedThread = isSharedDeliveryTask(ledger, target)
2861
+ ? ledger.groups.find((item) => item.id === target.group_id)?.agent_thread_id
2862
+ : target.thread_id;
2863
+ requireCondition(observedThread === payload.thread_id, "RESOURCE_MISMATCH", "Runtime observation thread does not match task", 4);
2820
2864
  const runtime = z.enum(["active", "idle", "unknown"]).parse(payload.runtime);
2821
2865
  const observedAt = z.iso.datetime().parse(payload.observed_at);
2822
2866
  if (runtime === "idle") requireCondition(typeof payload.turn_id === "string" && payload.turn_id.length > 0, "EVIDENCE_REQUIRED", "Idle observation requires a confirmed terminal turn identity", 4);
@@ -2829,7 +2873,7 @@ export function execute(
2829
2873
  target.state = target.agent_resume_state ?? "running";
2830
2874
  delete target.agent_resume_state;
2831
2875
  }
2832
- if (previous !== target.state) addEvent(ledger, request, "task_runtime_observed", target.id, { task_id: target.id, thread_id: target.thread_id, turn_id: payload.turn_id ?? null, observed_at: observedAt, runtime, state: target.state, previous_state: previous });
2876
+ if (previous !== target.state) addEvent(ledger, request, "task_runtime_observed", target.id, { task_id: target.id, thread_id: observedThread, turn_id: payload.turn_id ?? null, observed_at: observedAt, runtime, state: target.state, previous_state: previous });
2833
2877
  return { state: target.state };
2834
2878
  }
2835
2879
  case "task-update": {
@@ -2840,11 +2884,16 @@ export function execute(
2840
2884
  target.conversation_policy === "main_owned" &&
2841
2885
  request.actor.role === "main" &&
2842
2886
  request.actor.thread_id === ledger.control.owner_thread_id;
2887
+ const sharedOwned =
2888
+ isSharedDeliveryTask(ledger, target) &&
2889
+ request.actor.role === "group" &&
2890
+ ledger.groups.find((item) => item.id === target.group_id)?.agent_thread_id ===
2891
+ request.actor.thread_id;
2843
2892
  const state = z
2844
2893
  .enum(["running", "waiting", "blocked", "completed", "closed"])
2845
2894
  .parse(payload.state);
2846
2895
  const allowed: Record<string, string[]> = {
2847
- planned: mainOwned ? ["running", "waiting", "blocked"] : [],
2896
+ planned: mainOwned || sharedOwned ? ["running", "waiting", "blocked"] : [],
2848
2897
  starting: ["running", "waiting", "blocked"],
2849
2898
  running: ["waiting", "blocked"],
2850
2899
  agent_idle: ["running", "waiting", "blocked"],
@@ -2926,9 +2975,9 @@ export function execute(
2926
2975
  }
2927
2976
  if (state === "running" && previousState === "planned")
2928
2977
  requireCondition(
2929
- mainOwned && ready(ledger, target.id),
2978
+ (mainOwned || sharedOwned) && ready(ledger, target.id),
2930
2979
  "MAIN_TASK_REQUIRED",
2931
- "Only the bound main conversation can start a main-owned task directly",
2980
+ "Only the task owner can start a planned task directly",
2932
2981
  3,
2933
2982
  );
2934
2983
  if (state === "completed")
@@ -2956,7 +3005,10 @@ export function execute(
2956
3005
  );
2957
3006
  if (isContributionKind(target.kind))
2958
3007
  requireCondition(
2959
- legacyLedger(ledger) || target.lifecycle.pr_merged === "passed",
3008
+ legacyLedger(ledger) ||
3009
+ target.lifecycle.pr_merged === "passed" ||
3010
+ (isSharedDeliveryTask(ledger, target) &&
3011
+ ledger.groups.find((item) => item.id === target.group_id)?.lifecycle.pr_merged === "passed"),
2960
3012
  "PR_NOT_MERGED",
2961
3013
  "Contribution task PR must merge before closeout",
2962
3014
  4,
@@ -3044,6 +3096,7 @@ export function execute(
3044
3096
  const target = find(ledger.tasks, (item) => item.id === taskId, "Task");
3045
3097
  const owner = find(ledger.groups, (item) => item.id === target.group_id, "Group");
3046
3098
  requireCondition(isContributionKind(target.kind), "TASK_MISMATCH", "Only contribution tasks can be equivalently integrated", 3);
3099
+ requireCondition(!isSharedDeliveryTask(ledger, target), "SHARED_DELIVERY_UNIT_PR", "Shared delivery tasks do not have a child PR to integrate", 3);
3047
3100
  requireCondition(["blocked", "agent_idle", "running"].includes(target.state), "INVALID_TRANSITION", "Task is not awaiting integration", 3);
3048
3101
  requireCondition(target.atoms.every((atom) => atom.status === "completed"), "ATOMS_PENDING", "Complete the task atoms before integration", 4);
3049
3102
  const taskPr = parse(externalRef, payload.task_pr);
@@ -3079,8 +3132,13 @@ export function execute(
3079
3132
  target.conversation_policy === "main_owned" &&
3080
3133
  request.actor.role === "main" &&
3081
3134
  request.actor.thread_id === ledger.control.owner_thread_id;
3135
+ const sharedOwned =
3136
+ isSharedDeliveryTask(ledger, target) &&
3137
+ request.actor.role === "group" &&
3138
+ ledger.groups.find((item) => item.id === target.group_id)?.agent_thread_id ===
3139
+ request.actor.thread_id;
3082
3140
  requireCondition(
3083
- (request.actor.role === "task" || mainOwned) && taskExecutionState(target) === "running",
3141
+ (request.actor.role === "task" || mainOwned || sharedOwned) && taskExecutionState(target) === "running",
3084
3142
  "FORBIDDEN",
3085
3143
  "The running task owner updates its atoms",
3086
3144
  3,
@@ -5,8 +5,8 @@ export const digest = value => createHash('sha256').update(value).digest('hex');
5
5
  export function copySkillSources(root, output, { dependencies = false } = {}) {
6
6
  const manifest = JSON.parse(readFileSync(join(root, 'skills/source-manifest.json'), 'utf8'));
7
7
  const files = new Map();
8
- const copy = (path, expected) => {
9
- const source = resolve(root, path);
8
+ const copy = (path, expected, sourcePath = path) => {
9
+ const source = resolve(root, sourcePath);
10
10
  if (!path.startsWith('skills/') || !source.startsWith(resolve(root, 'skills') + '/')) throw new Error('Unsafe source resource: ' + path);
11
11
  const stat = lstatSync(source);
12
12
  if (!stat.isFile() || stat.isSymbolicLink() || realpathSync(source) !== source || (!path.includes('/node_modules/') && stat.nlink !== 1)) throw new Error('Unsafe source resource: ' + path);
@@ -15,9 +15,9 @@ export function copySkillSources(root, output, { dependencies = false } = {}) {
15
15
  const target = join(output, path); mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); cpSync(source, target);
16
16
  files.set(path, sha256);
17
17
  };
18
- for (const file of [...manifest.files, ...(manifest.additions ?? [])]) copy(file.path, file.installedSha256);
18
+ for (const file of [...manifest.files, ...(manifest.additions ?? [])]) copy(file.path, file.installedSha256, file.sourcePath ?? file.path);
19
19
  for (const path of ['skills/source-manifest.json', 'skills/catalog.json']) copy(path);
20
- for (const name of ['index.html','app.js','app.css']) copy('skills/multi-task-flow/dist/' + name);
20
+ for (const name of ['index.html','app.js','app.css']) copy('skills/multi-task-flow/dist/' + name, undefined, 'skills/multi-task-flow-runtime/dist/' + name);
21
21
  if (dependencies) {
22
22
  const walk = path => {
23
23
  for (const entry of readdirSync(join(root, path), { withFileTypes: true })) {
@@ -26,7 +26,7 @@ export function copySkillSources(root, output, { dependencies = false } = {}) {
26
26
  if (entry.isDirectory()) walk(child); else copy(child);
27
27
  }
28
28
  };
29
- for (const path of ['skills/node_modules', 'skills/multi-task-flow/node_modules']) {
29
+ for (const path of ['skills/node_modules', 'skills/multi-task-flow-runtime/node_modules']) {
30
30
  if (!existsSync(join(root, path))) throw new Error('Prepared dependency directory missing: ' + path);
31
31
  walk(path);
32
32
  }
@@ -384,7 +384,7 @@ export const task = z
384
384
  .nullable()
385
385
  .default(null),
386
386
  delivery_policy: z
387
- .enum(["parent_pr", "child_pr", "evidence", "none"])
387
+ .enum(["parent_pr", "child_pr", "shared_delivery_unit", "evidence", "none"])
388
388
  .nullable()
389
389
  .default(null),
390
390
  superseded_by_task_id: nullableText.default(null),
@@ -772,7 +772,7 @@ const taskExecutionPolicyDeclaration = z
772
772
  .enum(["main_owned", "delivery_owned", "task_agent", "none"])
773
773
  .nullable(),
774
774
  delivery_policy: z
775
- .enum(["parent_pr", "child_pr", "evidence", "none"])
775
+ .enum(["parent_pr", "child_pr", "shared_delivery_unit", "evidence", "none"])
776
776
  .nullable(),
777
777
  })
778
778
  .strict();
@@ -820,6 +820,8 @@ export function taskExecutionPolicyIssues(
820
820
  if (owner === "main" && delivery === "child_pr") issues.push({ code: "TASK_EXECUTION_POLICY_CONFLICT", message: "Main Agent 不得拥有 child PR" });
821
821
  if (owner === "delivery_agent" && delivery === "child_pr") issues.push({ code: "TASK_EXECUTION_POLICY_CONFLICT", message: "交付 Agent 不得拥有 child PR" });
822
822
  if (owner === "task_agent" && delivery === "parent_pr") issues.push({ code: "TASK_EXECUTION_POLICY_CONFLICT", message: "Task Agent 不得拥有 parent PR" });
823
+ if (delivery === "shared_delivery_unit" && owner !== "delivery_agent") issues.push({ code: "TASK_EXECUTION_POLICY_CONFLICT", message: "共享交付任务必须由交付 Agent 持有" });
824
+ if (delivery === "shared_delivery_unit" && conversation !== "delivery_owned") issues.push({ code: "TASK_EXECUTION_POLICY_CONFLICT", message: "共享交付任务必须复用交付单元对话" });
823
825
  if (conversation === "none" && ["child_pr", "evidence"].includes(delivery)) issues.push({ code: "TASK_EXECUTION_POLICY_CONFLICT", message: "无对话任务不能声明 child PR 或验证证据" });
824
826
  return issues;
825
827
  }
@@ -846,6 +848,82 @@ export function validateTaskExecutionPolicies(tasks: readonly Task[], requireCom
846
848
  export function isContributionKind(kind: Task["kind"]): boolean {
847
849
  return kind === "contribution" || kind === "development";
848
850
  }
851
+
852
+ export type DeliveryTopology = "shared_delivery" | "multi_task_integration" | "none";
853
+
854
+ export function deliveryTopologyForGroup(
855
+ tasks: readonly Pick<Task, "group_id" | "kind" | "disposition">[],
856
+ groupId: string,
857
+ ): DeliveryTopology {
858
+ const count = tasks.filter(
859
+ (task) =>
860
+ task.group_id === groupId &&
861
+ task.disposition === "active" &&
862
+ isContributionKind(task.kind),
863
+ ).length;
864
+ return count === 0
865
+ ? "none"
866
+ : count === 1
867
+ ? "shared_delivery"
868
+ : "multi_task_integration";
869
+ }
870
+
871
+ /**
872
+ * Validate the execution policy derived from the main Agent's approved grouping.
873
+ * The grouping itself is a planning decision; this function only checks that the
874
+ * resulting one-task/multi-task topology is represented consistently at runtime.
875
+ */
876
+ export function validateDeliveryTopologies(
877
+ tasks: readonly Task[],
878
+ requireComplete = false,
879
+ ): void {
880
+ const groupIds = new Set(
881
+ tasks
882
+ .filter((task) => task.disposition === "active" && isContributionKind(task.kind))
883
+ .map((task) => task.group_id),
884
+ );
885
+ for (const groupId of groupIds) {
886
+ const topology = deliveryTopologyForGroup(tasks, groupId);
887
+ const contributionTasks = tasks.filter(
888
+ (task) =>
889
+ task.group_id === groupId &&
890
+ task.disposition === "active" &&
891
+ isContributionKind(task.kind),
892
+ );
893
+ if (!requireComplete) {
894
+ if (contributionTasks.some((task) => task.delivery_policy === "shared_delivery_unit"))
895
+ requireCondition(
896
+ topology === "shared_delivery",
897
+ "DELIVERY_TOPOLOGY_CONFLICT",
898
+ `Delivery unit ${groupId} can use shared delivery only with one contribution task`,
899
+ 3,
900
+ );
901
+ continue;
902
+ }
903
+ for (const task of contributionTasks) {
904
+ if (topology === "shared_delivery") {
905
+ requireCondition(
906
+ task.execution_owner === "delivery_agent" &&
907
+ task.conversation_policy === "delivery_owned" &&
908
+ task.delivery_policy === "shared_delivery_unit",
909
+ "DELIVERY_TOPOLOGY_CONFLICT",
910
+ `Task ${task.id} must use the delivery-unit conversation and shared PR topology`,
911
+ 3,
912
+ );
913
+ } else {
914
+ requireCondition(
915
+ task.execution_owner === "task_agent" &&
916
+ task.conversation_policy === "task_agent" &&
917
+ task.delivery_policy === "child_pr",
918
+ "DELIVERY_TOPOLOGY_CONFLICT",
919
+ `Task ${task.id} must use an independent Task Agent and child PR in a multi-task delivery unit`,
920
+ 3,
921
+ );
922
+ }
923
+ }
924
+ }
925
+ }
926
+
849
927
  export function isCanonicalTaskKind(kind: Task["kind"]): boolean {
850
928
  return ["contribution", "release", "deployment", "verification"].includes(kind);
851
929
  }
@@ -2121,6 +2199,8 @@ export function validateLedger(value: unknown): Ledger {
2121
2199
  }
2122
2200
  for (const task of parsed.tasks.filter((item) => isContributionKind(item.kind))) {
2123
2201
  requireCondition(taskPrBinding.safeParse(task.pr).success, "ATOMIC_CONTRACT_REQUIRED", "Contract-3 contribution Task PR must use TaskPrBinding", 3);
2202
+ if (task.pr?.state === "shared_delivery_unit")
2203
+ requireCondition(task.pr.delivery_unit_id === task.group_id, "DELIVERY_TOPOLOGY_CONFLICT", "Shared task PR binding must reference its delivery unit", 3);
2124
2204
  }
2125
2205
  }
2126
2206
  for (const collection of [
@@ -8,26 +8,6 @@ export function viewerPage(
8
8
 
9
9
  const runPathPattern = /^\/runs\/[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\/$/;
10
10
  const viewIdPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
11
- const viewParamValidators: Record<string, (value: string) => boolean> = {
12
- tab: (value) => /^(graph|atomic|planned|actual)$/.test(value),
13
- graph: (value) => /^(aggregate|atomic)$/.test(value),
14
- layout: (value) => /^(board|grid)$/.test(value),
15
- q: (value) => value.length <= 200 && !/[\u0000-\u001f\u007f]/.test(value),
16
- group: (value) => viewIdPattern.test(value),
17
- state: (value) => viewIdPattern.test(value),
18
- kind: (value) => viewIdPattern.test(value),
19
- plan: (value) => viewIdPattern.test(value),
20
- selected: (value) => /^(task|group|milestone):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(value),
21
- detail: (value) => value === "1" || value === "true",
22
- zoom: (value) => {
23
- const zoom = Number(value);
24
- return Number.isFinite(zoom) && zoom >= 0.2 && zoom <= 2.5;
25
- },
26
- plannedStart: (value) => value.length <= 64 && !Number.isNaN(Date.parse(value)),
27
- plannedEnd: (value) => value.length <= 64 && !Number.isNaN(Date.parse(value)),
28
- actualStart: (value) => value.length <= 64 && !Number.isNaN(Date.parse(value)),
29
- actualEnd: (value) => value.length <= 64 && !Number.isNaN(Date.parse(value)),
30
- };
31
11
  const maxTimelineSpanMs = 10_000 * 365.25 * 24 * 60 * 60 * 1000;
32
12
 
33
13
  export type ViewerTarget = { target: string; ticket: string | null };
@@ -50,15 +30,10 @@ export function parseViewerTarget(
50
30
  if (ticketValues.length > 1 || (ticketValues.length > 0 && !allowTicket)) return null;
51
31
  const ticket = ticketValues[0] ?? null;
52
32
  if (ticket !== null && (ticket.length > 100 || /[\u0000-\u001f\u007f]/.test(ticket))) return null;
53
- let selected = false;
54
- let detail = false;
55
33
  for (const [key, parameter] of url.searchParams) {
56
34
  if (key === "ticket") continue;
57
- if (home || /[\u0000-\u001f\u007f]/.test(parameter) || !viewParamValidators[key] || url.searchParams.getAll(key).length !== 1 || !viewParamValidators[key]!(parameter)) return null;
58
- if (key === "selected") selected = true;
59
- if (key === "detail") detail = true;
35
+ if (home || /[\u0000-\u001f\u007f]/.test(key) || /[\u0000-\u001f\u007f]/.test(parameter)) return null;
60
36
  }
61
- if (detail && !selected) return null;
62
37
  for (const [start, end] of [["plannedStart", "plannedEnd"], ["actualStart", "actualEnd"]] as const) {
63
38
  const startValue = url.searchParams.get(start);
64
39
  const endValue = url.searchParams.get(end);
@@ -141,6 +141,21 @@ function deliveryGateDiagnostics(
141
141
  return diagnostics;
142
142
  }
143
143
 
144
+ function taskPrProjection(
145
+ task: Ledger["tasks"][number],
146
+ owner: Ledger["groups"][number] | undefined,
147
+ ): { url: string | null } | null {
148
+ if (!task.pr || typeof task.pr !== "object" || !("state" in task.pr))
149
+ return task.pr && "url" in task.pr ? { url: task.pr.url ?? null } : null;
150
+ if (task.pr.state === "shared_delivery_unit") {
151
+ const deliveryPr = owner?.pr;
152
+ if (deliveryPr && typeof deliveryPr === "object" && "state" in deliveryPr && "pr" in deliveryPr)
153
+ return { url: deliveryPr.pr.url ?? null };
154
+ return owner?.parent_pr ? { url: owner.parent_pr.url ?? null } : null;
155
+ }
156
+ return "pr" in task.pr ? { url: task.pr.pr.url ?? null } : null;
157
+ }
158
+
144
159
  function formalExecutionWarnings(ledger: Ledger): Record<string, unknown>[] {
145
160
  return ledger.groups.flatMap((group) => {
146
161
  if (group.state !== "active" || group.coordination_execution.state !== "running")
@@ -898,6 +913,7 @@ export function buildView(
898
913
  const derivedStatus = deriveBusinessStatus(canonicalAtomicNodes, { kind: "task", task_id: task.id }, milestoneEvaluations);
899
914
  return {
900
915
  ...task,
916
+ pr: taskPrProjection(task, owner),
901
917
  ...(() => {
902
918
  const gateDiagnostics = owner && isContributionKind(task.kind) ? deliveryGateDiagnostics(ledger, owner, task) : [];
903
919
  return { gateDiagnostics };
@@ -2,7 +2,7 @@
2
2
 
3
3
  The formal root is `~/.local/share/multi-task-flow/`: `config/`, `data/runs/`, `releases/`, `state/`, `logs/`. `CODEX_HOME` and the invoking worktree do not select a formal instance. `MULTI_TASK_FLOW_PROFILE=test|development` requires an absolute `MULTI_TASK_FLOW_ROOT`, distinct from the formal root. Isolated profiles derive separate launchd labels, port scopes and registry roots. `TASK_ORCHESTRATION_DATA_ROOT` is an explicit ledger-only fixture override; never use it to create a second formal authority.
4
4
 
5
- All commands use `service.ts <command> --input -`, consuming one JSON object. Normal usage:
5
+ All commands use `multi-task-flow service <command> --input -`, consuming one JSON object. Normal usage:
6
6
 
7
7
  - `ensure`, `renew`, `release`: `{"run_id":"example","thread_id":"main-thread","api_version":1}`.
8
8
  - `status`, `doctor`: `{}`. Inspect configured identity and runtime before remediation.
@@ -8,8 +8,9 @@ const path = resolve(root, 'skills/source-manifest.json');
8
8
  const manifest = JSON.parse(readFileSync(path, 'utf8'));
9
9
  const hash = data => createHash('sha256').update(data).digest('hex');
10
10
  const refresh = process.argv.includes('--write');
11
- const additional = [...JSON.parse(readFileSync(resolve(root, 'skills/support-manifest.json'), 'utf8')), 'skills/bin/modules/core/runtime.ts', 'skills/bin/package.json', 'skills/multi-task-flow/tests/service-release.test.ts', 'skills/multi-task-flow/lib/notification-delivery.ts', 'skills/multi-task-flow/tests/notification-delivery.test.ts', 'skills/multi-task-flow/lib/conversation-archive.ts', 'skills/multi-task-flow/lib/task-runtime.ts', 'skills/multi-task-flow/web/task-state.ts', 'skills/multi-task-flow/web/conversation-control.ts', 'skills/multi-task-flow/tests/ui/conversation-control.test.ts', 'skills/multi-task-flow/tests/task-runtime.test.ts'];
12
- if (refresh) manifest.additions = additional.map(path => ({ path, installedSha256: hash(readFileSync(resolve(root, path))) }));
11
+ const additional = [...JSON.parse(readFileSync(resolve(root, 'skills/support-manifest.json'), 'utf8')), 'skills/bin/modules/core/runtime.ts', 'skills/bin/package.json', 'skills/multi-task-flow/tests/service-release.test.ts', 'skills/multi-task-flow/lib/notification-delivery.ts', 'skills/multi-task-flow/tests/notification-delivery.test.ts', 'skills/multi-task-flow/lib/conversation-archive.ts', 'skills/multi-task-flow/lib/task-runtime.ts', 'skills/multi-task-flow/web/task-state.ts', 'skills/multi-task-flow/web/conversation-control.ts', 'skills/multi-task-flow/tests/ui/conversation-control.test.ts', 'skills/multi-task-flow/tests/task-runtime.test.ts', 'skills/multi-task-flow/lib/atomic-orchestration.ts', 'skills/multi-task-flow/tests/atomic-orchestration-schema.test.ts', 'skills/multi-task-flow/tests/task-kind.test.ts', 'skills/multi-task-flow/web/atomic-graph-view.tsx'];
12
+ const sourcePath = file => file.sourcePath ?? file.path;
13
+ if (refresh) manifest.additions = additional.map(path => { const source = path.startsWith('skills/multi-task-flow/') ? 'skills/multi-task-flow-runtime/' + path.slice('skills/multi-task-flow/'.length) : path; return { path, sourcePath: source, installedSha256: hash(readFileSync(resolve(root, source))) }; });
13
14
  const findings = [];
14
15
  const gitRoot = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd:root, encoding:'utf8' });
15
16
  if (gitRoot.status === 0 && gitRoot.stdout.trim() === root) {
@@ -18,6 +19,7 @@ if (gitRoot.status === 0 && gitRoot.stdout.trim() === root) {
18
19
  const covered = new Set([...manifest.files,...(manifest.additions ?? [])].map(file => file.path));
19
20
  for (const file of tracked.stdout.split('\0').filter(Boolean)) {
20
21
  if (/\.(?:js|mjs|cjs)$/.test(file)) findings.push('owned-source-must-be-typescript:'+file);
22
+ if (file.startsWith('skills/multi-task-flow-runtime/')) continue;
21
23
  if (file.startsWith('skills/') && !covered.has(file) && !['skills/source-manifest.json','skills/catalog.json'].includes(file)) findings.push('unlisted:'+file);
22
24
  }
23
25
  }
@@ -27,8 +29,9 @@ for (const excluded of manifest.exclusions ?? []) {
27
29
  if (manifest.files.some(file => file.source.startsWith(excluded.sourcePrefix)) || existsSync(resolve(root, installed))) findings.push(`excluded-resource:${installed}`);
28
30
  }
29
31
  for (const file of [...manifest.files, ...(manifest.additions ?? [])]) {
30
- const target = resolve(root, file.path);
31
- if (!file.path.startsWith('skills/') || !target.startsWith(root + '/') || !existsSync(target)) { findings.push(`missing:${file.path}`); continue; }
32
+ const source = resolve(root, sourcePath(file));
33
+ if (!file.path.startsWith('skills/') || !source.startsWith(root + '/') || !existsSync(source)) { findings.push(`missing:${file.path}`); continue; }
34
+ const target = source;
32
35
  const stat = lstatSync(target);
33
36
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) { findings.push(`unsafe:${file.path}`); continue; }
34
37
  const digest = hash(readFileSync(target));
@@ -12,6 +12,11 @@ export function checkSkillsInstallation(root, { probe = false } = {}) {
12
12
  const inventory = resolve(root, 'skills/source-manifest.json');
13
13
  if (!existsSync(inventory)) return { ok: false, code: 'SKILLS_INVENTORY_MISSING', root, missing: ['skills/source-manifest.json'] };
14
14
  const manifest = JSON.parse(readFileSync(inventory, 'utf8'));
15
+ const localPath = resource => {
16
+ const target = resolve(root, resource.path);
17
+ if (existsSync(target)) return resource.path;
18
+ return resource.sourcePath ?? resource.path;
19
+ };
15
20
  const contained = path => {
16
21
  const file = resolve(root, path);
17
22
  if (!existsSync(file)) return false;
@@ -19,14 +24,15 @@ export function checkSkillsInstallation(root, { probe = false } = {}) {
19
24
  return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && realpathSync(file) === file && realpathSync(file).startsWith(realpathSync(resolve(root, 'skills')) + '/');
20
25
  };
21
26
  const missing = [...manifest.files, ...(manifest.additions ?? [])]
22
- .map(file => file.path).filter(path => !path.startsWith('skills/') || path.split('/').includes('..') || !contained(path));
23
- const modified = [...manifest.files, ...(manifest.additions ?? [])].filter(file => !missing.includes(file.path) && createHash("sha256").update(readFileSync(resolve(root,file.path))).digest("hex") !== file.installedSha256).map(file => file.path);
27
+ .map(file => localPath(file)).filter(path => !path.startsWith('skills/') || path.split('/').includes('..') || !contained(path));
28
+ const modified = [...manifest.files, ...(manifest.additions ?? [])].filter(file => { const path = localPath(file); return !missing.includes(path) && createHash("sha256").update(readFileSync(resolve(root,path))).digest("hex") !== file.installedSha256; }).map(file => file.path);
24
29
  const escapedImports = [];
25
30
  for (const file of [...manifest.files, ...(manifest.additions ?? [])]) {
26
- if (!/\.(ts|mjs)$/.test(file.path) || !file.path.startsWith('skills/') || file.path.split('/').includes('..') || !contained(file.path)) continue;
27
- const text = readFileSync(resolve(root, file.path), 'utf8');
31
+ const path = localPath(file);
32
+ if (!/\.(ts|mjs)$/.test(path) || !path.startsWith('skills/') || path.split('/').includes('..') || !contained(path)) continue;
33
+ const text = readFileSync(resolve(root, path), 'utf8');
28
34
  for (const match of text.matchAll(/(?:from\s*|import\s*\(?\s*)["'](\.[^"']+)["']/g)) {
29
- const target = resolve(root, dirname(file.path), match[1]);
35
+ const target = resolve(root, dirname(path), match[1]);
30
36
  if (!target.startsWith(resolve(root, 'skills') + '/')) escapedImports.push({ file: file.path, import: match[1] });
31
37
  }
32
38
  }
@@ -46,7 +52,7 @@ export function checkSkillsInstallation(root, { probe = false } = {}) {
46
52
  const dependencies = { status:!sourceComplete ? 'not_checked' : dependencyMissing.length ? 'missing' : 'prepared', missing:dependencyMissing };
47
53
  const entrypoints = { status: dependencyMissing.length ? 'blocked' : 'not_checked', results: [] };
48
54
  if (probe && sourceComplete && runtime.available && dependencyMissing.length === 0) {
49
- for (const [script, args] of [['skills/bin/plan-cli.sh',['--help']], ['skills/dot-codex-development/scripts/context.ts',['--help']], ['skills/multi-task-flow/scripts/orchestrate.ts',['view','--input','-']]]) {
55
+ for (const [script, args] of [['skills/bin/plan-cli.sh',['--help']], ['skills/dot-codex-development/scripts/context.ts',['--help']], ['skills/multi-task-flow-runtime/scripts/orchestrate.ts',['view','--input','-']]]) {
50
56
  const result = spawnSync(script.endsWith('.sh') ? '/bin/bash' : process.execPath, [...(script.endsWith('.sh') ? ['-p'] : ['--no-install']), resolve(root,script), ...args], { cwd:root, env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH:'0' }, input:'{}', encoding:'utf8', timeout:30000 });
51
57
  const expected = script.endsWith('orchestrate.ts') ? 2 : 0;
52
58
  entrypoints.results.push({script, code:result.status, ok:!result.error && result.status === expected, error: result.error ? String(result.error) : result.status === expected ? undefined : result.stderr});
@@ -69,11 +75,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1]
69
75
  const probe = process.argv.includes('--probe');
70
76
  const args = process.argv.slice(2).filter(arg => arg !== '--probe');
71
77
  if (args.length !== 0 && (args.length !== 2 || args[0] !== '--root')) {
72
- console.error('Usage: bun skills/multi-task-flow/scripts/check-skills-installation.ts [--root PATH] [--probe]');
78
+ console.error('Usage: bun skills/multi-task-flow-runtime/scripts/check-skills-installation.ts [--root PATH] [--probe]');
73
79
  process.exitCode = 2;
74
80
  } else {
75
81
  try {
76
- const report = checkSkillsInstallation(args[1] ?? resolve(import.meta.dirname, '../..'), {probe});
82
+ const report = checkSkillsInstallation(args[1] ?? resolve(import.meta.dirname, '../../..'), {probe});
77
83
  console.log(JSON.stringify(report));
78
84
  process.exitCode = report.ok ? 0 : 1;
79
85
  } catch (error) {
@@ -7,7 +7,7 @@ import { dirname, resolve } from 'node:path';
7
7
 
8
8
  const source = process.argv[2];
9
9
  const commit = process.argv[3] ?? 'HEAD';
10
- if (!source) throw new Error('Usage: bun skills/multi-task-flow/scripts/import-client-skills.ts SOURCE_REPOSITORY [SOURCE_REF]');
10
+ if (!source) throw new Error('Usage: bun skills/multi-task-flow-runtime/scripts/import-client-skills.ts SOURCE_REPOSITORY [SOURCE_REF]');
11
11
  const git = (...args) => execFileSync('git', ['-C', source, ...args], { maxBuffer: 32 * 1024 * 1024 });
12
12
  const tree = git('ls-tree', '-rz', commit, 'skills', 'bin', 'docs').toString().split('\0').filter(Boolean);
13
13
  const files = [];
@@ -60,8 +60,8 @@ const outputs = new Map<string, { path: string; sha256: string }>();
60
60
  function record(path: string, contents: Uint8Array): void {
61
61
  outputs.set(path, { path, sha256: createHash('sha256').update(contents).digest('hex') });
62
62
  }
63
- function copy(path: string, expected: string | undefined, dependency = false): void {
64
- const source = resolve(root, path);
63
+ function copy(path: string, expected: string | undefined, dependency = false, sourcePath = path): void {
64
+ const source = resolve(root, sourcePath);
65
65
  if (!path.startsWith('skills/') || !source.startsWith(resolve(root, 'skills') + '/')) throw new Error('Unsafe toolkit resource path');
66
66
  const stat = lstatSync(source);
67
67
  if (!stat.isFile() || stat.isSymbolicLink() || (!dependency && stat.nlink !== 1) || realpathSync(source) !== source) throw new Error('Unsafe toolkit resource: ' + path);
@@ -104,10 +104,10 @@ function copyPackageManifest(): void {
104
104
  const packageManifest = { ...manifest, files, additions, packageOmissions };
105
105
  writeGenerated('skills/source-manifest.json', Buffer.from(JSON.stringify(packageManifest, null, 2) + '\n'));
106
106
  }
107
- for (const file of [...manifest.files, ...(manifest.additions ?? [])]) if (!packageOmissionPaths.has(file.path)) copy(file.path, file.installedSha256);
107
+ for (const file of [...manifest.files, ...(manifest.additions ?? [])]) if (!packageOmissionPaths.has(file.path)) copy(file.path, file.installedSha256, false, file.sourcePath ?? file.path);
108
108
  copyPackageManifest();
109
109
  copy('skills/catalog.json');
110
- for (const name of ['index.html','app.js','app.css']) copy(`skills/multi-task-flow/dist/${name}`);
110
+ for (const name of ['index.html','app.js','app.css']) copy(`skills/multi-task-flow/dist/${name}`, undefined, false, `skills/multi-task-flow-runtime/dist/${name}`);
111
111
  if (requireDependencies) validateInstalledDependencies();
112
112
  copyRootFile('AGENTS.md');
113
113
  const packageIdentityBase = {
@@ -10,7 +10,7 @@ const temporary = realpathSync(mkdtempSync(join(tmpdir(), 'viewer-source-test-')
10
10
  const output = join(temporary, 'package');
11
11
  const env = { ...process.env, MULTI_TASK_FLOW_PROFILE:'test', MULTI_TASK_FLOW_ROOT:join(temporary,'state') };
12
12
  try {
13
- const build=spawnSync(process.execPath,[join(root,'skills/multi-task-flow/scripts/build-service.ts'),'--output',output,'--service-version','0.0.0-test'],{cwd:temporary,env,encoding:'utf8',timeout:120000});
13
+ const build=spawnSync(process.execPath,[join(root,'skills/multi-task-flow-runtime/scripts/build-service.ts'),'--output',output,'--service-version','0.0.0-test'],{cwd:temporary,env,encoding:'utf8',timeout:120000});
14
14
  assert.equal(build.status,0,build.stderr);
15
15
  const manifest=JSON.parse(readFileSync(join(output,'manifest.json'),'utf8'));
16
16
  assert.equal(manifest.schema_version,2);
@@ -25,6 +25,11 @@ test("represents closed PRs without treating them as success", () => {
25
25
  expect(parsed.state).toBe("closed");
26
26
  });
27
27
 
28
+ test("represents a singleton task by the delivery-unit PR binding", () => {
29
+ const parsed = taskPrBinding.parse({ state: "shared_delivery_unit", delivery_unit_id: "du-1" });
30
+ expect(parsed).toEqual({ state: "shared_delivery_unit", delivery_unit_id: "du-1" });
31
+ });
32
+
28
33
  test("rejects cyclic atomic graphs and empty planned milestones", () => {
29
34
  const nodes = [
30
35
  { kind: "milestone", id: "m-1", title: "M1", state: "planned", scope: { kind: "delivery_unit", delivery_unit_id: "du-1" }, acceptance: [{ kind: "node_satisfied", node_id: "w-1" }] },
@@ -5,7 +5,7 @@ The agreed seam is the rendered mock example: click a task to open details and c
5
5
  Build the existing `demo:static` surface and use its current owned preview server. In the Codex in-app browser REPL, bind the preview tab and viewport capability, import the checked-in runner, then run it:
6
6
 
7
7
  ```js
8
- var regression = await import("file:///absolute/repo/skills/multi-task-flow/tests/browser/card-width-regression.ts");
8
+ var regression = await import("file:///absolute/repo/skills/multi-task-flow-runtime/tests/browser/card-width-regression.ts");
9
9
  await regression.checkCardWidths(tab, viewport);
10
10
  ```
11
11