@aiwg/cli 2026.10.10 → 2026.10.11

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 (34) hide show
  1. package/agentic/code/providers/model-capabilities.v1.json +78 -14
  2. package/dist/src/cli/handlers/sessions.js +198 -15
  3. package/dist/src/cli/handlers/use.js +12 -4
  4. package/dist/src/config/worker-context.js +337 -0
  5. package/dist/src/mcp/tools/worker-invoke.mjs +108 -10
  6. package/dist/src/models/model-capabilities.v1.json +78 -14
  7. package/dist/src/models/provider-policy.js +7 -0
  8. package/dist/src/policy/authorization.js +29 -1
  9. package/dist/src/process/aiwg-probe-command.d.mts +15 -0
  10. package/dist/src/process/aiwg-probe-command.mjs +37 -0
  11. package/dist/src/sessions/adapters/grokbot.js +7 -0
  12. package/dist/src/sessions/batch-import.js +34 -6
  13. package/dist/src/sessions/contracts.js +19 -0
  14. package/dist/src/sessions/coverage-preflight.js +7 -1
  15. package/dist/src/sessions/fortemi-export-mapping.js +33 -3
  16. package/dist/src/sessions/host-catalog.js +157 -0
  17. package/dist/src/sessions/image-blobs.js +141 -0
  18. package/dist/src/sessions/importer.js +23 -3
  19. package/dist/src/sessions/index.js +2 -0
  20. package/dist/src/sessions/refresh.js +25 -2
  21. package/dist/src/sessions/repository.js +164 -22
  22. package/dist/src/sessions/workspace-discovery.js +5 -2
  23. package/package.json +7 -1
  24. package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
  25. package/tools/ralph-external/lib/worker-context.mjs +237 -0
  26. package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
  27. package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
  28. package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
  29. package/tools/ralph-external/lib/worker-isolation.mjs +17 -29
  30. package/tools/ralph-external/lib/worker-launch-cli.mjs +3 -1
  31. package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
  32. package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
  33. package/tools/ralph-external/lib/worker-termination.mjs +82 -0
  34. package/tools/ralph-external/session-launcher.mjs +51 -96
@@ -2,8 +2,10 @@ import { requireFeaturePackage } from '../features/runtime.js';
2
2
  import { CandidateReviewReceiptSchema, DeletionReceiptSchema, IntelligenceCandidateSchema, PromotionDependencyDecisionSchema, PromotionReceiptSchema, SessionContractError, sha256, } from './contracts.js';
3
3
  import { coverageFromBatchRun, } from './batch-contracts.js';
4
4
  import { redactSessionText, redactSessionValue, sanitizeSessionEvent, sanitizeNativeExtensions } from './policy.js';
5
+ import { extractBatchEventImages, isImageDigest, } from './image-blobs.js';
5
6
  import { effectiveSessionLifecycle } from './lifecycle.js';
6
7
  import { deriveSessionAnalytics, } from './analytics.js';
8
+ import { HOST_REFERENCE_TABLE_DDL, } from './host-catalog.js';
7
9
  const POLICY_PROVIDER_MIGRATION = 'policy-provider-identity:v2';
8
10
  const EVENT_ORIGIN_INTENT_MIGRATION = 'event-origin-intent:v1';
9
11
  const VISIBLE_SESSION_PREDICATE = `s.lifecycle != 'tombstoned' AND EXISTS (
@@ -114,6 +116,17 @@ export class SessionRepository {
114
116
  CREATE TABLE IF NOT EXISTS session_catalog_meta (
115
117
  key TEXT PRIMARY KEY, value TEXT NOT NULL
116
118
  );
119
+ -- Content-addressed tool-output image bytes (#3265). One row per
120
+ -- content hash per catalog: repeat imports of identical images share
121
+ -- the row (INSERT OR IGNORE). Events reference blobs by digest and
122
+ -- keep only a bracketed image placeholder (sha256, type, size) in
123
+ -- searchableText, so image bytes never reach FTS. Pre-#3265 rows with
124
+ -- inline bytes are left untouched (no destructive migration).
125
+ CREATE TABLE IF NOT EXISTS session_image_blobs (
126
+ digest TEXT PRIMARY KEY, media_type TEXT NOT NULL,
127
+ byte_length INTEGER NOT NULL, source_locator TEXT,
128
+ source_offset INTEGER, bytes BLOB NOT NULL
129
+ );
117
130
  CREATE TABLE IF NOT EXISTS session_analytics_facts (
118
131
  fact_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL,
119
132
  session_id TEXT NOT NULL, event_id TEXT NOT NULL,
@@ -144,6 +157,9 @@ export class SessionRepository {
144
157
  -- alignment without reading searchable text or event JSON.
145
158
  CREATE INDEX IF NOT EXISTS idx_fts_event_id ON session_event_fts_content(c0);
146
159
  `);
