@tea-agent/loop-agent 0.33.6 → 0.33.7-beta.0
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/CHANGELOG.md +23 -23
- package/dist/application/task-lifecycle/advance.js +4 -254
- package/dist/application/task-lifecycle/gates.js +0 -50
- package/dist/application/task-lifecycle/observe.js +2 -11
- package/dist/commands/init-upgrade.js +1 -32
- package/dist/commands/init.js +3 -94
- package/dist/executors/shell-executor.js +91 -4
- package/dist/executors/shell-write-guard.js +8 -26
- package/dist/shared/operator/capabilities.js +42 -72
- package/dist/task/source-prepare/index.js +0 -2
- package/dist/task/source-prepare/parse-intent.js +10 -58
- package/dist/task/source-prepare/prepare.js +16 -180
- package/dist/task/source-prepare/reference-integrity.js +2 -18
- package/dist/worker/console/app-data.js +0 -2
- package/dist/worker/console/chat/chat-event-store.js +25 -190
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/pi-console-config.js +32 -250
- package/dist/worker/console/chat/pi-runtime.js +71 -625
- package/dist/worker/console/chat/resource-loader.js +4 -5
- package/dist/worker/console/chat/routes.js +146 -324
- package/dist/worker/console/chat/runtime-context.js +12 -48
- package/dist/worker/console/chat/runtime-selection.js +0 -59
- package/dist/worker/console/chat/shortcuts.js +0 -1
- package/dist/worker/console/chat/tool-adapter.js +3 -9
- package/dist/worker/console/chat/tools.js +1 -5
- package/dist/worker/console/operator-actions.js +68 -559
- package/dist/worker/console/server.js +15 -8
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
- package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
- package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
- package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
- package/dist/worker/delivery/final-verification.js +5 -13
- package/dist/worker/delivery/package.js +19 -31
- package/dist/worker/delivery/verification-bundle.js +4 -6
- package/dist/worker/observe/static/operator-chrome.css +2 -5
- package/dist/worker/observe/static/operator-chrome.js +1 -6
- package/dist/worker/observe/static/styles.css +9 -39
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
- package/dist/workflows/dag/backend-test-scenario-param.js +269 -82
- package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/frontend-worktree-diff.js +27 -12
- package/dist/workflows/dag/init-hybrid.js +46 -34
- package/dist/workflows/dag/types.js +7 -0
- package/dist/workflows/dag/workspace-checkpoint.js +27 -8
- package/docs/templates/backend-test-dag.json +32 -29
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -3
- package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
- package/skills/loop-agent/references/task-workflow.md +0 -4
- package/dist/shared/resilient-git.js +0 -133
- package/dist/task/source-prepare/artifact-meta.js +0 -137
- package/dist/task/source-prepare/semantic-intake.js +0 -404
- package/dist/worker/console/dag-execution-receipt.js +0 -380
- package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
- package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +0 -196
|
@@ -169,69 +169,7 @@ export function projectOperationEventSummary(event) {
|
|
|
169
169
|
.digest("hex"),
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
|
-
/** Conservative per-event byte contribution (compact JSON length × UTF-8/pretty scale). */
|
|
173
|
-
function byteHint(value) {
|
|
174
|
-
return JSON.stringify(value).length * BYTE_GATE_SCALE;
|
|
175
|
-
}
|
|
176
|
-
function recomputeApproxBytes(ring) {
|
|
177
|
-
let total = BYTE_GATE_SLACK;
|
|
178
|
-
for (const event of ring.events)
|
|
179
|
-
total += byteHint(event);
|
|
180
|
-
for (const turn of ring.turns)
|
|
181
|
-
total += byteHint(turn);
|
|
182
|
-
ring.approxBytes = total;
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* D3 eviction priority: drop the single oldest candidate — text updates
|
|
186
|
-
* (`message_update`) first, structural events second, and `message_end` (the
|
|
187
|
-
* terminal full-text event) absolutely last. Falls back to the oldest event
|
|
188
|
-
* (legacy `shift()` order) when only `message_end` remain.
|
|
189
|
-
*/
|
|
190
|
-
function dropOneByPriority(ring) {
|
|
191
|
-
const events = ring.events;
|
|
192
|
-
if (events.length === 0)
|
|
193
|
-
return undefined;
|
|
194
|
-
const textIndex = events.findIndex((event) => event.kind === "message_update");
|
|
195
|
-
if (textIndex !== -1)
|
|
196
|
-
return events.splice(textIndex, 1)[0];
|
|
197
|
-
const structuralIndex = events.findIndex((event) => event.kind !== "message_end");
|
|
198
|
-
if (structuralIndex !== -1)
|
|
199
|
-
return events.splice(structuralIndex, 1)[0];
|
|
200
|
-
return events.shift();
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* D2 coalescing decision: merge an incoming `message_update` into the ring tail
|
|
204
|
-
* only when the tail is also a same-turn `message_update` and the update landed
|
|
205
|
-
* inside the coalescing window. Structural events close the window implicitly
|
|
206
|
-
* (their presence makes the tail check fail), so an
|
|
207
|
-
* update → tool_call → update sequence never merges across the tool boundary.
|
|
208
|
-
* `partial.at` (when present) drives the clock so tests can inject window
|
|
209
|
-
* boundaries deterministically without fake timers.
|
|
210
|
-
*/
|
|
211
|
-
function shouldCoalesce(ring, turnId, partial) {
|
|
212
|
-
if (partial.kind !== "message_update")
|
|
213
|
-
return false;
|
|
214
|
-
const tail = ring.events.at(-1);
|
|
215
|
-
if (!tail || tail.kind !== "message_update")
|
|
216
|
-
return false;
|
|
217
|
-
if (tail.turnId !== turnId)
|
|
218
|
-
return false;
|
|
219
|
-
const nowMs = partial.at ? Date.parse(partial.at) : Date.now();
|
|
220
|
-
if (Number.isNaN(nowMs))
|
|
221
|
-
return false;
|
|
222
|
-
return nowMs - Date.parse(tail.at) <= ring.coalesceWindowMs;
|
|
223
|
-
}
|
|
224
172
|
const DEFAULT_MAX_EVENTS = 2000;
|
|
225
|
-
/** Mirrors pi-web's bounded-ring orientation (2000 events / 10 MiB). */
|
|
226
|
-
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
227
|
-
/** Same-turn `message_update` events inside this window coalesce into one persisted event. */
|
|
228
|
-
const DEFAULT_COALESCE_WINDOW_MS = 100;
|
|
229
|
-
/** Conservative per-event scale for the byte pre-check gate (UTF-8 multibyte ≤ 4 bytes/char × pretty-print expansion). */
|
|
230
|
-
const BYTE_GATE_SCALE = 12;
|
|
231
|
-
/** Fixed envelope slack (schema keys, turnCount, nextSeq, minSeq, turns) for the byte pre-check gate. */
|
|
232
|
-
const BYTE_GATE_SLACK = 8192;
|
|
233
|
-
/** Legacy epoch for event files written before M5.1 (no epoch field). */
|
|
234
|
-
const LEGACY_EPOCH = "legacy";
|
|
235
173
|
/** Make a sessionId safe to use as a filename component. */
|
|
236
174
|
function safeSessionId(sessionId) {
|
|
237
175
|
return sessionId.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 180);
|
|
@@ -354,9 +292,6 @@ export function createChatOperationLinker(options) {
|
|
|
354
292
|
}
|
|
355
293
|
export function createChatEventStore(options) {
|
|
356
294
|
const maxEvents = options?.maxEventsPerSession ?? DEFAULT_MAX_EVENTS;
|
|
357
|
-
const maxBytes = options?.maxBytesPerSession ?? DEFAULT_MAX_BYTES;
|
|
358
|
-
const coalesceWindowMs = options?.coalesceWindowMs ?? DEFAULT_COALESCE_WINDOW_MS;
|
|
359
|
-
const eventEpoch = options?.epoch?.trim() || LEGACY_EPOCH;
|
|
360
295
|
const persistenceDir = options?.appData?.chatEvents
|
|
361
296
|
? path.resolve(options.appData.chatEvents)
|
|
362
297
|
: undefined;
|
|
@@ -372,12 +307,6 @@ export function createChatEventStore(options) {
|
|
|
372
307
|
? path.join(persistenceDir, `${safeSessionId(sessionId)}.json`)
|
|
373
308
|
: undefined;
|
|
374
309
|
}
|
|
375
|
-
function eventIdFor(sessionId, seq) {
|
|
376
|
-
// Preserve the pre-M5.1 test/file surface for stores that did not opt in.
|
|
377
|
-
return eventEpoch === LEGACY_EPOCH
|
|
378
|
-
? `${sessionId}:${seq}`
|
|
379
|
-
: `${eventEpoch}:${seq}`;
|
|
380
|
-
}
|
|
381
310
|
function loadRing(sessionId) {
|
|
382
311
|
const file = persistedPath(sessionId);
|
|
383
312
|
if (!file || !existsSync(file))
|
|
@@ -389,41 +318,26 @@ export function createChatEventStore(options) {
|
|
|
389
318
|
!Array.isArray(parsed.events)) {
|
|
390
319
|
throw new Error("invalid persisted chat event ring");
|
|
391
320
|
}
|
|
392
|
-
|
|
393
|
-
// deliberately remap their ids into this store's generation namespace;
|
|
394
|
-
// clients with an old epoch then reconcile rather than cross generations.
|
|
395
|
-
const events = parsed.events
|
|
396
|
-
.slice(-maxEvents)
|
|
397
|
-
.map((event) => ({
|
|
398
|
-
...event,
|
|
399
|
-
eventId: eventIdFor(sessionId, event.seq),
|
|
400
|
-
}));
|
|
321
|
+
const events = parsed.events.slice(-maxEvents);
|
|
401
322
|
const turns = Array.isArray(parsed.turns) ? parsed.turns : [];
|
|
402
323
|
turnCounts.set(sessionId, parsed.turnCount ?? 0);
|
|
403
|
-
|
|
324
|
+
return {
|
|
404
325
|
events,
|
|
405
326
|
turns,
|
|
406
327
|
nextSeq: Math.max(parsed.nextSeq, (events[events.length - 1]?.seq ?? 0) + 1),
|
|
407
328
|
minSeq: events[0]?.seq ?? parsed.minSeq ?? 1,
|
|
408
329
|
maxEvents,
|
|
409
|
-
maxBytes,
|
|
410
|
-
coalesceWindowMs,
|
|
411
|
-
approxBytes: 0,
|
|
412
|
-
epoch: eventEpoch,
|
|
413
330
|
};
|
|
414
|
-
recomputeApproxBytes(ring);
|
|
415
|
-
// R6: re-check the bounds after a restart — a persisted file may exceed the
|
|
416
|
-
// current limits if it was written by an older config or hand-edited.
|
|
417
|
-
enforceBounds(sessionId, ring);
|
|
418
|
-
return ring;
|
|
419
331
|
}
|
|
420
332
|
catch (error) {
|
|
421
333
|
throw new Error(`invalid persisted chat events ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
422
334
|
}
|
|
423
335
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
336
|
+
function persist(sessionId, ring) {
|
|
337
|
+
const file = persistedPath(sessionId);
|
|
338
|
+
if (!file)
|
|
339
|
+
return;
|
|
340
|
+
writeSecureJsonSync(file, {
|
|
427
341
|
schemaVersion: 1,
|
|
428
342
|
sessionId,
|
|
429
343
|
turnCount: turnCounts.get(sessionId) ?? 0,
|
|
@@ -431,48 +345,7 @@ export function createChatEventStore(options) {
|
|
|
431
345
|
events: ring.events,
|
|
432
346
|
nextSeq: ring.nextSeq,
|
|
433
347
|
minSeq: ring.minSeq,
|
|
434
|
-
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
/** Serialized exactly as `writeSecureJsonSync` writes it, so byte accounting
|
|
438
|
-
* (D4) and the on-disk bytes use the same formatting. */
|
|
439
|
-
function serializeRingForPersist(sessionId, ring) {
|
|
440
|
-
return `${JSON.stringify(ringPayload(sessionId, ring), null, 2)}\n`;
|
|
441
|
-
}
|
|
442
|
-
function bytesOfRing(sessionId, ring) {
|
|
443
|
-
return Buffer.byteLength(serializeRingForPersist(sessionId, ring), "utf8");
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* Enforce the `maxEvents + maxBytes` dual bound (D3/D4). maxEvents first
|
|
447
|
-
* (count), then bytes against the real serialized payload; every drop removes
|
|
448
|
-
* the oldest eviction candidate by priority. The byte loop is bounded because
|
|
449
|
-
* each iteration removes an event and stops on an empty ring. A conservative
|
|
450
|
-
* pre-check skips the exact serialization for small rings (keeps the frequent
|
|
451
|
-
* per-token append path O(1)-ish).
|
|
452
|
-
*/
|
|
453
|
-
function enforceBounds(sessionId, ring) {
|
|
454
|
-
while (ring.events.length > ring.maxEvents) {
|
|
455
|
-
const dropped = dropOneByPriority(ring);
|
|
456
|
-
if (dropped)
|
|
457
|
-
ring.approxBytes -= byteHint(dropped);
|
|
458
|
-
}
|
|
459
|
-
if (ring.events.length > 0 && ring.approxBytes > ring.maxBytes) {
|
|
460
|
-
while (ring.events.length > 0 &&
|
|
461
|
-
bytesOfRing(sessionId, ring) > ring.maxBytes) {
|
|
462
|
-
const dropped = dropOneByPriority(ring);
|
|
463
|
-
if (dropped)
|
|
464
|
-
ring.approxBytes -= byteHint(dropped);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
if (ring.events.length > 0) {
|
|
468
|
-
ring.minSeq = ring.events[0].seq;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
function persist(sessionId, ring) {
|
|
472
|
-
const file = persistedPath(sessionId);
|
|
473
|
-
if (!file)
|
|
474
|
-
return;
|
|
475
|
-
writeSecureJsonSync(file, ringPayload(sessionId, ring));
|
|
348
|
+
});
|
|
476
349
|
}
|
|
477
350
|
function ringFor(sessionId) {
|
|
478
351
|
let ring = rings.get(sessionId);
|
|
@@ -483,10 +356,6 @@ export function createChatEventStore(options) {
|
|
|
483
356
|
nextSeq: 1,
|
|
484
357
|
minSeq: 1,
|
|
485
358
|
maxEvents,
|
|
486
|
-
maxBytes,
|
|
487
|
-
coalesceWindowMs,
|
|
488
|
-
approxBytes: BYTE_GATE_SLACK,
|
|
489
|
-
epoch: eventEpoch,
|
|
490
359
|
};
|
|
491
360
|
rings.set(sessionId, ring);
|
|
492
361
|
}
|
|
@@ -538,7 +407,6 @@ export function createChatEventStore(options) {
|
|
|
538
407
|
createdAt: now,
|
|
539
408
|
};
|
|
540
409
|
ring.turns.push(turn);
|
|
541
|
-
ring.approxBytes += byteHint(turn);
|
|
542
410
|
persist(sessionId, ring);
|
|
543
411
|
return { ok: true, turn };
|
|
544
412
|
},
|
|
@@ -548,7 +416,6 @@ export function createChatEventStore(options) {
|
|
|
548
416
|
if (!turn)
|
|
549
417
|
return undefined;
|
|
550
418
|
turn.state = state;
|
|
551
|
-
const before = JSON.stringify(turn).length;
|
|
552
419
|
if (state === "running" && !turn.startedAt)
|
|
553
420
|
turn.startedAt = new Date().toISOString();
|
|
554
421
|
if (state === "settled" || state === "aborted" || state === "failed") {
|
|
@@ -556,8 +423,6 @@ export function createChatEventStore(options) {
|
|
|
556
423
|
}
|
|
557
424
|
if (error)
|
|
558
425
|
turn.error = error;
|
|
559
|
-
ring.approxBytes +=
|
|
560
|
-
(JSON.stringify(turn).length - before) * BYTE_GATE_SCALE;
|
|
561
426
|
persist(sessionId, ring);
|
|
562
427
|
return { ...turn };
|
|
563
428
|
},
|
|
@@ -578,79 +443,49 @@ export function createChatEventStore(options) {
|
|
|
578
443
|
},
|
|
579
444
|
append(sessionId, turnId, partial) {
|
|
580
445
|
const ring = ringFor(sessionId);
|
|
581
|
-
// D1: every append (coalesced or not) consumes a fresh live seq so the
|
|
582
|
-
// live stream stays fully dense (AC-006); the persisted ring may collapse
|
|
583
|
-
// text updates, but nextSeq is always persisted alongside it so a restart
|
|
584
|
-
// never re-issues a seq a live client already saw.
|
|
585
446
|
const seq = ring.nextSeq++;
|
|
586
|
-
const
|
|
587
|
-
const live = {
|
|
447
|
+
const event = {
|
|
588
448
|
schemaVersion: 1,
|
|
589
|
-
eventId:
|
|
449
|
+
eventId: `${sessionId}:${seq}`,
|
|
590
450
|
sessionId,
|
|
591
451
|
seq,
|
|
592
452
|
turnId,
|
|
593
|
-
at,
|
|
453
|
+
at: partial.at ?? new Date().toISOString(),
|
|
594
454
|
kind: partial.kind,
|
|
595
455
|
data: partial.data,
|
|
596
456
|
};
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
const before = JSON.stringify(tail).length;
|
|
603
|
-
tail.data = live.data;
|
|
604
|
-
tail.at = live.at;
|
|
605
|
-
ring.approxBytes +=
|
|
606
|
-
(JSON.stringify(tail).length - before) * BYTE_GATE_SCALE;
|
|
457
|
+
ring.events.push(event);
|
|
458
|
+
while (ring.events.length > ring.maxEvents) {
|
|
459
|
+
const dropped = ring.events.shift();
|
|
460
|
+
if (dropped)
|
|
461
|
+
ring.minSeq = dropped.seq + 1;
|
|
607
462
|
}
|
|
608
|
-
|
|
609
|
-
ring.events.
|
|
610
|
-
ring.approxBytes += byteHint(live);
|
|
463
|
+
if (ring.events.length > 0) {
|
|
464
|
+
ring.minSeq = ring.events[0].seq;
|
|
611
465
|
}
|
|
612
|
-
enforceBounds(sessionId, ring);
|
|
613
466
|
persist(sessionId, ring);
|
|
614
467
|
const subs = subscribers.get(sessionId);
|
|
615
468
|
if (subs) {
|
|
616
469
|
for (const fn of subs) {
|
|
617
470
|
try {
|
|
618
|
-
fn(
|
|
471
|
+
fn(event);
|
|
619
472
|
}
|
|
620
473
|
catch {
|
|
621
474
|
// a subscriber throwing must never break the append path
|
|
622
475
|
}
|
|
623
476
|
}
|
|
624
477
|
}
|
|
625
|
-
return
|
|
478
|
+
return event;
|
|
626
479
|
},
|
|
627
|
-
listFrom(sessionId,
|
|
480
|
+
listFrom(sessionId, afterSeq) {
|
|
628
481
|
const ring = rings.get(sessionId) ?? loadRing(sessionId);
|
|
629
482
|
if (!ring)
|
|
630
|
-
return { events: []
|
|
483
|
+
return { events: [] };
|
|
631
484
|
rings.set(sessionId, ring);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
return {
|
|
635
|
-
error: "CURSOR_EPOCH_MISMATCH",
|
|
636
|
-
minSeq: ring.minSeq,
|
|
637
|
-
epoch: ring.epoch,
|
|
638
|
-
};
|
|
485
|
+
if (ring.events.length > 0 && afterSeq + 1 < ring.minSeq) {
|
|
486
|
+
return { error: "CURSOR_EXPIRED", minSeq: ring.minSeq };
|
|
639
487
|
}
|
|
640
|
-
|
|
641
|
-
return {
|
|
642
|
-
error: "CURSOR_EXPIRED",
|
|
643
|
-
minSeq: ring.minSeq,
|
|
644
|
-
epoch: ring.epoch,
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
return {
|
|
648
|
-
events: ring.events.filter((event) => event.seq > normalized.seq),
|
|
649
|
-
epoch: ring.epoch,
|
|
650
|
-
};
|
|
651
|
-
},
|
|
652
|
-
epoch(sessionId) {
|
|
653
|
-
return ringFor(sessionId).epoch;
|
|
488
|
+
return { events: ring.events.filter((e) => e.seq > afterSeq) };
|
|
654
489
|
},
|
|
655
490
|
snapshot(sessionId) {
|
|
656
491
|
const ring = rings.get(sessionId) ?? loadRing(sessionId);
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — instruction skills whitelist (design §7.5 / plan D1).
|
|
3
|
+
*
|
|
4
|
+
* Hard rules (plan D1):
|
|
5
|
+
* - skills are READ-ONLY text context injected into the Chat system prompt;
|
|
6
|
+
* - skills are NEVER registered as executable tools;
|
|
7
|
+
* - the whitelist is a COMPILE-TIME constant (no runtime dir scan, no env override);
|
|
8
|
+
* - arbitrary pi extension / package tool / user-level ~/.pi/agent skill is
|
|
9
|
+
* never discovered or loaded (V12);
|
|
10
|
+
* - project `.agents/skills/` (init projection) is NOT injected (prevents
|
|
11
|
+
* target-repo injecting write instructions).
|
|
12
|
+
*
|
|
13
|
+
* Only the loop-agent repo's own audited skills are eligible. Workflow-specific
|
|
14
|
+
* skills are conditional on taskKind/workflow and loaded on demand (not in MVP
|
|
15
|
+
* operator-context default set).
|
|
16
|
+
*/
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
/**
|
|
20
|
+
* Operator-context instruction skills (plan D1, "operator-context 允许").
|
|
21
|
+
* These are read-only methodology skills. Loaded as text context for every
|
|
22
|
+
* Chat session by default.
|
|
23
|
+
*
|
|
24
|
+
* MUST stay a compile-time constant. Adding a skill requires an ADR / plan note.
|
|
25
|
+
*/
|
|
26
|
+
export const OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS = Object.freeze([
|
|
27
|
+
"grill-me",
|
|
28
|
+
"systematic-debugging",
|
|
29
|
+
"code-review-core",
|
|
30
|
+
"codebase-scout",
|
|
31
|
+
"verification-before-completion",
|
|
32
|
+
"requesting-code-review",
|
|
33
|
+
"analyze-product-requirements",
|
|
34
|
+
"analyze-product-dependencies",
|
|
35
|
+
"using-git-worktrees",
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* Meta / governance skills that must NEVER be injected into Chat context
|
|
39
|
+
* (plan D1 "元 / 治理 skill"). Injecting these would let Chat rewrite its own
|
|
40
|
+
* governance. Listed explicitly so accidental allowlist edits are caught.
|
|
41
|
+
*/
|
|
42
|
+
export const OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS = Object.freeze([
|
|
43
|
+
"loop-agent",
|
|
44
|
+
"agent-worker",
|
|
45
|
+
"init-capability-evolution",
|
|
46
|
+
"ai-engineering-context",
|
|
47
|
+
"grill-with-docs",
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Workflow-specific skills (plan D1 "workflow-specific 允许(条件)").
|
|
51
|
+
* Only injected when the corresponding taskKind / workflow is active.
|
|
52
|
+
* NOT loaded by default in MVP Chat.
|
|
53
|
+
*/
|
|
54
|
+
export const OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS = Object.freeze([
|
|
55
|
+
"frontend-implementation",
|
|
56
|
+
"frontend-review",
|
|
57
|
+
"frontend-verification",
|
|
58
|
+
"frontend-design-review",
|
|
59
|
+
"test-driven-development",
|
|
60
|
+
"webapp-testing",
|
|
61
|
+
"playwright-cli",
|
|
62
|
+
"playwright-cli-case-generator",
|
|
63
|
+
"browser-tools",
|
|
64
|
+
]);
|
|
65
|
+
const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.map((s) => s.toLowerCase()));
|
|
66
|
+
const DENIED = new Set(OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS.map((s) => s.toLowerCase()));
|
|
67
|
+
const CONDITIONAL = new Set(OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS.map((s) => s.toLowerCase()));
|
|
68
|
+
export function isOperatorChatInstructionSkillAllowed(name) {
|
|
69
|
+
return ALLOWED.has(name.trim().toLowerCase());
|
|
70
|
+
}
|
|
71
|
+
export function isOperatorChatInstructionSkillDenied(name) {
|
|
72
|
+
return DENIED.has(name.trim().toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
export function isOperatorChatInstructionSkillConditional(name) {
|
|
75
|
+
return CONDITIONAL.has(name.trim().toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
export function classifyInstructionSkill(name, options) {
|
|
78
|
+
const n = name.trim().toLowerCase();
|
|
79
|
+
if (DENIED.has(n)) {
|
|
80
|
+
return { ok: false, reason: "denied-governance", name };
|
|
81
|
+
}
|
|
82
|
+
if (ALLOWED.has(n)) {
|
|
83
|
+
return { ok: true, kind: "operator-context", name };
|
|
84
|
+
}
|
|
85
|
+
if (CONDITIONAL.has(n)) {
|
|
86
|
+
const active = options?.activeWorkflowSkills ?? [];
|
|
87
|
+
const activeLower = new Set(active.map((s) => s.toLowerCase()));
|
|
88
|
+
if (activeLower.has(n)) {
|
|
89
|
+
// Conditional skill is allowed only when explicitly activated by workflow.
|
|
90
|
+
// For MVP, conditional skills are not auto-injected; callers must opt in.
|
|
91
|
+
return { ok: false, reason: "conditional-not-active", name };
|
|
92
|
+
}
|
|
93
|
+
return { ok: false, reason: "conditional-not-active", name };
|
|
94
|
+
}
|
|
95
|
+
return { ok: false, reason: "unknown", name };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse a SKILL.md frontmatter description (first `description:` line) and
|
|
99
|
+
* return the body text (frontmatter stripped). Tolerant of missing frontmatter.
|
|
100
|
+
*/
|
|
101
|
+
export function parseSkillFrontmatter(raw) {
|
|
102
|
+
let description = "";
|
|
103
|
+
let bodyText = raw;
|
|
104
|
+
const hasFrontmatter = raw.trimStart().startsWith("---");
|
|
105
|
+
if (hasFrontmatter) {
|
|
106
|
+
const lines = raw.split(/\r?\n/);
|
|
107
|
+
// find closing ---
|
|
108
|
+
let closeIdx = -1;
|
|
109
|
+
for (let i = 1; i < lines.length; i++) {
|
|
110
|
+
if (lines[i].trim() === "---") {
|
|
111
|
+
closeIdx = i;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (closeIdx > 0) {
|
|
116
|
+
const fm = lines.slice(1, closeIdx).join("\n");
|
|
117
|
+
bodyText = lines.slice(closeIdx + 1).join("\n").trim();
|
|
118
|
+
const descMatch = fm.match(/^description:\s*(.+)$/m);
|
|
119
|
+
if (descMatch) {
|
|
120
|
+
description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
bodyText = raw.trim();
|
|
126
|
+
}
|
|
127
|
+
return { description, bodyText };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Load the operator-context instruction skills from the loop-agent repo's own
|
|
131
|
+
* `skills/` directory and return their text bodies for system-prompt injection.
|
|
132
|
+
*
|
|
133
|
+
* Security: only names in OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS are read;
|
|
134
|
+
* any other path is rejected before filesystem access (safe-side).
|
|
135
|
+
*
|
|
136
|
+
* @param skillsDir absolute path to the loop-agent repo `skills/` directory
|
|
137
|
+
* (source repo / npm pack asset, NOT target project `.agents/skills/`).
|
|
138
|
+
* @param names override list (tests); defaults to the full allowlist.
|
|
139
|
+
*/
|
|
140
|
+
export async function loadOperatorChatInstructionSkills(skillsDir, names) {
|
|
141
|
+
const selected = names ?? OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS;
|
|
142
|
+
const loaded = [];
|
|
143
|
+
const failures = [];
|
|
144
|
+
const skipped = [];
|
|
145
|
+
for (const rawName of selected) {
|
|
146
|
+
const name = rawName.trim();
|
|
147
|
+
if (!isOperatorChatInstructionSkillAllowed(name)) {
|
|
148
|
+
skipped.push(name);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Defense in depth: reject path traversal / governance skills even if
|
|
152
|
+
// an attacker managed to mutate the allowlist at runtime.
|
|
153
|
+
if (isOperatorChatInstructionSkillDenied(name)) {
|
|
154
|
+
skipped.push(name);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const skillPath = path.join(skillsDir, name, "SKILL.md");
|
|
158
|
+
const resolved = path.resolve(skillPath);
|
|
159
|
+
const resolvedSkillsDir = path.resolve(skillsDir);
|
|
160
|
+
if (!resolved.startsWith(resolvedSkillsDir + path.sep)) {
|
|
161
|
+
failures.push({
|
|
162
|
+
name,
|
|
163
|
+
message: "path escapes skills directory",
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const raw = await readFile(resolved, "utf8");
|
|
169
|
+
const { description, bodyText } = parseSkillFrontmatter(raw);
|
|
170
|
+
loaded.push({
|
|
171
|
+
name,
|
|
172
|
+
description,
|
|
173
|
+
bodyText,
|
|
174
|
+
charCount: bodyText.length,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
failures.push({
|
|
179
|
+
name,
|
|
180
|
+
message: error instanceof Error ? error.message : String(error),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { loaded, failures, skipped };
|
|
185
|
+
}
|
|
186
|
+
/** Hard cap on total injected instruction-skill chars (token budget guard). */
|
|
187
|
+
export const OPERATOR_CHAT_INSTRUCTION_CHAR_BUDGET = 24_000;
|
|
188
|
+
/**
|
|
189
|
+
* Compose the system-prompt fragment listing loaded instruction skills.
|
|
190
|
+
* Each skill is rendered as a bounded text block. If total chars exceed the
|
|
191
|
+
* budget, later skills are truncated (operator-context set is small enough
|
|
192
|
+
* that truncation should not trigger in practice, but the cap is enforced).
|
|
193
|
+
*/
|
|
194
|
+
export function composeInstructionSkillsPrompt(skills, budget = OPERATOR_CHAT_INSTRUCTION_CHAR_BUDGET) {
|
|
195
|
+
if (skills.length === 0)
|
|
196
|
+
return "";
|
|
197
|
+
const lines = [
|
|
198
|
+
"## Operator methodology context (read-only instruction skills)",
|
|
199
|
+
"",
|
|
200
|
+
"These skills are READ-ONLY context. You may reference their methodology when reasoning, but you CANNOT execute them. All actions must go through the registered operator tools.",
|
|
201
|
+
"",
|
|
202
|
+
];
|
|
203
|
+
let used = 0;
|
|
204
|
+
for (const skill of skills) {
|
|
205
|
+
const header = `\n### ${skill.name}${skill.description ? ` — ${skill.description}` : ""}\n`;
|
|
206
|
+
const remaining = budget - used - header.length;
|
|
207
|
+
if (remaining <= 0) {
|
|
208
|
+
lines.push(`_[${skill.name}: omitted, char budget exhausted]_`);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
const slice = skill.bodyText.slice(0, Math.max(0, remaining));
|
|
212
|
+
lines.push(header);
|
|
213
|
+
lines.push(slice);
|
|
214
|
+
used += header.length + slice.length;
|
|
215
|
+
}
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|