@kal-elsam/kairo-runtime 0.4.2 → 0.5.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/README.md +124 -17
- package/global-template/components/catalog.json +63 -6
- package/global-template/components/sdd-core/personas/teaching.md +42 -0
- package/global-template/components/sdd-core/skills/sdd-apply/SKILL.md +34 -0
- package/global-template/components/sdd-core/skills/sdd-apply/references/contract.md +4 -0
- package/global-template/components/sdd-core/skills/sdd-archive/SKILL.md +34 -0
- package/global-template/components/sdd-core/skills/sdd-archive/references/contract.md +4 -0
- package/global-template/components/sdd-core/skills/sdd-design/SKILL.md +34 -0
- package/global-template/components/sdd-core/skills/sdd-design/references/contract.md +4 -0
- package/global-template/components/sdd-core/skills/sdd-explore/SKILL.md +34 -0
- package/global-template/components/sdd-core/skills/sdd-explore/references/contract.md +4 -0
- package/global-template/components/sdd-core/skills/sdd-init/SKILL.md +34 -0
- package/global-template/components/sdd-core/skills/sdd-init/references/contract.md +4 -0
- package/global-template/components/sdd-core/skills/sdd-propose/SKILL.md +35 -0
- package/global-template/components/sdd-core/skills/sdd-propose/references/contract.md +4 -0
- package/global-template/components/sdd-core/skills/sdd-spec/SKILL.md +34 -0
- package/global-template/components/sdd-core/skills/sdd-spec/references/contract.md +4 -0
- package/global-template/components/sdd-core/skills/sdd-tasks/SKILL.md +34 -0
- package/global-template/components/sdd-core/skills/sdd-tasks/references/contract.md +4 -0
- package/global-template/components/sdd-core/skills/sdd-verify/SKILL.md +34 -0
- package/global-template/components/sdd-core/skills/sdd-verify/references/contract.md +4 -0
- package/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +29 -8
- package/scripts/smoke-test.sh +9 -0
- package/src/cli.js +54 -5
- package/src/global/action-planner.js +17 -4
- package/src/global/component-authoring.js +1 -2
- package/src/global/component-ecosystem-checks.js +74 -15
- package/src/global/component-health.js +56 -0
- package/src/global/component-integration-cli.js +302 -0
- package/src/global/component-manifest.js +214 -0
- package/src/global/component-registry.js +19 -2
- package/src/global/component-resolve.js +51 -0
- package/src/global/components/sdd-core.js +6 -0
- package/src/global/control-plane-proposals.js +229 -0
- package/src/global/control-plane-snapshot.js +212 -0
- package/src/global/dashboard-guidance.js +27 -3
- package/src/global/diff.js +2 -5
- package/src/global/global-cli.js +16 -1
- package/src/global/global-doctor.js +75 -2
- package/src/global/global-installer.js +43 -6
- package/src/global/governance-actions.js +212 -0
- package/src/global/governance-repair.js +13 -0
- package/src/global/ink/cockpit-changes.js +124 -0
- package/src/global/ink/cockpit-control-center.js +106 -0
- package/src/global/ink/cockpit-controller.js +17 -0
- package/src/global/ink/cockpit-enter.js +27 -0
- package/src/global/ink/cockpit-focus.js +7 -1
- package/src/global/ink/cockpit-models.js +98 -46
- package/src/global/ink/cockpit-proposals.js +82 -0
- package/src/global/ink/cockpit-recovery.js +120 -0
- package/src/global/ink/cockpit-runs.js +47 -0
- package/src/global/ink/cockpit-scan.js +60 -0
- package/src/global/ink/cockpit-views.js +144 -74
- package/src/global/ink/orchestrator-app.js +156 -42
- package/src/global/ink/orchestrator-state.js +22 -1
- package/src/global/ink/use-orchestrator-data.js +239 -17
- package/src/global/integrations/engram-apply.js +105 -0
- package/src/global/integrations/engram-evidence.js +218 -0
- package/src/global/integrations/engram-exec.js +107 -0
- package/src/global/integrations/engram-plan.js +60 -0
- package/src/global/integrations/engram-provider.js +22 -0
- package/src/global/integrations/engram-receipts.js +116 -0
- package/src/global/integrations/engram-rollback.js +131 -0
- package/src/global/integrations/index.js +12 -0
- package/src/global/integrations/provider-contract.js +83 -0
- package/src/global/integrations/provider-registry.js +47 -0
- package/src/global/integrations/sdd-apply.js +190 -0
- package/src/global/integrations/sdd-destinations.js +95 -0
- package/src/global/integrations/sdd-evidence.js +89 -0
- package/src/global/integrations/sdd-fs-guard.js +183 -0
- package/src/global/integrations/sdd-lifecycle.js +84 -0
- package/src/global/integrations/sdd-persona.js +71 -0
- package/src/global/integrations/sdd-plan.js +87 -0
- package/src/global/integrations/sdd-provider.js +21 -0
- package/src/global/integrations/sdd-receipts.js +44 -0
- package/src/global/integrations/sdd-rollback.js +269 -0
- package/src/global/integrations/sdd-skill-files.js +61 -0
- package/src/global/integrations/sdd-state.js +175 -0
- package/src/global/integrations/sdd-verify.js +122 -0
- package/src/global/intelligence/backends/opencode-catalog.js +212 -0
- package/src/global/intelligence/backends/opencode-cli.js +247 -0
- package/src/global/intelligence/backends/opencode-evidence.js +129 -0
- package/src/global/intelligence/backends/opencode-http.js +152 -0
- package/src/global/intelligence/backends/opencode-invoke.js +83 -0
- package/src/global/intelligence/backends/opencode-providers.js +67 -0
- package/src/global/intelligence/backends/opencode-runtime.js +242 -0
- package/src/global/intelligence/context-compiler.js +95 -50
- package/src/global/intelligence/index.js +38 -1
- package/src/global/intelligence/orchestrate.js +4 -2
- package/src/global/intelligence/registry.js +62 -6
- package/src/global/intelligence/router.js +188 -70
- package/src/global/intelligence/transport-registry.js +147 -0
- package/src/global/intelligence/types.js +21 -2
- package/src/global/intelligence-cli.js +48 -17
- package/src/global/intelligence-session.js +37 -0
- package/src/global/json-output.js +1 -0
- package/src/global/load-component-catalog.js +10 -28
- package/src/global/load-workspace-component-catalog.js +22 -63
- package/src/global/state-migration.js +21 -1
- package/src/global/state.js +13 -8
- package/src/global/status.js +4 -17
- package/src/global/sync.js +2 -5
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { SDD_MANAGED_AGENT_IDS, SDD_PERSONA_IDS } from "./sdd-destinations.js";
|
|
2
|
+
import { SDD_FILE_OUTCOMES, SDD_PLAN_ACTIONS } from "./sdd-evidence.js";
|
|
3
|
+
|
|
4
|
+
export const SDD_PERSONA_GATE = "sdd.personaAgentIds";
|
|
5
|
+
export const SDD_PERSONA_HEALTH = Object.freeze({
|
|
6
|
+
OFF: "off", CONFIGURED: "configured", SYNC_REQUIRED: "sync_required", CONFLICT: "conflict"
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function normalizePersonaAgentIds(ids = []) {
|
|
10
|
+
const managed = new Set(SDD_MANAGED_AGENT_IDS);
|
|
11
|
+
return [...new Set((ids ?? []).filter((id) => managed.has(id)))].sort();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function derivePersona(ids) {
|
|
15
|
+
return normalizePersonaAgentIds(ids).length ? "teaching" : "off";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function samePersonaAgentIds(a, b) {
|
|
19
|
+
const left = normalizePersonaAgentIds(a), right = normalizePersonaAgentIds(b);
|
|
20
|
+
return left.length === right.length && left.every((id, i) => id === right[i]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function result(before, after, admitted, rejected) {
|
|
24
|
+
return { before, after, admitted, rejected, persona: derivePersona(after), personaChanged: !samePersonaAgentIds(before, after) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function planPersonaTransition({
|
|
28
|
+
requestedPersona = "off", selectedAgentIds = [], currentPersonaAgentIds = [], actions = [],
|
|
29
|
+
preservePersona = false
|
|
30
|
+
} = {}) {
|
|
31
|
+
if (!SDD_PERSONA_IDS.includes(requestedPersona)) {
|
|
32
|
+
throw new Error(`Persona "${requestedPersona}" is invalid. Use: ${SDD_PERSONA_IDS.join(", ")}.`);
|
|
33
|
+
}
|
|
34
|
+
const before = normalizePersonaAgentIds(currentPersonaAgentIds);
|
|
35
|
+
if (preservePersona) return result(before, before, [], []);
|
|
36
|
+
const selected = normalizePersonaAgentIds(selectedAgentIds);
|
|
37
|
+
if (requestedPersona === "off") {
|
|
38
|
+
return result(before, before.filter((id) => !selected.includes(id)), selected.filter((id) => before.includes(id)), []);
|
|
39
|
+
}
|
|
40
|
+
const admitted = selected.filter((id) => {
|
|
41
|
+
const mine = actions.filter((e) => e.agentIds?.includes(id));
|
|
42
|
+
return mine.length && mine.every((e) => e.action !== SDD_PLAN_ACTIONS.CONFLICT);
|
|
43
|
+
});
|
|
44
|
+
const kept = before.filter((id) => !selected.includes(id) || admitted.includes(id));
|
|
45
|
+
return result(before, normalizePersonaAgentIds([...kept, ...admitted]), admitted, selected.filter((id) => !admitted.includes(id)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function finalizePersonaTransition(planned, files, requestedPersona, { preservePersona = false } = {}) {
|
|
49
|
+
if (preservePersona || requestedPersona === "off" || !planned) return planned ?? result([], [], [], []);
|
|
50
|
+
const selected = normalizePersonaAgentIds([...planned.admitted, ...planned.rejected]);
|
|
51
|
+
const admitted = selected.filter((id) => {
|
|
52
|
+
const mine = files.filter((e) => e.agentIds?.includes(id));
|
|
53
|
+
return mine.length && mine.every((e) => e.outcome === SDD_FILE_OUTCOMES.APPLIED || e.outcome === SDD_FILE_OUTCOMES.NOOP);
|
|
54
|
+
});
|
|
55
|
+
const kept = planned.before.filter((id) => !selected.includes(id) || admitted.includes(id));
|
|
56
|
+
return result(planned.before, normalizePersonaAgentIds([...kept, ...admitted]), admitted, selected.filter((id) => !admitted.includes(id)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function classifyPersonaHealth({
|
|
60
|
+
personaAgentIds = [], assetPresent = false, gatePresent = false, incompleteAgentIds = []
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (!normalizePersonaAgentIds(personaAgentIds).length) {
|
|
63
|
+
return { status: SDD_PERSONA_HEALTH.OFF, personaActive: false, reason: "No consumers." };
|
|
64
|
+
}
|
|
65
|
+
if (!assetPresent) return { status: SDD_PERSONA_HEALTH.CONFLICT, personaActive: false, reason: "Asset missing." };
|
|
66
|
+
if (!gatePresent) return { status: SDD_PERSONA_HEALTH.SYNC_REQUIRED, personaActive: false, reason: "Gate missing." };
|
|
67
|
+
if (incompleteAgentIds.length) {
|
|
68
|
+
return { status: SDD_PERSONA_HEALTH.CONFLICT, personaActive: false, reason: `Incomplete: ${incompleteAgentIds.join(", ")}.` };
|
|
69
|
+
}
|
|
70
|
+
return { status: SDD_PERSONA_HEALTH.CONFIGURED, personaActive: true, reason: "Aligned." };
|
|
71
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hashBuffer } from "../../hash.js";
|
|
5
|
+
import { classifySddSkillFile, SDD_PLAN_ACTIONS } from "./sdd-evidence.js";
|
|
6
|
+
import {
|
|
7
|
+
SDD_PERSONA_IDS,
|
|
8
|
+
SDD_SKILL_IDS,
|
|
9
|
+
groupSddSkillDestinations,
|
|
10
|
+
resolveCanonicalTeachingPersonaPath,
|
|
11
|
+
resolveSddAgentSelection
|
|
12
|
+
} from "./sdd-destinations.js";
|
|
13
|
+
import { planPersonaTransition } from "./sdd-persona.js";
|
|
14
|
+
import { compareSkillPaths, loadCanonicalSddSkill } from "./sdd-skill-files.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deterministic dry-run planner for SDD skill materialization.
|
|
18
|
+
* Never writes, backs up, or mutates state.
|
|
19
|
+
*/
|
|
20
|
+
export async function planSddConfigure({
|
|
21
|
+
requestedAgentIds = null,
|
|
22
|
+
detectedAgentIds = [],
|
|
23
|
+
homeDir,
|
|
24
|
+
packageRoot,
|
|
25
|
+
persona = "off",
|
|
26
|
+
personaAgentIds = [],
|
|
27
|
+
trackedFiles = {},
|
|
28
|
+
preservePersona = false,
|
|
29
|
+
dryRun = true,
|
|
30
|
+
exists = existsSync,
|
|
31
|
+
readFileImpl = readFile
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (!homeDir) throw new Error("planSddConfigure requires homeDir.");
|
|
34
|
+
if (!packageRoot) throw new Error("planSddConfigure requires packageRoot.");
|
|
35
|
+
if (!SDD_PERSONA_IDS.includes(persona)) {
|
|
36
|
+
throw new Error(`Persona "${persona}" is invalid. Use: ${SDD_PERSONA_IDS.join(", ")}.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const agentIds = resolveSddAgentSelection({
|
|
40
|
+
requestedIds: requestedAgentIds,
|
|
41
|
+
detectedIds: detectedAgentIds
|
|
42
|
+
});
|
|
43
|
+
const destinationGroups = groupSddSkillDestinations(agentIds, homeDir);
|
|
44
|
+
const actions = [];
|
|
45
|
+
|
|
46
|
+
for (const skillId of SDD_SKILL_IDS) {
|
|
47
|
+
const { files, skillHash } = await loadCanonicalSddSkill(skillId, packageRoot);
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const canonicalHash = hashBuffer(file.bytes);
|
|
50
|
+
for (const group of destinationGroups) {
|
|
51
|
+
const destinationPath = join(group.root, skillId, ...file.relativePath.split("/"));
|
|
52
|
+
const trackedHash = trackedFiles[destinationPath] ?? null;
|
|
53
|
+
const fileExists = exists(destinationPath);
|
|
54
|
+
const diskHash = fileExists ? hashBuffer(await readFileImpl(destinationPath)) : null;
|
|
55
|
+
const classification = classifySddSkillFile({
|
|
56
|
+
exists: fileExists, canonicalHash, diskHash, trackedHash
|
|
57
|
+
});
|
|
58
|
+
actions.push({
|
|
59
|
+
skillId, relativePath: file.relativePath, destinationPath,
|
|
60
|
+
agentIds: [...group.agentIds], kind: group.kind,
|
|
61
|
+
action: classification.action, reason: classification.reason,
|
|
62
|
+
canonicalHash, skillHash, diskHash, trackedHash, writes: false, executes: false
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
actions.sort((a, b) => compareSkillPaths(a.skillId, b.skillId)
|
|
69
|
+
|| compareSkillPaths(a.relativePath, b.relativePath)
|
|
70
|
+
|| compareSkillPaths(a.destinationPath, b.destinationPath));
|
|
71
|
+
|
|
72
|
+
const summary = { create: 0, noop: 0, update: 0, conflict: 0 };
|
|
73
|
+
for (const action of actions) summary[action.action] += 1;
|
|
74
|
+
const personaTransition = planPersonaTransition({
|
|
75
|
+
requestedPersona: persona, selectedAgentIds: agentIds,
|
|
76
|
+
currentPersonaAgentIds: personaAgentIds, actions, preservePersona
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
provider: "sdd-core", componentId: "sdd-core", dryRun: Boolean(dryRun),
|
|
80
|
+
executes: false, writes: false, requestedPersona: persona, preservePersona,
|
|
81
|
+
persona: personaTransition.persona,
|
|
82
|
+
personaPath: resolveCanonicalTeachingPersonaPath(packageRoot),
|
|
83
|
+
personaActive: personaTransition.after.length > 0, personaTransition, agentIds, actions,
|
|
84
|
+
conflicts: actions.filter((entry) => entry.action === SDD_PLAN_ACTIONS.CONFLICT),
|
|
85
|
+
summary, sessionRefreshRequired: personaTransition.personaChanged
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { planSddConfigure } from "./sdd-plan.js";
|
|
2
|
+
import { applySddConfigure } from "./sdd-apply.js";
|
|
3
|
+
import { verifySddConfigure } from "./sdd-verify.js";
|
|
4
|
+
import { rollbackSddReceipt } from "./sdd-rollback.js";
|
|
5
|
+
|
|
6
|
+
export function createSddCoreProvider({
|
|
7
|
+
inspect = verifySddConfigure,
|
|
8
|
+
plan = planSddConfigure,
|
|
9
|
+
apply = applySddConfigure,
|
|
10
|
+
verify = verifySddConfigure,
|
|
11
|
+
rollback = rollbackSddReceipt
|
|
12
|
+
} = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id: "sdd-core",
|
|
15
|
+
async inspect(context = {}) { return inspect(context); },
|
|
16
|
+
async plan(context = {}) { return plan(context); },
|
|
17
|
+
async apply(context = {}) { return apply(context); },
|
|
18
|
+
async verify(context = {}) { return verify(context); },
|
|
19
|
+
async rollback(context = {}) { return rollback(context); }
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { harnessHomePaths } from "../paths.js";
|
|
5
|
+
|
|
6
|
+
export function sddIntegrationsDir(homeDir) {
|
|
7
|
+
return join(harnessHomePaths(homeDir).root, "integrations", "sdd-core");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function assertSafeSddReceiptId(receiptId) {
|
|
11
|
+
if (typeof receiptId !== "string" || !/^sdd-[A-Za-z0-9-]+$/.test(receiptId)) {
|
|
12
|
+
throw new Error(`Invalid SDD receipt id "${receiptId}".`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sddReceiptPath(homeDir, receiptId) {
|
|
17
|
+
assertSafeSddReceiptId(receiptId);
|
|
18
|
+
return join(sddIntegrationsDir(homeDir), `${receiptId}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Persist a secret-free receipt under ~/.harness/integrations/sdd-core/. */
|
|
22
|
+
export async function saveSddReceipt(receipt, { homeDir } = {}) {
|
|
23
|
+
assertSafeSddReceiptId(receipt.id);
|
|
24
|
+
await mkdir(sddIntegrationsDir(homeDir), { recursive: true });
|
|
25
|
+
const path = sddReceiptPath(homeDir, receipt.id);
|
|
26
|
+
const sanitized = { ...receipt, persisted: true };
|
|
27
|
+
await writeFile(path, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
|
|
28
|
+
return { path, receipt: sanitized };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadSddReceipt(receiptId, { homeDir } = {}) {
|
|
32
|
+
const path = sddReceiptPath(homeDir, receiptId);
|
|
33
|
+
if (!existsSync(path)) throw new Error(`SDD receipt not found: ${receiptId}`);
|
|
34
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function listSddReceipts({ homeDir } = {}) {
|
|
38
|
+
const dir = sddIntegrationsDir(homeDir);
|
|
39
|
+
if (!existsSync(dir)) return [];
|
|
40
|
+
return (await readdir(dir))
|
|
41
|
+
.filter((name) => name.endsWith(".json"))
|
|
42
|
+
.map((name) => name.slice(0, -5))
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
assertExplicitApplyConsent,
|
|
6
|
+
promptApplyConfirmation,
|
|
7
|
+
shouldPromptApplyConfirmation
|
|
8
|
+
} from "../apply-confirmation.js";
|
|
9
|
+
import {
|
|
10
|
+
assertBackupPathContained,
|
|
11
|
+
assertSafePathChain,
|
|
12
|
+
deleteRegularFileIfHash,
|
|
13
|
+
hashRegularFile,
|
|
14
|
+
parentRealpath,
|
|
15
|
+
refuseSymlink,
|
|
16
|
+
replaceRegularFile,
|
|
17
|
+
resolveExpectedSddDestination,
|
|
18
|
+
snapshotRegularFile
|
|
19
|
+
} from "./sdd-fs-guard.js";
|
|
20
|
+
import { assertSafeSddReceiptId, loadSddReceipt, sddIntegrationsDir } from "./sdd-receipts.js";
|
|
21
|
+
import { samePersonaAgentIds } from "./sdd-persona.js";
|
|
22
|
+
|
|
23
|
+
export function sddReceiptBackupDir(homeDir, receiptId) {
|
|
24
|
+
assertSafeSddReceiptId(receiptId);
|
|
25
|
+
return join(sddIntegrationsDir(homeDir), "backups", receiptId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sddBackupPathFor(homeDir, receiptId, destinationPath) {
|
|
29
|
+
return join(sddReceiptBackupDir(homeDir, receiptId), Buffer.from(destinationPath).toString("base64url"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function backupSddDestination(destinationPath, {
|
|
33
|
+
homeDir, receiptId, managedRoot = null, snapshot = null, parentRealpath: knownParent = null
|
|
34
|
+
} = {}) {
|
|
35
|
+
if (!existsSync(destinationPath) && !snapshot) return null;
|
|
36
|
+
if (managedRoot) {
|
|
37
|
+
const chain = await assertSafePathChain(destinationPath, managedRoot, homeDir);
|
|
38
|
+
if (!chain.ok) throw new Error(chain.reason);
|
|
39
|
+
}
|
|
40
|
+
const snap = snapshot ?? await snapshotRegularFile(destinationPath);
|
|
41
|
+
const backupDir = sddReceiptBackupDir(homeDir, receiptId);
|
|
42
|
+
await mkdir(backupDir, { recursive: true });
|
|
43
|
+
const backupPath = sddBackupPathFor(homeDir, receiptId, destinationPath);
|
|
44
|
+
const chain = await assertSafePathChain(backupPath, backupDir, homeDir);
|
|
45
|
+
if (!chain.ok) throw new Error(chain.reason);
|
|
46
|
+
const planted = await refuseSymlink(backupPath, "Backup");
|
|
47
|
+
if (planted) throw new Error(planted);
|
|
48
|
+
if (existsSync(backupPath)) throw new Error("Backup already exists; refusing overwrite.");
|
|
49
|
+
await replaceRegularFile(backupPath, snap.bytes, { createExclusive: true, managedRoot: backupDir, expectedParentRealpath: await parentRealpath(backupPath), trustedAnchor: homeDir });
|
|
50
|
+
return {
|
|
51
|
+
path: destinationPath, backupPath, beforeHash: snap.hash, ino: snap.ino,
|
|
52
|
+
parentRealpath: knownParent ?? await parentRealpath(destinationPath)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Bounded rollback: require complete evidence, then delete/restore only when afterHash matches. */
|
|
57
|
+
export async function rollbackSddReceipt({
|
|
58
|
+
receiptId,
|
|
59
|
+
homeDir,
|
|
60
|
+
dryRun = false,
|
|
61
|
+
yes = false,
|
|
62
|
+
json = false,
|
|
63
|
+
interactive = null,
|
|
64
|
+
confirm = promptApplyConfirmation,
|
|
65
|
+
personaAgentIds = []
|
|
66
|
+
} = {}) {
|
|
67
|
+
assertExplicitApplyConsent({
|
|
68
|
+
applying: !dryRun, dryRun, json, yes, interactive, command: "components rollback sdd-core"
|
|
69
|
+
});
|
|
70
|
+
const receipt = await loadSddReceipt(receiptId, { homeDir });
|
|
71
|
+
|
|
72
|
+
if (shouldPromptApplyConfirmation({ applying: true, dryRun, json, confirm: yes, interactive })) {
|
|
73
|
+
const accepted = await confirm({
|
|
74
|
+
command: `components rollback sdd-core --receipt ${receiptId}`,
|
|
75
|
+
question: `Rollback SDD receipt ${receiptId}? [Y/n]: `
|
|
76
|
+
});
|
|
77
|
+
if (!accepted) return { dryRun, cancelled: true, receiptId, actions: [] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const evidence = await assessRollbackEvidence(receipt, { homeDir, receiptId, personaAgentIds });
|
|
81
|
+
if (!evidence.complete) {
|
|
82
|
+
return {
|
|
83
|
+
dryRun,
|
|
84
|
+
cancelled: false,
|
|
85
|
+
receiptId,
|
|
86
|
+
ok: false,
|
|
87
|
+
blocked: true,
|
|
88
|
+
reason: "Incomplete or unsafe rollback evidence; refusing all mutations.",
|
|
89
|
+
actions: evidence.actions
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const actions = [];
|
|
94
|
+
const fileSteps = evidence.steps.filter((step) => step.action !== "persona");
|
|
95
|
+
const personaStep = evidence.steps.find((step) => step.action === "persona");
|
|
96
|
+
for (const step of fileSteps) {
|
|
97
|
+
if (step.action === "delete") actions.push(await executeDelete(step, { dryRun, homeDir }));
|
|
98
|
+
else if (step.action === "restore") actions.push(await executeRestore(step, { dryRun, homeDir }));
|
|
99
|
+
}
|
|
100
|
+
if (personaStep) {
|
|
101
|
+
const filesOk = actions.every((entry) => entry.ok !== false && (
|
|
102
|
+
entry.action === "delete" || entry.action === "restore"
|
|
103
|
+
));
|
|
104
|
+
const ok = personaStep.noop || filesOk;
|
|
105
|
+
actions.push({
|
|
106
|
+
action: "persona", ok, noop: Boolean(personaStep.noop), dryRun: Boolean(dryRun),
|
|
107
|
+
before: personaStep.before, after: personaStep.after,
|
|
108
|
+
...(ok ? {} : { reason: "Refusing persona rollback after incomplete file rollback." }),
|
|
109
|
+
...(personaStep.noop ? { reason: "Already at before persona state." } : {})
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
dryRun, cancelled: false, receiptId, blocked: false,
|
|
115
|
+
ok: actions.every((entry) => entry.ok !== false), actions
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function assessRollbackEvidence(receipt, { homeDir, receiptId, personaAgentIds }) {
|
|
120
|
+
const backups = new Map((receipt.backups ?? []).map((entry) => [entry.path, entry]));
|
|
121
|
+
const actions = [];
|
|
122
|
+
const steps = [];
|
|
123
|
+
|
|
124
|
+
for (const file of receipt.files ?? []) {
|
|
125
|
+
if (!file.applied) continue;
|
|
126
|
+
const expected = resolveExpectedSddDestination(file, homeDir);
|
|
127
|
+
if (!expected.ok) {
|
|
128
|
+
actions.push({ path: file.destinationPath, action: "block", ok: false, reason: expected.reason });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!file.afterHash) {
|
|
132
|
+
actions.push({ path: expected.path, action: "block", ok: false, reason: "Missing afterHash evidence." });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const chain = await assertSafePathChain(expected.path, expected.managedRoot, homeDir);
|
|
136
|
+
if (!chain.ok) {
|
|
137
|
+
actions.push({ path: expected.path, action: "block", ok: false, reason: chain.reason });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const backup = backups.get(file.destinationPath) ?? backups.get(expected.path);
|
|
141
|
+
const expectedParent = file.parentRealpath ?? backup?.parentRealpath ?? null;
|
|
142
|
+
if (!expectedParent) {
|
|
143
|
+
actions.push({
|
|
144
|
+
path: expected.path, action: "block", ok: false,
|
|
145
|
+
reason: "Missing parentRealpath evidence from apply."
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if ((await parentRealpath(expected.path)) !== expectedParent) {
|
|
150
|
+
actions.push({
|
|
151
|
+
path: expected.path, action: "block", ok: false,
|
|
152
|
+
reason: "Parent realpath changed since apply; refusing."
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (file.action === "create") {
|
|
158
|
+
steps.push({
|
|
159
|
+
action: "delete", path: expected.path, afterHash: file.afterHash,
|
|
160
|
+
managedRoot: expected.managedRoot, expectedParentRealpath: expectedParent
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (file.action === "update") {
|
|
166
|
+
const backupCheck = await validateUpdateBackup(file, backup, {
|
|
167
|
+
homeDir, receiptId, expectedPath: expected.path
|
|
168
|
+
});
|
|
169
|
+
if (!backupCheck.ok) {
|
|
170
|
+
actions.push({ path: expected.path, action: "block", ok: false, reason: backupCheck.reason });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
steps.push({
|
|
174
|
+
action: "restore",
|
|
175
|
+
path: expected.path,
|
|
176
|
+
afterHash: file.afterHash,
|
|
177
|
+
beforeHash: backupCheck.beforeHash,
|
|
178
|
+
backupPath: backupCheck.backupPath,
|
|
179
|
+
managedRoot: expected.managedRoot,
|
|
180
|
+
expectedParentRealpath: expectedParent
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const transition = receipt.personaTransition;
|
|
186
|
+
if (transition?.personaChanged) {
|
|
187
|
+
if (samePersonaAgentIds(transition.before, personaAgentIds)) {
|
|
188
|
+
steps.push({ action: "persona", noop: true, before: transition.before, after: transition.after });
|
|
189
|
+
} else if (!samePersonaAgentIds(transition.after, personaAgentIds)) {
|
|
190
|
+
actions.push({
|
|
191
|
+
action: "persona", ok: false,
|
|
192
|
+
reason: "Persona state changed since apply; refusing persona rollback."
|
|
193
|
+
});
|
|
194
|
+
} else {
|
|
195
|
+
steps.push({ action: "persona", before: transition.before, after: transition.after });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { complete: actions.length === 0, actions, steps };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function validateUpdateBackup(file, backup, { homeDir, receiptId, expectedPath }) {
|
|
202
|
+
if (!backup?.backupPath) return { ok: false, reason: "Backup missing." };
|
|
203
|
+
const contained = assertBackupPathContained(backup.backupPath, sddReceiptBackupDir(homeDir, receiptId));
|
|
204
|
+
if (!contained.ok) return contained;
|
|
205
|
+
const expectedBackup = sddBackupPathFor(homeDir, receiptId, expectedPath);
|
|
206
|
+
if (contained.path !== expectedBackup) {
|
|
207
|
+
return { ok: false, reason: "Backup path does not match receipt destination encoding." };
|
|
208
|
+
}
|
|
209
|
+
const beforeHash = backup.beforeHash ?? file.beforeHash;
|
|
210
|
+
if (!beforeHash) return { ok: false, reason: "Missing beforeHash evidence for backup." };
|
|
211
|
+
if (!existsSync(contained.path)) return { ok: false, reason: "Backup missing." };
|
|
212
|
+
try {
|
|
213
|
+
const backupHash = await hashRegularFile(contained.path);
|
|
214
|
+
if (backupHash !== beforeHash) {
|
|
215
|
+
return { ok: false, reason: "Backup hash does not match beforeHash." };
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return { ok: false, reason: error.message };
|
|
219
|
+
}
|
|
220
|
+
return { ok: true, backupPath: contained.path, beforeHash };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function executeDelete(step, { dryRun, homeDir }) {
|
|
224
|
+
if (!existsSync(step.path)) {
|
|
225
|
+
return { path: step.path, action: "delete", ok: true, reason: "Already absent." };
|
|
226
|
+
}
|
|
227
|
+
if (dryRun) return { path: step.path, action: "delete", ok: true, dryRun: true };
|
|
228
|
+
try {
|
|
229
|
+
const result = await deleteRegularFileIfHash(step.path, step.afterHash, {
|
|
230
|
+
managedRoot: step.managedRoot, expectedParentRealpath: step.expectedParentRealpath, trustedAnchor: homeDir
|
|
231
|
+
});
|
|
232
|
+
if (result.skipped) return { path: step.path, action: "skip", ok: true, reason: result.reason };
|
|
233
|
+
if (!result.ok) return { path: step.path, action: "skip", ok: false, reason: result.reason };
|
|
234
|
+
return { path: step.path, action: "delete", ok: true };
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return { path: step.path, action: "skip", ok: false, reason: error.message };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function executeRestore(step, { dryRun, homeDir }) {
|
|
241
|
+
if (!existsSync(step.path)) {
|
|
242
|
+
return { path: step.path, action: "skip", ok: false, reason: "Updated destination missing." };
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const chain = await assertSafePathChain(step.path, step.managedRoot, homeDir);
|
|
246
|
+
if (!chain.ok) return { path: step.path, action: "skip", ok: false, reason: chain.reason };
|
|
247
|
+
if ((await parentRealpath(step.path)) !== step.expectedParentRealpath) {
|
|
248
|
+
return { path: step.path, action: "skip", ok: false, reason: "Parent realpath changed before restore; refusing." };
|
|
249
|
+
}
|
|
250
|
+
const destination = await snapshotRegularFile(step.path);
|
|
251
|
+
if (destination.hash !== step.afterHash) {
|
|
252
|
+
return { path: step.path, action: "skip", ok: true, reason: "File changed after apply; refusing restore." };
|
|
253
|
+
}
|
|
254
|
+
if (!existsSync(step.backupPath)) {
|
|
255
|
+
return { path: step.path, action: "skip", ok: false, reason: "Backup missing." };
|
|
256
|
+
}
|
|
257
|
+
const backup = await snapshotRegularFile(step.backupPath);
|
|
258
|
+
if (backup.hash !== step.beforeHash) {
|
|
259
|
+
return { path: step.path, action: "skip", ok: false, reason: "Backup hash does not match beforeHash." };
|
|
260
|
+
}
|
|
261
|
+
if (dryRun) {
|
|
262
|
+
return { path: step.path, action: "restore", ok: true, dryRun: true, backupPath: step.backupPath };
|
|
263
|
+
}
|
|
264
|
+
await replaceRegularFile(step.path, backup.bytes, { expectedIno: destination.ino, expectedHash: destination.hash, managedRoot: step.managedRoot, expectedParentRealpath: step.expectedParentRealpath, trustedAnchor: homeDir });
|
|
265
|
+
return { path: step.path, action: "restore", ok: true, backupPath: step.backupPath };
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return { path: step.path, action: "skip", ok: false, reason: error.message };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { isPathInside } from "../component-paths.js";
|
|
5
|
+
import { resolveCanonicalSddSkillDir } from "./sdd-destinations.js";
|
|
6
|
+
export function normalizeSkillRelativePath(p) {
|
|
7
|
+
return String(p ?? "").split(sep).join("/");
|
|
8
|
+
}
|
|
9
|
+
export function compareSkillPaths(left, right) {
|
|
10
|
+
const a = normalizeSkillRelativePath(left), b = normalizeSkillRelativePath(right);
|
|
11
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
12
|
+
}
|
|
13
|
+
function assertSafeRelative(root, absolutePath) {
|
|
14
|
+
const rel = normalizeSkillRelativePath(relative(root, absolutePath));
|
|
15
|
+
if (!rel || rel.startsWith("../") || rel.split("/").includes("..")
|
|
16
|
+
|| (!isPathInside(root, absolutePath) && resolve(absolutePath) !== root)) {
|
|
17
|
+
throw new Error(`Skill path escapes skill root: ${rel || "."}`);
|
|
18
|
+
}
|
|
19
|
+
return rel;
|
|
20
|
+
}
|
|
21
|
+
function u32be(n) {
|
|
22
|
+
const buf = Buffer.allocUnsafe(4);
|
|
23
|
+
buf.writeUInt32BE(n >>> 0);
|
|
24
|
+
return buf;
|
|
25
|
+
}
|
|
26
|
+
export async function listSddSkillFiles(skillDir) {
|
|
27
|
+
const root = resolve(skillDir), rootStats = await lstat(root);
|
|
28
|
+
if (rootStats.isSymbolicLink()) throw new Error("Skill root is a symlink; refusing.");
|
|
29
|
+
if (!rootStats.isDirectory()) throw new Error("Skill root is not a directory; refusing.");
|
|
30
|
+
const files = [];
|
|
31
|
+
await walk(root, root, files);
|
|
32
|
+
return files.sort((a, b) => compareSkillPaths(a.relativePath, b.relativePath));
|
|
33
|
+
}
|
|
34
|
+
async function walk(root, dir, files) {
|
|
35
|
+
for (const name of (await readdir(dir)).sort()) {
|
|
36
|
+
const absolutePath = join(dir, name), relativePath = assertSafeRelative(root, absolutePath);
|
|
37
|
+
const stats = await lstat(absolutePath);
|
|
38
|
+
if (stats.isSymbolicLink()) throw new Error(`Symlink refused in skill: ${relativePath}`);
|
|
39
|
+
if (stats.isDirectory()) { await walk(root, absolutePath, files); continue; }
|
|
40
|
+
if (!stats.isFile()) throw new Error(`Non-regular file refused in skill: ${relativePath}`);
|
|
41
|
+
files.push({ relativePath, absolutePath });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function readSddSkillFiles(skillDir) {
|
|
45
|
+
return Promise.all((await listSddSkillFiles(skillDir)).map(async (e) => (
|
|
46
|
+
{ ...e, bytes: await readFile(e.absolutePath) }
|
|
47
|
+
)));
|
|
48
|
+
}
|
|
49
|
+
export function hashSddSkillFiles(files) {
|
|
50
|
+
const hash = createHash("sha256");
|
|
51
|
+
for (const file of [...files].sort((a, b) => compareSkillPaths(a.relativePath, b.relativePath))) {
|
|
52
|
+
const path = Buffer.from(normalizeSkillRelativePath(file.relativePath), "utf8");
|
|
53
|
+
hash.update(u32be(path.length)).update(path);
|
|
54
|
+
hash.update(u32be(file.bytes.length)).update(file.bytes);
|
|
55
|
+
}
|
|
56
|
+
return hash.digest("hex");
|
|
57
|
+
}
|
|
58
|
+
export async function loadCanonicalSddSkill(skillId, packageRoot) {
|
|
59
|
+
const files = await readSddSkillFiles(resolveCanonicalSddSkillDir(skillId, packageRoot));
|
|
60
|
+
return { files, skillHash: hashSddSkillFiles(files) };
|
|
61
|
+
}
|