160
+ // Workspace pointers at account-scoped sessions stored in the host
161
+ // catalog (#3232). References, never copies; legacy copied rows stay.
162
+ this.db.exec(HOST_REFERENCE_TABLE_DDL);
147
163
  const deletionReceiptColumns = this.db.prepare('PRAGMA table_info(deletion_receipts)').all().map((row) => String(row.name));
148
164
  if (!deletionReceiptColumns.includes('workspace_id')) {
149
165
  this.db.exec('ALTER TABLE deletion_receipts ADD COLUMN workspace_id TEXT');
@@ -479,6 +495,18 @@ export class SessionRepository {
479
495
  };
480
496
  }
481
497
  applyImport(batch, checkpoint, publish = true) {
498
+ // Lift inline image payloads out of event text before redaction (#3265):
499
+ // base64 data URLs in text survive redaction and would otherwise be
500
+ // stored inline. Provider image blocks in extensions keep their existing
501
+ // redaction to markers; their bytes are not retained.
502
+ // Idempotent -- placeholder text from an earlier pass matches no image
503
+ // pattern, so importer-extracted batches and direct writers converge.
504
+ // Bytes are parked beside the batch: the redaction pass below walks
505
+ // every value and would flatten Uint8Array into plain objects.
506
+ const extracted = extractBatchEventImages(batch);
507
+ const imageBlobs = extracted.imageBlobs;
508
+ batch = { ...batch, events: extracted.events };
509
+ delete batch.imageBlobs;
482
510
  // Guard callers that submit normalized batches directly as well as adapters.
483
511
  batch = redactSessionValue({
484
512
  ...batch,
@@ -525,6 +553,17 @@ export class SessionRepository {
525
553
  const merged = mergeSessionAggregate(prior, session);
526
554
  this.db.prepare(`UPDATE sessions SET source_digest=?, lifecycle=?, data=? WHERE session_id=?`).run(merged.sourceDigest, merged.lifecycle, JSON.stringify(merged), merged.sessionId);
527
555
  }
556
+ // Persist image bytes once per content hash per catalog (#3265).
557
+ // INSERT OR IGNORE keeps the first-seen row when identical images
558
+ // arrive in later batches or replayed records.
559
+ const insertImageBlob = this.db.prepare(`INSERT OR IGNORE INTO session_image_blobs
560
+ (digest, media_type, byte_length, source_locator, source_offset, bytes)
561
+ VALUES (?, ?, ?, ?, ?, ?)`);
562
+ for (const image of imageBlobs) {
563
+ if (!isImageDigest(image.digest))
564
+ continue;
565
+ insertImageBlob.run(image.digest, image.mediaType, image.byteLength, image.locatorClass ?? null, image.offset ?? null, Buffer.from(image.bytes));
566
+ }
528
567
  const insertEvent = this.db.prepare(`INSERT OR IGNORE INTO session_events
529
568
  (event_id, session_id, source_id, import_run_id, sequence_no, digest, data)
530
569
  VALUES (?, ?, ?, ?, ?, ?, ?)`);
@@ -746,6 +785,49 @@ export class SessionRepository {
746
785
  listWorkspaceIds() {
747
786
  return this.db.prepare('SELECT DISTINCT workspace_id FROM sessions ORDER BY workspace_id').all().map((row) => String(row.workspace_id));
748
787
  }
788
+ /**
789
+ * Record a workspace pointer at an account-scoped session stored in the
790
+ * host catalog (#3232). Non-destructive: existing copied rows are left
791
+ * untouched and remain readable.
792
+ */
793
+ recordHostSessionReference(reference) {
794
+ this.db.prepare(`INSERT INTO session_host_refs
795
+ (provider, native_session_id, session_id, source_id, host_catalog)
796
+ VALUES (?, ?, ?, ?, ?)
797
+ ON CONFLICT(provider, native_session_id) DO UPDATE SET
798
+ session_id=excluded.session_id, source_id=excluded.source_id,
799
+ host_catalog=excluded.host_catalog`).run(reference.provider, reference.nativeSessionId, reference.sessionId, reference.sourceId, reference.hostCatalog);
800
+ }
801
+ listHostSessionReferences() {
802
+ return this.db.prepare(`SELECT provider, native_session_id, session_id, source_id, host_catalog
803
+ FROM session_host_refs ORDER BY provider, native_session_id`).all().map((row) => ({
804
+ provider: String(row.provider),
805
+ nativeSessionId: String(row.native_session_id),
806
+ sessionId: String(row.session_id),
807
+ sourceId: String(row.source_id),
808
+ hostCatalog: String(row.host_catalog),
809
+ }));
810
+ }
811
+ /**
812
+ * Visible sessions reduced to aggregate identities (plus the internal ids
813
+ * reference recording needs). Aggregate search/analytics over several
814
+ * catalogs dedupe on provider + native session so legacy copied rows
815
+ * count once (#3232).
816
+ */
817
+ listSessionIdentities(workspaceId) {
818
+ const where = workspaceId
819
+ ? `s.workspace_id=? AND ${VISIBLE_SESSION_PREDICATE}`
820
+ : VISIBLE_SESSION_PREDICATE;
821
+ const params = workspaceId ? [workspaceId] : [];
822
+ return this.db.prepare(`SELECT json_extract(s.data, '$.provider') AS provider, s.native_session_id,
823
+ s.session_id, s.source_id
824
+ FROM sessions s WHERE ${where} ORDER BY s.session_id`).all(...params).map((row) => ({
825
+ provider: String(row.provider),
826
+ nativeSessionId: String(row.native_session_id),
827
+ sessionId: String(row.session_id),
828
+ sourceId: String(row.source_id),
829
+ }));
830
+ }
749
831
  saveBatchImportRun(run) {
750
832
  run = redactSessionValue(run).value;
751
833
  const save = this.db.transaction(() => {
@@ -889,6 +971,62 @@ export class SessionRepository {
889
971
  ORDER BY e.sequence_no, e.event_id`).all(...(workspaceId ? [sessionId, workspaceId] : [sessionId]))
890
972
  .map((row) => JSON.parse(String(row.data)));
891
973
  }
974
+ /**
975
+ * Distinct image blobs in this catalog. Identical images share one row
976
+ * per content hash, so this counts unique images, not references (#3265).
977
+ */
978
+ countImageBlobs() {
979
+ return Number(this.db.prepare('SELECT COUNT(*) AS count FROM session_image_blobs').get()?.count ?? 0);
980
+ }
981
+ /**
982
+ * Fetch one image blob by content digest. Returns null when the blob is
983
+ * unknown or the digest is malformed -- callers report that as an
984
+ * "unavailable" reference rather than failing the whole read (#3265).
985
+ * Pre-#3265 rows carry inline bytes instead of references and never reach
986
+ * this lookup; they load verbatim through `listEvents`.
987
+ */
988
+ getImageBlob(digest) {
989
+ if (!isImageDigest(digest))
990
+ return null;
991
+ const row = this.db.prepare(`SELECT digest, media_type, byte_length, source_locator, source_offset, bytes
992
+ FROM session_image_blobs WHERE digest=?`).get(digest);
993
+ if (!row)
994
+ return null;
995
+ return {
996
+ digest: String(row.digest),
997
+ mediaType: String(row.media_type),
998
+ byteLength: Number(row.byte_length),
999
+ sourceLocator: row.source_locator === null ? null : String(row.source_locator),
1000
+ sourceOffset: row.source_offset === null ? null : Number(row.source_offset),
1001
+ bytes: Uint8Array.from(row.bytes),
1002
+ };
1003
+ }
1004
+ /**
1005
+ * Resolve an event's image references against the blob store. Entries
1006
+ * whose bytes are present resolve `available`; entries whose blob is
1007
+ * missing (pruned catalog, legacy reference) resolve `unavailable` with
1008
+ * the reference metadata intact so callers can report it (#3265). Events
1009
+ * without references -- including pre-#3265 inline rows -- resolve to [].
1010
+ */
1011
+ resolveEventImages(event) {
1012
+ return (event.imageRefs ?? []).map((ref) => {
1013
+ const stored = this.getImageBlob(ref.digest);
1014
+ if (!stored)
1015
+ return { ...ref, status: 'unavailable' };
1016
+ return {
1017
+ digest: ref.digest,
1018
+ mediaType: stored.mediaType,
1019
+ byteLength: stored.byteLength,
1020
+ ...(ref.locatorClass !== undefined
1021
+ ? { locatorClass: ref.locatorClass }
1022
+ : stored.sourceLocator !== null ? { locatorClass: stored.sourceLocator } : {}),
1023
+ ...(ref.offset !== undefined
1024
+ ? { offset: ref.offset }
1025
+ : stored.sourceOffset !== null ? { offset: stored.sourceOffset } : {}),
1026
+ status: 'available',
1027
+ };
1028
+ });
1029
+ }
892
1030
  listTimelineInputs(workspaceId) {
893
1031
  return this.db.prepare(`SELECT s.data AS session_data, e.data AS event_data
894
1032
  FROM session_events e
@@ -1168,28 +1306,7 @@ export class SessionRepository {
1168
1306
  }
1169
1307
  analyticsSummary(options) {
1170
1308
  const facts = this.listAnalyticsFacts({ ...options, limit: options.limit ?? 5_000 });
1171
- const byCategory = countBy(facts, (fact) => fact.category);
1172
- const byStatus = countBy(facts, (fact) => fact.status);
1173
- const byProvider = countBy(facts, (fact) => fact.provider);
1174
- const byTool = countBy(facts.filter((fact) => fact.toolName), (fact) => fact.toolName);
1175
- return {
1176
- analyticsVersion: '1.0.0',
1177
- workspaceId: options.workspaceId,
1178
- totals: {
1179
- facts: facts.length,
1180
- sessions: new Set(facts.map((fact) => fact.sessionId)).size,
1181
- toolCalls: facts.filter((fact) => fact.category === 'tool-call').length,
1182
- toolFailures: facts.filter((fact) => fact.category === 'tool-result' && fact.status === 'failed').length,
1183
- escalations: facts.filter((fact) => fact.category === 'escalation').length,
1184
- hitlDecisions: facts.filter((fact) => fact.category === 'hitl').length,
1185
- indicators: facts.filter((fact) => fact.category === 'indicator').length,
1186
- retryGroups: new Set(facts.map((fact) => fact.retryGroupId).filter(Boolean)).size,
1187
- },
1188
- byCategory,
1189
- byStatus,
1190
- byProvider,
1191
- byTool,
1192
- };
1309
+ return summarizeSessionAnalyticsFacts(facts, options.workspaceId);
1193
1310
  }
1194
1311
  getAnalyticsEvidence(id, workspaceId) {
1195
1312
  const row = this.db.prepare(`SELECT f.data AS fact_data, e.data AS event_data
@@ -1852,6 +1969,31 @@ function canonicalDevinLocator(value) {
1852
1969
  ? `devin-desktop-${value.slice('windsurf-'.length)}`
1853
1970
  : value;
1854
1971
  }
1972
+ /**
1973
+ * Summarize analytics facts without touching a catalog, so cross-catalog
1974
+ * merges (workspace facts plus referenced host facts, #3232) report the
1975
+ * same shape as a single-catalog summary.
1976
+ */
1977
+ export function summarizeSessionAnalyticsFacts(facts, workspaceId) {
1978
+ return {
1979
+ analyticsVersion: '1.0.0',
1980
+ workspaceId,
1981
+ totals: {
1982
+ facts: facts.length,
1983
+ sessions: new Set(facts.map((fact) => fact.sessionId)).size,
1984
+ toolCalls: facts.filter((fact) => fact.category === 'tool-call').length,
1985
+ toolFailures: facts.filter((fact) => fact.category === 'tool-result' && fact.status === 'failed').length,
1986
+ escalations: facts.filter((fact) => fact.category === 'escalation').length,
1987
+ hitlDecisions: facts.filter((fact) => fact.category === 'hitl').length,
1988
+ indicators: facts.filter((fact) => fact.category === 'indicator').length,
1989
+ retryGroups: new Set(facts.map((fact) => fact.retryGroupId).filter(Boolean)).size,
1990
+ },
1991
+ byCategory: countBy(facts, (fact) => fact.category),
1992
+ byStatus: countBy(facts, (fact) => fact.status),
1993
+ byProvider: countBy(facts, (fact) => fact.provider),
1994
+ byTool: countBy(facts.filter((fact) => fact.toolName), (fact) => fact.toolName),
1995
+ };
1996
+ }
1855
1997
  function countBy(items, key) {
1856
1998
  const counts = {};
1857
1999
  for (const item of items) {
@@ -269,10 +269,13 @@ export async function discoverWorkspaceHistories(options) {
269
269
  // old entries, so a content-digest sourceId (the default above)
270
270
  // would churn every re-discovery and break checkpoint continuity.
271
271
  // Key it on the conversation id instead, same rationale as the OMP
272
- // override above.
272
+ // override above. Unlike OMP, the key carries no workspaceId: Grok
273
+ // Bot conversations are account-scoped (#3232), so the same replica
274
+ // must resolve to one host-catalog source no matter which workspace
275
+ // authorizes it; workspace catalogs keep a reference instead of a copy.
273
276
  const conversationId = grokbotConversationIdFromLocator(locator);
274
277
  if (conversationId) {
275
- source.sourceId = sha256(['grokbot-native-source-v1', workspaceId, conversationId].join('\0'));
278
+ source.sourceId = sha256(['grokbot-native-source-v1', conversationId].join('\0'));
276
279
  }
277
280
  }
278
281
  providerSources.push(source);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.10.10",
3
+ "version": "2026.10.11",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,12 @@
54
54
  "tools/ralph-external/lib/worker-launch-cli.mjs",
55
55
  "tools/ralph-external/lib/worker-isolation.mjs",
56
56
  "tools/ralph-external/lib/worker-native-sandbox.mjs",
57
+ "tools/ralph-external/lib/worker-isolation-status.mjs",
58
+ "tools/ralph-external/lib/worker-retry-evidence.mjs",
59
+ "tools/ralph-external/lib/worker-termination.mjs",
60
+ "tools/ralph-external/lib/worker-invocation-processes.mjs",
61
+ "tools/ralph-external/lib/worker-invocation-ancestry.mjs",
62
+ "tools/ralph-external/lib/worker-context.mjs",
57
63
  "tools/ralph-external/lib/systemd-client.mjs",
58
64
  "tools/ralph-external/lib/consecutive-failure-circuit-breaker.mjs",
59
65
  "tools/ralph-external/lib/launcher-recovery-evidence.mjs",
@@ -31,6 +31,13 @@
31
31
  * - `--approval-mode <mode>` — common to both; "`muse exec` accepts both
32
32
  * approval flags" (configuration). Opt-in only: the adapter sets NO
33
33
  * approval posture by default (#230 out of scope: `--yolo` defaults).
34
+ * - `--trust-workspace` — loads project rules, skills and hooks for the
35
+ * workspace (#3685: untrusted Muse reports `rules.context_load`
36
+ * `project_sources=0` with user skills only and empty hook admission;
37
+ * trusted reports `project_sources=1` with project skills and hooks).
38
+ * Tier 0 workspaces are conductor-prepared worker trees the conductor
39
+ * owns, so the direct profile binds this flag by default through the
40
+ * host-controlled trust mapping; the brief never supplies it.
34
41
  * - `muse export --session <uuid> --out <path>` — offline,
35
42
  * byte-deterministic transcript projection; `--last` and `--redacted`
36
43
  * skip the picker / redact payload strings (cookbook:
@@ -173,6 +180,9 @@ export class MuseAdapter extends ProviderAdapter {
173
180
  if ((permissions.approval ?? 'never') !== 'never') throw new Error('Muse direct worker approval requires never');
174
181
  // Trust is a host-owned, qualification-bound control, like the schema.
175
182
  // Brief contents and CLI defaults cannot opt into workspace instructions.
183
+ // Tier 0 binds ["--trust-workspace"] because the workspace is always a
184
+ // conductor-prepared worker tree (#3685); untrusted trees stay refused
185
+ // at dispatch by the operator native-argv gate.
176
186
  let trust;
177
187
  try { trust = JSON.parse(profile.controls.trust.mapping); } catch { /* fail closed below */ }
178
188
  if (!Array.isArray(trust) || !(trust.length === 0 || trust.length === 1 && trust[0] === '--trust-workspace')) {
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Worker AIWG context loading and launch-time ensure, tools side (#3685).
3
+ *
4
+ * The provider-set UNION lives in `src/config/worker-context.ts` and the
5
+ * config loads through the existing `readAiwgConfig` loader (never ad-hoc
6
+ * JSON). Both cross the tools/src boundary through `importImpl`, so this
7
+ * module stays runnable from an installed package (`dist/src`) and a
8
+ * checkout (`src/`); every such seam is injectable for synthetic tests.
9
+ * `src/` must never import `tools/`, so the src module is always the
10
+ * callee, never the caller.
11
+ *
12
+ * Also home to the single-pass deploy, section verifier, and
13
+ * skip-worktree helpers extracted from `worktree-preparation.mjs` (which
14
+ * re-exports them) so that file stays agent-friendly.
15
+ */
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+ import { readFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { importImpl } from '../../_resolve-impl.mjs';
21
+
22
+ /** Load the project config through the existing loader. */
23
+ async function defaultReadConfig(repoRoot) {
24
+ const { readAiwgConfig } = await importImpl(import.meta.url, 'config/aiwg-config.js');
25
+ return readAiwgConfig(repoRoot);
26
+ }
27
+
28
+ /** Resolve the provider union through the src-side pure function. */
29
+ async function defaultResolveFromConfig(config) {
30
+ const { resolveWorkerContextProvidersFromConfig } = await importImpl(import.meta.url, 'config/worker-context.js');
31
+ return resolveWorkerContextProvidersFromConfig(config);
32
+ }
33
+
34
+ /** Map one orchestration CLI id through the src-side registry mapping. */
35
+ async function defaultMapCli(cli, parallelism) {
36
+ const { mapCliToProviderId } = await importImpl(import.meta.url, 'config/worker-context.js');
37
+ return mapCliToProviderId(cli, parallelism);
38
+ }
39
+
40
+ /**
41
+ * Resolve the worker-context provider set (#3685 round 2): the union of
42
+ * `.aiwg/aiwg.config` `providers` and every CLI configured for
43
+ * orchestration (`parallelism.clis` keys/adapters and the `cli` of every
44
+ * `parallelism.groups.*.members[]`), mapped through the provider
45
+ * registry. An explicit list wins and is returned verbatim.
46
+ *
47
+ * @returns {Promise<{providers: string[], skipped: string[]}>}
48
+ */
49
+ export async function resolveWorkerContextProviders({ repoRoot, providers = null, readConfig = null, resolveFromConfig = null } = {}) {
50
+ if (providers !== null && providers !== undefined) {
51
+ if (!Array.isArray(providers) || providers.length === 0
52
+ || providers.some(p => typeof p !== 'string' || p.length === 0 || p.includes('\0') || p.includes(','))) {
53
+ throw new Error('worker context providers must be a non-empty array of provider ids');
54
+ }
55
+ return { providers: [...new Set(providers)], skipped: [] };
56
+ }
57
+ if (typeof repoRoot !== 'string' || !repoRoot) {
58
+ throw new Error('worker context providers need a repoRoot or an explicit provider list');
59
+ }
60
+ const config = await (readConfig ?? defaultReadConfig)(repoRoot);
61
+ if (!config) {
62
+ throw new Error(`worker context deployment needs configured providers: cannot read ${path.join(repoRoot, '.aiwg', 'aiwg.config')} (missing or invalid); configure providers and orchestration CLIs, or pass an explicit provider list`);
63
+ }
64
+ return (resolveFromConfig ?? defaultResolveFromConfig)(config);
65
+ }
66
+
67
+ /**
68
+ * Ensure a worker workdir carries its CLI's AIWG bootstrap before launch.
69
+ * Resolves the worker CLI to its provider (from the project config, so
70
+ * orchestration CLIs map through the registry), checks presence, and
71
+ * deploys through the same one-pass helper when missing. The deploy
72
+ * target is always exactly `workdir` — never anywhere else.
73
+ *
74
+ * Injectable for synthetic tests: `ensureFn` stubs the whole ensure,
75
+ * otherwise `mapCli`, `readConfig`, `resolveFromConfig`, `deployFn`,
76
+ * `statusFn`, and `skipWorktreeFn` stub each seam.
77
+ *
78
+ * @returns {Promise<{ensured: boolean, provider: string, providers: string[], skippedBridges: string[], skipped: string[]}>}
79
+ */
80
+ export async function ensureWorkerContextForLaunch({
81
+ workdir,
82
+ cli,
83
+ config = null,
84
+ repoRoot = null,
85
+ providers = null,
86
+ readConfig = null,
87
+ resolveFromConfig = null,
88
+ mapCli = null,
89
+ ensureFn = null,
90
+ statusFn = null,
91
+ deployFn = null,
92
+ skipWorktreeFn = null,
93
+ aiwgBin = process.env.AIWG_BIN ?? 'aiwg',
94
+ } = {}) {
95
+ if (typeof workdir !== 'string' || !workdir) throw new Error('worker context ensure needs a workdir');
96
+ if (typeof cli !== 'string' || !cli) throw new Error('worker context ensure needs the worker CLI id');
97
+ let resolved = Array.isArray(providers) && providers.length > 0 ? { providers: [...new Set(providers)], skipped: [] } : null;
98
+ let loaded = config ?? null;
99
+ if (!resolved) {
100
+ if (!loaded && repoRoot) loaded = await (readConfig ?? defaultReadConfig)(repoRoot);
101
+ if (loaded) {
102
+ resolved = await (resolveFromConfig ?? defaultResolveFromConfig)(loaded);
103
+ }
104
+ }
105
+ const map = mapCli ?? defaultMapCli;
106
+ const mapped = await map(cli, loaded?.parallelism ?? null);
107
+ if (!('provider' in mapped) || !mapped.provider) {
108
+ // Typed-shaped refusal without importing the src error class (which
109
+ // would couple this sync-throwing path to the compiled `dist`
110
+ // layout): callers discriminate on `code`, never `instanceof`.
111
+ throw Object.assign(
112
+ new Error(`cannot ensure worker context: CLI '${cli}' maps to no AIWG provider (${mapped.skipped ?? 'unmapped'})`),
113
+ { code: 'OG_WORKER_CONTEXT_UNAVAILABLE',
114
+ remedy: `configure parallelism.clis.${cli} with a providers:<id> adapter, or use a CLI whose adapter maps to a known provider`,
115
+ workdir },
116
+ );
117
+ }
118
+ const provider = mapped.provider;
119
+ const deployProviders = resolved ? [...new Set([...resolved.providers, provider])] : [provider];
120
+ const ensure = ensureFn ?? (async (opts) => {
121
+ const { ensureWorkerContextBootstrap } = await importImpl(import.meta.url, 'config/worker-context.js');
122
+ return ensureWorkerContextBootstrap(opts);
123
+ });
124
+ const outcome = await ensure({
125
+ workdir, provider, providers: deployProviders, aiwgBin,
126
+ ...(statusFn ? { statusFn } : {}),
127
+ ...(deployFn ? { deployFn } : {}),
128
+ ...(skipWorktreeFn ? { skipWorktreeFn } : {}),
129
+ });
130
+ return { ...outcome, provider, skipped: resolved?.skipped ?? [] };
131
+ }
132
+
133
+ /** Materialize the union set into the worktree config through the src-side sync. */
134
+ async function defaultSyncProviders(worktreePath, providers) {
135
+ const { syncWorkerContextProviders } = await importImpl(import.meta.url, 'config/worker-context.js');
136
+ return syncWorkerContextProviders({ workdir: worktreePath, providers });
137
+ }
138
+
139
+ /** Single-pass argv through the src-side canonical builder (never `--providers`). */
140
+ async function defaultDeployArgv(providers, worktreePath) {
141
+ const { buildWorkerContextDeployArgs } = await importImpl(import.meta.url, 'config/worker-context.js');
142
+ return buildWorkerContextDeployArgs({ providers, target: worktreePath });
143
+ }
144
+
145
+ /**
146
+ * Default single-pass AIWG context deploy (#3685): sync the union set
147
+ * into the worktree config, then one `aiwg use all --target <worktree>`
148
+ * covering every configured provider. Never one deploy per provider:
149
+ * per-provider invocations rewrite AGENTS.md piecemeal and clobber each
150
+ * other's rule sections, while a single pass keeps them all. No
151
+ * `--providers` flag: it never reaches project-scope deploys
152
+ * (`executeCore` resolves from config), so the configured-provider path
153
+ * above is the supported mechanism. Injectable via
154
+ * `prepareWorkerWorktree()`'s `deployContextFn` for synthetic tests;
155
+ * `syncFn` stubs the config sync as `(worktreePath, providers)` and
156
+ * `argvFn` stubs the argv builder as `(providers, target)`.
157
+ *
158
+ * @returns {Promise<{providers: string[]}>}
159
+ */
160
+ export async function defaultDeployWorkerContext({ worktreePath, providers, aiwgBin = 'aiwg', spawn = spawnSync, timeoutMs = 300000, syncFn = null, argvFn = null } = {}) {
161
+ if (typeof worktreePath !== 'string' || !worktreePath || typeof aiwgBin !== 'string' || !aiwgBin) {
162
+ throw new Error('worker context deploy requires a worktree path and an aiwg binary');
163
+ }
164
+ if (!Array.isArray(providers) || providers.length === 0) {
165
+ throw new Error('worker context deploy requires a non-empty provider list');
166
+ }
167
+ const sync = syncFn ?? defaultSyncProviders;
168
+ const synced = await sync(worktreePath, providers);
169
+ const syncedProviders = Array.isArray(synced?.providers) && synced.providers.length > 0 ? synced.providers : [...providers];
170
+ const buildArgv = argvFn ?? defaultDeployArgv;
171
+ const argv = await buildArgv(syncedProviders, worktreePath);
172
+ const result = spawn(aiwgBin, argv, { cwd: worktreePath, encoding: 'utf8', timeout: timeoutMs });
173
+ if (result.error) throw new Error(`aiwg ${argv.join(' ')} failed to run: ${result.error.message}`);
174
+ if (result.status !== 0) {
175
+ throw new Error(`aiwg ${argv.join(' ')} exited ${result.status}: ${String(result.stderr ?? '').trim().slice(0, 2000)}`);
176
+ }
177
+ return { providers: [...syncedProviders] };
178
+ }
179
+
180
+ /** AIWG-managed rule-section markers written into a deployed AGENTS.md. */
181
+ const WORKER_CONTEXT_RULE_MARKER = /<!--\s*AIWG:[A-Za-z0-9_-]+:(start|end)\s*-->/;
182
+
183
+ /**
184
+ * Verify a prepared worktree actually carries AIWG rule sections (#3685):
185
+ * the worktree's AGENTS.md must exist and contain AIWG-managed section
186
+ * markers. Throws before launch otherwise — a worktree without them would
187
+ * run the worker with no project rules/skills/hooks. Injectable via
188
+ * `prepareWorkerWorktree()`'s `verifyContextFn` for synthetic tests.
189
+ *
190
+ * @returns {{verified: boolean, providers: string[]}}
191
+ */
192
+ export function verifyWorkerContextSections({ worktreePath, providers = [] } = {}) {
193
+ const agentsMd = path.join(worktreePath, 'AGENTS.md');
194
+ let content;
195
+ try {
196
+ content = readFileSync(agentsMd, 'utf8');
197
+ } catch {
198
+ throw new Error(`worker context verification failed: ${agentsMd} is missing; the worker would run without AIWG rule sections`);
199
+ }
200
+ if (!WORKER_CONTEXT_RULE_MARKER.test(content)) {
201
+ throw new Error(`worker context verification failed: ${agentsMd} has no AIWG rule sections (expected <!-- AIWG:<rule>:start --> markers for ${[...providers].join(',') || 'the configured providers'})`);
202
+ }
203
+ return { verified: true, providers: [...providers] };
204
+ }
205
+
206
+ /**
207
+ * Tracked files the context deploy modified, so the caller can hide them
208
+ * from the worker diff. Parses `git status --porcelain=v1` with untracked
209
+ * files excluded: every remaining entry is a tracked modification (the
210
+ * fresh worktree starts clean, so each one is deploy-caused).
211
+ *
212
+ * @returns {string[]} Repo-relative paths of tracked-modified files.
213
+ */
214
+ export function listDeployDirtiedBridges({ worktreePath, spawn = spawnSync, timeoutMs = 30000 } = {}) {
215
+ // Raw spawn output, never a trimmed helper: trim would eat the
216
+ // significant leading space of the first porcelain line (` M AGENTS.md`
217
+ // trims to `M AGENTS.md`, shifting slice(3) one byte into the filename).
218
+ const result = spawn('git', ['status', '--porcelain=v1', '--untracked-files=no'], { cwd: worktreePath, encoding: 'utf8', timeout: timeoutMs });
219
+ if (result.error) throw new Error(`git status failed to run: ${result.error.message}`);
220
+ if (result.status !== 0) {
221
+ throw new Error(`git status exited ${result.status}: ${String(result.stderr ?? '').trim()}`);
222
+ }
223
+ const files = [];
224
+ for (const line of String(result.stdout ?? '').split('\n')) {
225
+ if (!line.trim()) continue;
226
+ // `XY PATH` or `XY ORIG -> NEW` for renames; take the new path, which is
227
+ // what the worker diff would report.
228
+ const raw = line.slice(3);
229
+ const arrow = raw.indexOf(' -> ');
230
+ const rel = arrow >= 0 ? raw.slice(arrow + 4) : raw;
231
+ // Porcelain quotes paths with special bytes; a deploy that creates such
232
+ // a name is refused rather than mis-flagged.
233
+ if (!rel || rel.startsWith('"')) throw new Error(`worker context deploy dirtied an unflaggeable path: ${line}`);
234
+ files.push(rel);
235
+ }
236
+ return files;
237
+ }