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) |
|
| Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) |
|
||||||
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
|
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
|
||||||
| Статус | Спецификация готова; реализация — по фазам в `TODO.md` |
|
| Статус | Спецификация готова; **фаза 0** завершена; реализация — фазы 1+ в `TODO.md` |
|
||||||
| Целевой API | Публичный `ExtensionAPI` Pi (`extensions.md`), без приватных internal imports |
|
| Целевой API | Публичный `ExtensionAPI` Pi (`extensions.md`), без приватных internal imports |
|
||||||
| Совместимость | [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact) — режим `defer` по умолчанию (см. SPEC §16) |
|
| Совместимость | [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact) — режим `defer` по умолчанию (см. SPEC §16) |
|
||||||
|
|
||||||
|
|||||||
@@ -112,30 +112,30 @@
|
|||||||
|
|
||||||
### Фаза 0 — Каркас репозитория
|
### Фаза 0 — Каркас репозитория
|
||||||
|
|
||||||
- [ ] **package.json** — manifest с `pi.extensions`, devDependencies (`@earendil-works/pi-coding-agent`, `typescript`); зачем: загрузка extension через `pi -e` (SPEC §9.1, §10)
|
- [x] **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
|
- [x] **tsconfig.json** — strict TS, module resolution под Pi extension runtime; зачем: `tsc --noEmit` в цикле AGENTS
|
||||||
- [ ] **npm scripts** — `typecheck`, `test`, `build` (минимально); зачем: единая проверка в каждом пункте
|
- [x] **npm scripts** — `typecheck`, `test`, `build` (минимально); зачем: единая проверка в каждом пункте
|
||||||
- [ ] **src/index.ts shell** — `export default function(pi: ExtensionAPI)`, пустой `session_start`; зачем: smoke `pi -e ./src/index.ts` без логики
|
- [x] **src/index.ts shell** — `export default function(pi: ExtensionAPI)`, пустой `session_start`; зачем: smoke `pi -e ./src/index.ts` без логики
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Фаза 1 — Состояние и персистенция
|
### Фаза 1 — Состояние и персистенция
|
||||||
|
|
||||||
- [ ] **state.ts types** — `TrackedSkill`, `ExtensionState` (version 1), `RuntimeFlags`; зачем: единый контракт §6.1
|
- [x] **state.ts types** — `TrackedSkill`, `ExtensionState` (version 1), `RuntimeFlags`; зачем: единый контракт §6.1
|
||||||
- [ ] **state.ts initial** — `createInitialState()`, `createRuntimeFlags()`; зачем: предсказуемый старт сессии
|
- [x] **state.ts initial** — `createInitialState()`, `createRuntimeFlags()`; зачем: предсказуемый старт сессии
|
||||||
- [ ] **state.ts persist** — `saveState(pi, state)` через `appendEntry("skill-reinject:state", …)`; зачем: пережить `/resume` (§6.1)
|
- [x] **state.ts persist** — `saveState(pi, state)` через `appendEntry("skill-reinject:state", …)`; зачем: пережить `/resume` (§6.1)
|
||||||
- [ ] **state.ts load** — `loadStateFromBranch(branch)` из последнего custom entry; зачем: восстановление без полного rescan
|
- [x] **state.ts load** — `loadStateFromBranch(branch)` из последнего custom entry; зачем: восстановление без полного rescan
|
||||||
- [ ] **state.ts trackSkill** — upsert по `name`, merge `sources`, preserve insertion order; зачем: дедуп §6.1
|
- [x] **state.ts trackSkill** — upsert по `name`, merge `sources`, preserve insertion order; зачем: дедуп §6.1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Фаза 2 — Настройки
|
### Фаза 2 — Настройки
|
||||||
|
|
||||||
- [ ] **settings.ts types** — `SkillReinjectSettings` + defaults из §7.3 (`enabled`, `trackReadPaths`, `triggerTurn`, `reinjectOnManualCompaction`, `autoCompactIntegration`, `suffix`)
|
- [x] **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
|
- [x] **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`
|
- [x] **settings.ts writeGlobal** — merge в `~/.pi/agent/settings.json` без затирания чужих ключей; зачем: `/skill-reinject global on`
|
||||||
- [ ] **settings.ts effective** — `effectiveEnabled(sessionOverride, global)`, `effectiveIntegration(...)`; зачем: три слоя §5.1
|
- [x] **settings.ts effective** — `effectiveEnabled(sessionOverride, global)`, `effectiveIntegration(...)`; зачем: три слоя §5.1
|
||||||
- [ ] **test/settings.test.ts** — defaults, merge write (mock/temp file); зачем: §12.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