@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.
- package/agentic/code/providers/model-capabilities.v1.json +78 -14
- package/dist/src/cli/handlers/sessions.js +198 -15
- package/dist/src/cli/handlers/use.js +12 -4
- package/dist/src/config/worker-context.js +337 -0
- package/dist/src/mcp/tools/worker-invoke.mjs +108 -10
- package/dist/src/models/model-capabilities.v1.json +78 -14
- package/dist/src/models/provider-policy.js +7 -0
- package/dist/src/policy/authorization.js +29 -1
- package/dist/src/process/aiwg-probe-command.d.mts +15 -0
- package/dist/src/process/aiwg-probe-command.mjs +37 -0
- package/dist/src/sessions/adapters/grokbot.js +7 -0
- package/dist/src/sessions/batch-import.js +34 -6
- package/dist/src/sessions/contracts.js +19 -0
- package/dist/src/sessions/coverage-preflight.js +7 -1
- package/dist/src/sessions/fortemi-export-mapping.js +33 -3
- package/dist/src/sessions/host-catalog.js +157 -0
- package/dist/src/sessions/image-blobs.js +141 -0
- package/dist/src/sessions/importer.js +23 -3
- package/dist/src/sessions/index.js +2 -0
- package/dist/src/sessions/refresh.js +25 -2
- package/dist/src/sessions/repository.js +164 -22
- package/dist/src/sessions/workspace-discovery.js +5 -2
- package/package.json +7 -1
- package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
- package/tools/ralph-external/lib/worker-context.mjs +237 -0
- package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
- package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
- package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
- package/tools/ralph-external/lib/worker-isolation.mjs +17 -29
- package/tools/ralph-external/lib/worker-launch-cli.mjs +3 -1
- package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
- package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
- package/tools/ralph-external/lib/worker-termination.mjs +82 -0
- 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
|
-
|
|
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',
|
|
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.
|
|
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
|
+
}
|