Compare commits
17 Commits
fb028336ec
...
884fee99a5
| Author | SHA1 | Date | |
|---|---|---|---|
| 884fee99a5 | |||
| 36cafc4405 | |||
| 00ffdff578 | |||
| 9b0264f687 | |||
| d8abd46f90 | |||
| 04dcab5b42 | |||
| 6050ca98ea | |||
| a671a6dc35 | |||
| edcf5352c4 | |||
| db04a1dd01 | |||
| 195ed026ff | |||
| 57d96e8b46 | |||
| 645a46c0e9 | |||
| 2ddb9431b5 | |||
| 3ed38d7e1e | |||
| 311c329f87 | |||
| 2e3f909527 |
@@ -10,7 +10,7 @@
|
||||
|---|---|
|
||||
| Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) |
|
||||
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
|
||||
| Статус | Спецификация готова; реализация — по фазам в `TODO.md` |
|
||||
| Статус | Спецификация готова; **фаза 0** завершена; реализация — фазы 1+ в `TODO.md` |
|
||||
| Целевой API | Публичный `ExtensionAPI` Pi (`extensions.md`), без приватных internal imports |
|
||||
| Совместимость | [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact) — режим `defer` по умолчанию (см. SPEC §16) |
|
||||
|
||||
|
||||
@@ -112,30 +112,30 @@
|
||||
|
||||
### Фаза 0 — Каркас репозитория
|
||||
|
||||
- [ ] **package.json** — manifest с `pi.extensions`, devDependencies (`@earendil-works/pi-coding-agent`, `typescript`); зачем: загрузка extension через `pi -e` (SPEC §9.1, §10)
|
||||
- [ ] **tsconfig.json** — strict TS, module resolution под Pi extension runtime; зачем: `tsc --noEmit` в цикле AGENTS
|
||||
- [ ] **npm scripts** — `typecheck`, `test`, `build` (минимально); зачем: единая проверка в каждом пункте
|
||||
- [ ] **src/index.ts shell** — `export default function(pi: ExtensionAPI)`, пустой `session_start`; зачем: smoke `pi -e ./src/index.ts` без логики
|
||||
- [x] **package.json** — manifest с `pi.extensions`, devDependencies (`@earendil-works/pi-coding-agent`, `typescript`); зачем: загрузка extension через `pi -e` (SPEC §9.1, §10)
|
||||
- [x] **tsconfig.json** — strict TS, module resolution под Pi extension runtime; зачем: `tsc --noEmit` в цикле AGENTS
|
||||
- [x] **npm scripts** — `typecheck`, `test`, `build` (минимально); зачем: единая проверка в каждом пункте
|
||||
- [x] **src/index.ts shell** — `export default function(pi: ExtensionAPI)`, пустой `session_start`; зачем: smoke `pi -e ./src/index.ts` без логики
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1 — Состояние и персистенция
|
||||
|
||||
- [ ] **state.ts types** — `TrackedSkill`, `ExtensionState` (version 1), `RuntimeFlags`; зачем: единый контракт §6.1
|
||||
- [ ] **state.ts initial** — `createInitialState()`, `createRuntimeFlags()`; зачем: предсказуемый старт сессии
|
||||
- [ ] **state.ts persist** — `saveState(pi, state)` через `appendEntry("skill-reinject:state", …)`; зачем: пережить `/resume` (§6.1)
|
||||
- [ ] **state.ts load** — `loadStateFromBranch(branch)` из последнего custom entry; зачем: восстановление без полного rescan
|
||||
- [ ] **state.ts trackSkill** — upsert по `name`, merge `sources`, preserve insertion order; зачем: дедуп §6.1
|
||||
- [x] **state.ts types** — `TrackedSkill`, `ExtensionState` (version 1), `RuntimeFlags`; зачем: единый контракт §6.1
|
||||
- [x] **state.ts initial** — `createInitialState()`, `createRuntimeFlags()`; зачем: предсказуемый старт сессии
|
||||
- [x] **state.ts persist** — `saveState(pi, state)` через `appendEntry("skill-reinject:state", …)`; зачем: пережить `/resume` (§6.1)
|
||||
- [x] **state.ts load** — `loadStateFromBranch(branch)` из последнего custom entry; зачем: восстановление без полного rescan
|
||||
- [x] **state.ts trackSkill** — upsert по `name`, merge `sources`, preserve insertion order; зачем: дедуп §6.1
|
||||
|
||||
---
|
||||
|
||||
### Фаза 2 — Настройки
|
||||
|
||||
- [ ] **settings.ts types** — `SkillReinjectSettings` + defaults из §7.3 (`enabled`, `trackReadPaths`, `triggerTurn`, `reinjectOnManualCompaction`, `autoCompactIntegration`, `suffix`)
|
||||
- [ ] **settings.ts read** — merge global + project из `ctx` / Pi settings API; зачем: не читать файл вручную, если API даёт merged view
|
||||
- [ ] **settings.ts writeGlobal** — merge в `~/.pi/agent/settings.json` без затирания чужих ключей; зачем: `/skill-reinject global on`
|
||||
- [ ] **settings.ts effective** — `effectiveEnabled(sessionOverride, global)`, `effectiveIntegration(...)`; зачем: три слоя §5.1
|
||||
- [ ] **test/settings.test.ts** — defaults, merge write (mock/temp file); зачем: §12.1
|
||||
- [x] **settings.ts types** — `SkillReinjectSettings` + defaults из §7.3 (`enabled`, `trackReadPaths`, `triggerTurn`, `reinjectOnManualCompaction`, `autoCompactIntegration`, `suffix`)
|
||||
- [x] **settings.ts read** — merge global + project из `ctx` / Pi settings API; зачем: не читать файл вручную, если API даёт merged view
|
||||
- [x] **settings.ts writeGlobal** — merge в `~/.pi/agent/settings.json` без затирания чужих ключей; зачем: `/skill-reinject global on`
|
||||
- [x] **settings.ts effective** — `effectiveEnabled(sessionOverride, global)`, `effectiveIntegration(...)`; зачем: три слоя §5.1
|
||||
- [x] **test/settings.test.ts** — defaults, merge write (mock/temp file); зачем: §12.1
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+3538
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "pi-skill-reinject",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Pi Coding Agent extension: re-inject skills after auto compaction",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"build": "npm run typecheck"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.79.6",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export default function skillReinject(pi: ExtensionAPI): void {
|
||||
pi.on("session_start", () => {
|
||||
// Phase 0 shell — hooks wired in later phases
|
||||
});
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import type { AutoCompactIntegration } from "./state";
|
||||
|
||||
/** JSON key in global/project settings.json (SPEC §7.3). */
|
||||
export const SKILL_REINJECT_SETTINGS_KEY = "skillReinject";
|
||||
|
||||
export type PartialSkillReinjectSettings = Partial<SkillReinjectSettings>;
|
||||
|
||||
const AUTO_COMPACT_INTEGRATION_VALUES: readonly AutoCompactIntegration[] = [
|
||||
"auto",
|
||||
"defer",
|
||||
"immediate",
|
||||
"off",
|
||||
];
|
||||
|
||||
/** Global/project skillReinject.* settings (SPEC §7.3). */
|
||||
export interface SkillReinjectSettings {
|
||||
enabled: boolean;
|
||||
trackReadPaths: boolean;
|
||||
triggerTurn: boolean;
|
||||
reinjectOnManualCompaction: boolean;
|
||||
autoCompactIntegration: AutoCompactIntegration;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
/** Defaults from SPEC §7.3 — extension off until explicitly enabled. */
|
||||
export const DEFAULT_SKILL_REINJECT_SETTINGS: Readonly<SkillReinjectSettings> = {
|
||||
enabled: false,
|
||||
trackReadPaths: true,
|
||||
triggerTurn: false,
|
||||
reinjectOnManualCompaction: false,
|
||||
autoCompactIntegration: "auto",
|
||||
suffix: "[skill-reinject] Re-applied after compaction.",
|
||||
};
|
||||
|
||||
export function createDefaultSettings(): SkillReinjectSettings {
|
||||
return { ...DEFAULT_SKILL_REINJECT_SETTINGS };
|
||||
}
|
||||
|
||||
function isAutoCompactIntegration(value: unknown): value is AutoCompactIntegration {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(AUTO_COMPACT_INTEGRATION_VALUES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse unknown JSON; invalid fields are ignored. */
|
||||
export function parseSkillReinjectPartial(raw: unknown): PartialSkillReinjectSettings {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const result: PartialSkillReinjectSettings = {};
|
||||
if (typeof obj.enabled === "boolean") {
|
||||
result.enabled = obj.enabled;
|
||||
}
|
||||
if (typeof obj.trackReadPaths === "boolean") {
|
||||
result.trackReadPaths = obj.trackReadPaths;
|
||||
}
|
||||
if (typeof obj.triggerTurn === "boolean") {
|
||||
result.triggerTurn = obj.triggerTurn;
|
||||
}
|
||||
if (typeof obj.reinjectOnManualCompaction === "boolean") {
|
||||
result.reinjectOnManualCompaction = obj.reinjectOnManualCompaction;
|
||||
}
|
||||
if (isAutoCompactIntegration(obj.autoCompactIntegration)) {
|
||||
result.autoCompactIntegration = obj.autoCompactIntegration;
|
||||
}
|
||||
if (typeof obj.suffix === "string") {
|
||||
result.suffix = obj.suffix;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Project overrides global; both layers merge onto defaults (SPEC §7.3). */
|
||||
export function mergeSkillReinjectSettings(
|
||||
global: PartialSkillReinjectSettings,
|
||||
project: PartialSkillReinjectSettings,
|
||||
): SkillReinjectSettings {
|
||||
return {
|
||||
...createDefaultSettings(),
|
||||
...global,
|
||||
...project,
|
||||
};
|
||||
}
|
||||
|
||||
function extractSkillReinject(settings: object): PartialSkillReinjectSettings {
|
||||
return parseSkillReinjectPartial(
|
||||
(settings as Record<string, unknown>)[SKILL_REINJECT_SETTINGS_KEY],
|
||||
);
|
||||
}
|
||||
|
||||
/** Merged global + project settings via Pi SettingsManager (SPEC §7.3). */
|
||||
export function readSettings(ctx: ExtensionContext): SkillReinjectSettings {
|
||||
const manager = SettingsManager.create(ctx.cwd, getAgentDir(), {
|
||||
projectTrusted: ctx.isProjectTrusted(),
|
||||
});
|
||||
return mergeSkillReinjectSettings(
|
||||
extractSkillReinject(manager.getGlobalSettings()),
|
||||
extractSkillReinject(manager.getProjectSettings()),
|
||||
);
|
||||
}
|
||||
|
||||
function readSettingsFile(settingsPath: string): Record<string, unknown> {
|
||||
if (!existsSync(settingsPath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge skillReinject into a settings.json file without replacing unrelated keys (SPEC §7.3). */
|
||||
export function mergeSkillReinjectIntoSettingsFile(
|
||||
settingsPath: string,
|
||||
partial: PartialSkillReinjectSettings,
|
||||
): void {
|
||||
const current = readSettingsFile(settingsPath);
|
||||
const existing = parseSkillReinjectPartial(current[SKILL_REINJECT_SETTINGS_KEY]);
|
||||
current[SKILL_REINJECT_SETTINGS_KEY] = { ...existing, ...partial };
|
||||
mkdirSync(dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, `${JSON.stringify(current, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/** Persist partial skillReinject settings to global ~/.pi/agent/settings.json (SPEC §7.3). */
|
||||
export function writeGlobalSettings(partial: PartialSkillReinjectSettings): void {
|
||||
mergeSkillReinjectIntoSettingsFile(join(getAgentDir(), "settings.json"), partial);
|
||||
}
|
||||
|
||||
/** Resolved re-inject delivery mode after integration settings (SPEC §6.5.3). */
|
||||
export type ReinjectDeliveryMode = "defer" | "immediate";
|
||||
|
||||
/** Session override wins over global enabled default (SPEC §5.1). */
|
||||
export function effectiveEnabled(
|
||||
sessionOverride: boolean | null,
|
||||
settings: SkillReinjectSettings,
|
||||
): boolean {
|
||||
return sessionOverride ?? settings.enabled;
|
||||
}
|
||||
|
||||
/** Resolve delivery mode from integration setting, pi-auto-compact detect, and triggerTurn (SPEC §6.5.3). */
|
||||
export function effectiveIntegration(
|
||||
settings: SkillReinjectSettings,
|
||||
autoCompactDetected: boolean,
|
||||
sessionIntegrationOverride?: AutoCompactIntegration | null,
|
||||
): ReinjectDeliveryMode {
|
||||
const integration = sessionIntegrationOverride ?? settings.autoCompactIntegration;
|
||||
switch (integration) {
|
||||
case "defer":
|
||||
return "defer";
|
||||
case "immediate":
|
||||
return "immediate";
|
||||
case "off":
|
||||
return settings.triggerTurn ? "immediate" : "defer";
|
||||
case "auto":
|
||||
default:
|
||||
if (autoCompactDetected) {
|
||||
return "defer";
|
||||
}
|
||||
return settings.triggerTurn ? "immediate" : "defer";
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** How a skill was first observed in the session (SPEC §6.2). */
|
||||
export type SkillSource = "slash" | "skill-block" | "read";
|
||||
|
||||
/** Compaction that triggered or skipped re-inject (SPEC §8). */
|
||||
export type CompactionSource = "auto" | "manual";
|
||||
|
||||
/** pi-auto-compact delivery mode (SPEC §6.5, §16). */
|
||||
export type AutoCompactIntegration = "auto" | "defer" | "immediate" | "off";
|
||||
|
||||
export interface TrackedSkill {
|
||||
name: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
sources: SkillSource[];
|
||||
}
|
||||
|
||||
export interface ExtensionState {
|
||||
version: 1;
|
||||
sessionOverride: boolean | null;
|
||||
skills: TrackedSkill[];
|
||||
lastCompactionSource: CompactionSource | null;
|
||||
/** Skill names awaiting re-inject on the next before_agent_start (SPEC §6.5). */
|
||||
pendingReinject: string[];
|
||||
}
|
||||
|
||||
/** Runtime-only; not persisted via appendEntry (SPEC §6.1). */
|
||||
export interface RuntimeFlags {
|
||||
autoCompactDetected: boolean;
|
||||
autoCompactIntegration: AutoCompactIntegration;
|
||||
}
|
||||
|
||||
export const STATE_ENTRY_TYPE = "skill-reinject:state";
|
||||
|
||||
export function saveState(pi: ExtensionAPI, state: ExtensionState): void {
|
||||
pi.appendEntry<ExtensionState>(STATE_ENTRY_TYPE, state);
|
||||
}
|
||||
|
||||
function isExtensionState(data: unknown): data is ExtensionState {
|
||||
if (!data || typeof data !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = data as ExtensionState;
|
||||
return (
|
||||
candidate.version === 1 &&
|
||||
(candidate.sessionOverride === null || typeof candidate.sessionOverride === "boolean") &&
|
||||
Array.isArray(candidate.skills) &&
|
||||
(candidate.lastCompactionSource === null ||
|
||||
candidate.lastCompactionSource === "auto" ||
|
||||
candidate.lastCompactionSource === "manual") &&
|
||||
Array.isArray(candidate.pendingReinject)
|
||||
);
|
||||
}
|
||||
|
||||
/** Latest persisted state on the branch, or null if none (SPEC §6.3). */
|
||||
export function loadStateFromBranch(branch: SessionEntry[]): ExtensionState | null {
|
||||
for (let i = branch.length - 1; i >= 0; i--) {
|
||||
const entry = branch[i];
|
||||
if (entry.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE || entry.data === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!isExtensionState(entry.data)) {
|
||||
continue;
|
||||
}
|
||||
return structuredClone(entry.data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createInitialState(): ExtensionState {
|
||||
return {
|
||||
version: 1,
|
||||
sessionOverride: null,
|
||||
skills: [],
|
||||
lastCompactionSource: null,
|
||||
pendingReinject: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeFlags(): RuntimeFlags {
|
||||
return {
|
||||
autoCompactDetected: false,
|
||||
autoCompactIntegration: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
export interface TrackSkillInput {
|
||||
name: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
source: SkillSource;
|
||||
seenAt?: number;
|
||||
}
|
||||
|
||||
/** Upsert by name, merge sources, preserve insertion order (SPEC §6.1). */
|
||||
export function trackSkill(state: ExtensionState, input: TrackSkillInput): void {
|
||||
const seenAt = input.seenAt ?? Date.now();
|
||||
const existing = state.skills.find((skill) => skill.name === input.name);
|
||||
if (existing) {
|
||||
existing.filePath = input.filePath;
|
||||
existing.baseDir = input.baseDir;
|
||||
existing.lastSeenAt = seenAt;
|
||||
if (!existing.sources.includes(input.source)) {
|
||||
existing.sources.push(input.source);
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.skills.push({
|
||||
name: input.name,
|
||||
filePath: input.filePath,
|
||||
baseDir: input.baseDir,
|
||||
firstSeenAt: seenAt,
|
||||
lastSeenAt: seenAt,
|
||||
sources: [input.source],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SKILL_REINJECT_SETTINGS,
|
||||
SKILL_REINJECT_SETTINGS_KEY,
|
||||
createDefaultSettings,
|
||||
effectiveEnabled,
|
||||
effectiveIntegration,
|
||||
mergeSkillReinjectIntoSettingsFile,
|
||||
mergeSkillReinjectSettings,
|
||||
parseSkillReinjectPartial,
|
||||
} from "../src/settings";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function tempSettingsPath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-skill-reinject-settings-"));
|
||||
tempDirs.push(dir);
|
||||
return join(dir, "settings.json");
|
||||
}
|
||||
|
||||
describe("createDefaultSettings", () => {
|
||||
it("matches SPEC §7.3 defaults", () => {
|
||||
expect(createDefaultSettings()).toEqual(DEFAULT_SKILL_REINJECT_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSkillReinjectPartial", () => {
|
||||
it("ignores invalid shapes and fields", () => {
|
||||
expect(parseSkillReinjectPartial(null)).toEqual({});
|
||||
expect(parseSkillReinjectPartial([])).toEqual({});
|
||||
expect(
|
||||
parseSkillReinjectPartial({
|
||||
enabled: "yes",
|
||||
autoCompactIntegration: "bogus",
|
||||
extra: true,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps valid fields only", () => {
|
||||
expect(
|
||||
parseSkillReinjectPartial({
|
||||
enabled: true,
|
||||
suffix: "custom",
|
||||
}),
|
||||
).toEqual({ enabled: true, suffix: "custom" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeSkillReinjectSettings", () => {
|
||||
it("applies global then project overrides on defaults", () => {
|
||||
expect(
|
||||
mergeSkillReinjectSettings({ enabled: true }, { trackReadPaths: false }),
|
||||
).toEqual({
|
||||
...DEFAULT_SKILL_REINJECT_SETTINGS,
|
||||
enabled: true,
|
||||
trackReadPaths: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lets project win over global for the same field", () => {
|
||||
expect(
|
||||
mergeSkillReinjectSettings({ enabled: true }, { enabled: false }),
|
||||
).toEqual({
|
||||
...DEFAULT_SKILL_REINJECT_SETTINGS,
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeSkillReinjectIntoSettingsFile", () => {
|
||||
it("merges skillReinject fields across successive writes", () => {
|
||||
const path = tempSettingsPath();
|
||||
mergeSkillReinjectIntoSettingsFile(path, { enabled: true });
|
||||
mergeSkillReinjectIntoSettingsFile(path, { triggerTurn: true });
|
||||
|
||||
const saved = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
expect(saved[SKILL_REINJECT_SETTINGS_KEY]).toEqual({
|
||||
enabled: true,
|
||||
triggerTurn: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing top-level keys", () => {
|
||||
const path = tempSettingsPath();
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify({ theme: "light", compaction: { enabled: false } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
mergeSkillReinjectIntoSettingsFile(path, { enabled: true, suffix: "x" });
|
||||
|
||||
const saved = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
expect(saved.theme).toBe("light");
|
||||
expect(saved.compaction).toEqual({ enabled: false });
|
||||
expect(saved[SKILL_REINJECT_SETTINGS_KEY]).toEqual({
|
||||
enabled: true,
|
||||
suffix: "x",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveEnabled", () => {
|
||||
const settings = createDefaultSettings();
|
||||
|
||||
it("uses session override when set", () => {
|
||||
expect(effectiveEnabled(true, settings)).toBe(true);
|
||||
expect(effectiveEnabled(false, { ...settings, enabled: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to global enabled when override is null", () => {
|
||||
expect(effectiveEnabled(null, settings)).toBe(false);
|
||||
expect(effectiveEnabled(null, { ...settings, enabled: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveIntegration", () => {
|
||||
const base = createDefaultSettings();
|
||||
|
||||
it("honors explicit defer and immediate", () => {
|
||||
expect(
|
||||
effectiveIntegration({ ...base, autoCompactIntegration: "defer" }, false),
|
||||
).toBe("defer");
|
||||
expect(
|
||||
effectiveIntegration({ ...base, autoCompactIntegration: "immediate" }, true),
|
||||
).toBe("immediate");
|
||||
});
|
||||
|
||||
it("uses triggerTurn when integration is off", () => {
|
||||
expect(
|
||||
effectiveIntegration({ ...base, autoCompactIntegration: "off", triggerTurn: false }, true),
|
||||
).toBe("defer");
|
||||
expect(
|
||||
effectiveIntegration({ ...base, autoCompactIntegration: "off", triggerTurn: true }, true),
|
||||
).toBe("immediate");
|
||||
});
|
||||
|
||||
it("defers when auto mode detects pi-auto-compact", () => {
|
||||
expect(effectiveIntegration(base, true)).toBe("defer");
|
||||
});
|
||||
|
||||
it("uses triggerTurn in auto mode without pi-auto-compact", () => {
|
||||
expect(effectiveIntegration({ ...base, triggerTurn: false }, false)).toBe("defer");
|
||||
expect(effectiveIntegration({ ...base, triggerTurn: true }, false)).toBe("immediate");
|
||||
});
|
||||
|
||||
it("prefers session integration override", () => {
|
||||
expect(
|
||||
effectiveIntegration({ ...base, autoCompactIntegration: "immediate" }, false, "defer"),
|
||||
).toBe("defer");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user