@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.
Files changed (103) hide show
  1. package/README.md +124 -17
  2. package/global-template/components/catalog.json +63 -6
  3. package/global-template/components/sdd-core/personas/teaching.md +42 -0
  4. package/global-template/components/sdd-core/skills/sdd-apply/SKILL.md +34 -0
  5. package/global-template/components/sdd-core/skills/sdd-apply/references/contract.md +4 -0
  6. package/global-template/components/sdd-core/skills/sdd-archive/SKILL.md +34 -0
  7. package/global-template/components/sdd-core/skills/sdd-archive/references/contract.md +4 -0
  8. package/global-template/components/sdd-core/skills/sdd-design/SKILL.md +34 -0
  9. package/global-template/components/sdd-core/skills/sdd-design/references/contract.md +4 -0
  10. package/global-template/components/sdd-core/skills/sdd-explore/SKILL.md +34 -0
  11. package/global-template/components/sdd-core/skills/sdd-explore/references/contract.md +4 -0
  12. package/global-template/components/sdd-core/skills/sdd-init/SKILL.md +34 -0
  13. package/global-template/components/sdd-core/skills/sdd-init/references/contract.md +4 -0
  14. package/global-template/components/sdd-core/skills/sdd-propose/SKILL.md +35 -0
  15. package/global-template/components/sdd-core/skills/sdd-propose/references/contract.md +4 -0
  16. package/global-template/components/sdd-core/skills/sdd-spec/SKILL.md +34 -0
  17. package/global-template/components/sdd-core/skills/sdd-spec/references/contract.md +4 -0
  18. package/global-template/components/sdd-core/skills/sdd-tasks/SKILL.md +34 -0
  19. package/global-template/components/sdd-core/skills/sdd-tasks/references/contract.md +4 -0
  20. package/global-template/components/sdd-core/skills/sdd-verify/SKILL.md +34 -0
  21. package/global-template/components/sdd-core/skills/sdd-verify/references/contract.md +4 -0
  22. package/package.json +1 -1
  23. package/scripts/cockpit-smoke.mjs +29 -8
  24. package/scripts/smoke-test.sh +9 -0
  25. package/src/cli.js +54 -5
  26. package/src/global/action-planner.js +17 -4
  27. package/src/global/component-authoring.js +1 -2
  28. package/src/global/component-ecosystem-checks.js +74 -15
  29. package/src/global/component-health.js +56 -0
  30. package/src/global/component-integration-cli.js +302 -0
  31. package/src/global/component-manifest.js +214 -0
  32. package/src/global/component-registry.js +19 -2
  33. package/src/global/component-resolve.js +51 -0
  34. package/src/global/components/sdd-core.js +6 -0
  35. package/src/global/control-plane-proposals.js +229 -0
  36. package/src/global/control-plane-snapshot.js +212 -0
  37. package/src/global/dashboard-guidance.js +27 -3
  38. package/src/global/diff.js +2 -5
  39. package/src/global/global-cli.js +16 -1
  40. package/src/global/global-doctor.js +75 -2
  41. package/src/global/global-installer.js +43 -6
  42. package/src/global/governance-actions.js +212 -0
  43. package/src/global/governance-repair.js +13 -0
  44. package/src/global/ink/cockpit-changes.js +124 -0
  45. package/src/global/ink/cockpit-control-center.js +106 -0
  46. package/src/global/ink/cockpit-controller.js +17 -0
  47. package/src/global/ink/cockpit-enter.js +27 -0
  48. package/src/global/ink/cockpit-focus.js +7 -1
  49. package/src/global/ink/cockpit-models.js +98 -46
  50. package/src/global/ink/cockpit-proposals.js +82 -0
  51. package/src/global/ink/cockpit-recovery.js +120 -0
  52. package/src/global/ink/cockpit-runs.js +47 -0
  53. package/src/global/ink/cockpit-scan.js +60 -0
  54. package/src/global/ink/cockpit-views.js +144 -74
  55. package/src/global/ink/orchestrator-app.js +156 -42
  56. package/src/global/ink/orchestrator-state.js +22 -1
  57. package/src/global/ink/use-orchestrator-data.js +239 -17
  58. package/src/global/integrations/engram-apply.js +105 -0
  59. package/src/global/integrations/engram-evidence.js +218 -0
  60. package/src/global/integrations/engram-exec.js +107 -0
  61. package/src/global/integrations/engram-plan.js +60 -0
  62. package/src/global/integrations/engram-provider.js +22 -0
  63. package/src/global/integrations/engram-receipts.js +116 -0
  64. package/src/global/integrations/engram-rollback.js +131 -0
  65. package/src/global/integrations/index.js +12 -0
  66. package/src/global/integrations/provider-contract.js +83 -0
  67. package/src/global/integrations/provider-registry.js +47 -0
  68. package/src/global/integrations/sdd-apply.js +190 -0
  69. package/src/global/integrations/sdd-destinations.js +95 -0
  70. package/src/global/integrations/sdd-evidence.js +89 -0
  71. package/src/global/integrations/sdd-fs-guard.js +183 -0
  72. package/src/global/integrations/sdd-lifecycle.js +84 -0
  73. package/src/global/integrations/sdd-persona.js +71 -0
  74. package/src/global/integrations/sdd-plan.js +87 -0
  75. package/src/global/integrations/sdd-provider.js +21 -0
  76. package/src/global/integrations/sdd-receipts.js +44 -0
  77. package/src/global/integrations/sdd-rollback.js +269 -0
  78. package/src/global/integrations/sdd-skill-files.js +61 -0
  79. package/src/global/integrations/sdd-state.js +175 -0
  80. package/src/global/integrations/sdd-verify.js +122 -0
  81. package/src/global/intelligence/backends/opencode-catalog.js +212 -0
  82. package/src/global/intelligence/backends/opencode-cli.js +247 -0
  83. package/src/global/intelligence/backends/opencode-evidence.js +129 -0
  84. package/src/global/intelligence/backends/opencode-http.js +152 -0
  85. package/src/global/intelligence/backends/opencode-invoke.js +83 -0
  86. package/src/global/intelligence/backends/opencode-providers.js +67 -0
  87. package/src/global/intelligence/backends/opencode-runtime.js +242 -0
  88. package/src/global/intelligence/context-compiler.js +95 -50
  89. package/src/global/intelligence/index.js +38 -1
  90. package/src/global/intelligence/orchestrate.js +4 -2
  91. package/src/global/intelligence/registry.js +62 -6
  92. package/src/global/intelligence/router.js +188 -70
  93. package/src/global/intelligence/transport-registry.js +147 -0
  94. package/src/global/intelligence/types.js +21 -2
  95. package/src/global/intelligence-cli.js +48 -17
  96. package/src/global/intelligence-session.js +37 -0
  97. package/src/global/json-output.js +1 -0
  98. package/src/global/load-component-catalog.js +10 -28
  99. package/src/global/load-workspace-component-catalog.js +22 -63
  100. package/src/global/state-migration.js +21 -1
  101. package/src/global/state.js +13 -8
  102. package/src/global/status.js +4 -17
  103. package/src/global/sync.js +2 -5
@@ -0,0 +1,190 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { hashBuffer } from "../../hash.js";
5
+ import { assertExplicitApplyConsent, promptApplyConfirmation, shouldPromptApplyConfirmation } from "../apply-confirmation.js";
6
+ import { SDD_FILE_OUTCOMES, SDD_PLAN_ACTIONS } from "./sdd-evidence.js";
7
+ import { resolveCanonicalSddSkillFile, resolveSddSkillRoot } from "./sdd-destinations.js";
8
+ import {
9
+ assertSafePathChain, parentRealpath, replaceRegularFile, snapshotRegularFile
10
+ } from "./sdd-fs-guard.js";
11
+ import { finalizePersonaTransition } from "./sdd-persona.js";
12
+ import { planSddConfigure } from "./sdd-plan.js";
13
+ import { saveSddReceipt } from "./sdd-receipts.js";
14
+ import { backupSddDestination } from "./sdd-rollback.js";
15
+
16
+ const APPLYING_ACTIONS = new Set([SDD_PLAN_ACTIONS.CREATE, SDD_PLAN_ACTIONS.UPDATE]);
17
+
18
+ export async function applySddConfigure({
19
+ requestedAgentIds = null, detectedAgentIds = [], homeDir, packageRoot, persona = "off",
20
+ personaAgentIds = [], trackedFiles = {}, preservePersona = false, dryRun = false, yes = false,
21
+ json = false, interactive = null, receiptId = null, plan = planSddConfigure,
22
+ confirm = promptApplyConfirmation, saveReceipt = saveSddReceipt,
23
+ now = () => new Date().toISOString()
24
+ } = {}) {
25
+ assertExplicitApplyConsent({
26
+ applying: !dryRun, dryRun, json, yes, interactive, command: "components configure sdd-core"
27
+ });
28
+
29
+ const planned = await plan({
30
+ requestedAgentIds, detectedAgentIds, homeDir, packageRoot, persona, personaAgentIds,
31
+ trackedFiles, preservePersona, dryRun: true
32
+ });
33
+ if (dryRun) return { ...planned, applied: false, cancelled: false, receipt: null };
34
+
35
+ if (shouldPromptApplyConfirmation({ applying: true, dryRun, json, confirm: yes, interactive })) {
36
+ const accepted = await confirm({
37
+ command: "components configure sdd-core",
38
+ question: "Materialize SDD skills for the planned agents? [Y/n]: "
39
+ });
40
+ if (!accepted) return { ...planned, applied: false, cancelled: true, receipt: null };
41
+ }
42
+
43
+ const startedAt = now();
44
+ const resolvedReceiptId = receiptId ?? `sdd-${startedAt.replace(/[:.]/g, "-")}`;
45
+ const files = [];
46
+ const backups = [];
47
+ let failed = null;
48
+ let abortRemaining = false;
49
+
50
+ for (const action of planned.actions) {
51
+ const record = buildFileRecord(action);
52
+ if (!APPLYING_ACTIONS.has(action.action)) {
53
+ const outcome = action.action === SDD_PLAN_ACTIONS.CONFLICT
54
+ ? SDD_FILE_OUTCOMES.CONFLICT
55
+ : SDD_FILE_OUTCOMES.NOOP;
56
+ files.push({ ...record, applied: false, skipped: true, outcome, afterHash: action.diskHash });
57
+ continue;
58
+ }
59
+ if (abortRemaining) {
60
+ files.push({
61
+ ...record, applied: false, skipped: true, outcome: SDD_FILE_OUTCOMES.SKIPPED,
62
+ error: "Skipped after prior file failure; partial evidence retained."
63
+ });
64
+ continue;
65
+ }
66
+ try {
67
+ const managedRoot = resolveSddSkillRoot(action.agentIds[0], homeDir);
68
+ const outcome = await materializeOne(action, {
69
+ homeDir, packageRoot, managedRoot, receiptId: resolvedReceiptId
70
+ });
71
+ if (outcome.conflict) {
72
+ files.push({
73
+ ...record, action: SDD_PLAN_ACTIONS.CONFLICT, reason: outcome.conflict,
74
+ applied: false, skipped: true, outcome: SDD_FILE_OUTCOMES.CONFLICT
75
+ });
76
+ continue;
77
+ }
78
+ if (outcome.backup) backups.push(outcome.backup);
79
+ files.push({
80
+ ...record, applied: true, skipped: false, outcome: SDD_FILE_OUTCOMES.APPLIED,
81
+ afterHash: outcome.afterHash, parentRealpath: outcome.parentRealpath
82
+ });
83
+ } catch (error) {
84
+ failed = { skillId: action.skillId, destinationPath: action.destinationPath, error: error.message };
85
+ files.push({
86
+ ...record, applied: false, skipped: false, outcome: SDD_FILE_OUTCOMES.FAILED, error: error.message
87
+ });
88
+ abortRemaining = true;
89
+ }
90
+ }
91
+
92
+ const appliedCount = files.filter((entry) => entry.applied).length;
93
+ const personaTransition = finalizePersonaTransition(
94
+ planned.personaTransition ?? {
95
+ before: [], after: [], admitted: [], rejected: [], persona: "off", personaChanged: false
96
+ },
97
+ files, planned.requestedPersona ?? persona, { preservePersona }
98
+ );
99
+ const { before, after, admitted, rejected, personaChanged } = personaTransition;
100
+ const receipt = {
101
+ id: resolvedReceiptId,
102
+ provider: "sdd-core", componentId: "sdd-core", startedAt, finishedAt: now(),
103
+ persona: personaTransition.persona, personaActive: after.length > 0,
104
+ personaTransition: { before, after, admitted, rejected, personaChanged },
105
+ agentIds: planned.agentIds, files, backups, summary: planned.summary,
106
+ conflicts: files
107
+ .filter((entry) => entry.action === SDD_PLAN_ACTIONS.CONFLICT)
108
+ .map((entry) => ({ destinationPath: entry.destinationPath, reason: entry.reason })),
109
+ ok: failed == null, partial: failed != null && appliedCount > 0, failed,
110
+ sessionRefreshRequired: appliedCount > 0 || personaChanged, persisted: false
111
+ };
112
+
113
+ const saved = await saveReceipt(receipt, { homeDir });
114
+ return {
115
+ ...planned, persona: personaTransition.persona, personaActive: receipt.personaActive,
116
+ personaTransition, dryRun: false, executes: true, writes: appliedCount > 0,
117
+ applied: failed == null, cancelled: false,
118
+ sessionRefreshRequired: receipt.sessionRefreshRequired,
119
+ receipt: saved.receipt, receiptPath: saved.path
120
+ };
121
+ }
122
+
123
+ function buildFileRecord(action) {
124
+ return {
125
+ skillId: action.skillId,
126
+ relativePath: action.relativePath ?? "SKILL.md",
127
+ destinationPath: action.destinationPath,
128
+ agentIds: [...action.agentIds],
129
+ action: action.action,
130
+ reason: action.reason,
131
+ canonicalHash: action.canonicalHash,
132
+ skillHash: action.skillHash ?? null,
133
+ beforeHash: action.diskHash,
134
+ trackedHash: action.trackedHash
135
+ };
136
+ }
137
+
138
+ async function readCanonicalBytes(action, packageRoot) {
139
+ return readFile(resolveCanonicalSddSkillFile(
140
+ action.skillId, action.relativePath ?? "SKILL.md", packageRoot
141
+ ));
142
+ }
143
+
144
+ async function materializeOne(action, { homeDir, packageRoot, managedRoot, receiptId }) {
145
+ const chain = await assertSafePathChain(action.destinationPath, managedRoot, homeDir);
146
+ if (!chain.ok) return { conflict: chain.reason };
147
+
148
+ if (action.action === SDD_PLAN_ACTIONS.CREATE) {
149
+ if (existsSync(action.destinationPath)) {
150
+ return { conflict: "Destination appeared after planning; preserving byte-for-byte." };
151
+ }
152
+ const bytes = await readCanonicalBytes(action, packageRoot);
153
+ await mkdir(dirname(action.destinationPath), { recursive: true });
154
+ const after = await assertSafePathChain(action.destinationPath, managedRoot, homeDir);
155
+ if (!after.ok) throw new Error(after.reason);
156
+ const parent = await parentRealpath(action.destinationPath);
157
+ try {
158
+ await replaceRegularFile(action.destinationPath, bytes, {
159
+ createExclusive: true, managedRoot, expectedParentRealpath: parent, trustedAnchor: homeDir
160
+ });
161
+ } catch (error) {
162
+ if (/appeared before create|EEXIST/i.test(error.message)) return { conflict: error.message };
163
+ throw error;
164
+ }
165
+ return { afterHash: hashBuffer(bytes), parentRealpath: parent, backup: null };
166
+ }
167
+
168
+ if (!existsSync(action.destinationPath)) {
169
+ return { conflict: "Managed destination disappeared after planning; preserving byte-for-byte." };
170
+ }
171
+ const snap = await snapshotRegularFile(action.destinationPath);
172
+ if (snap.hash !== action.trackedHash) {
173
+ return { conflict: "Managed file changed after planning; preserving byte-for-byte." };
174
+ }
175
+ const parent = await parentRealpath(action.destinationPath);
176
+ let backup;
177
+ try {
178
+ backup = await backupSddDestination(action.destinationPath, {
179
+ homeDir, receiptId, managedRoot, snapshot: snap, parentRealpath: parent
180
+ });
181
+ } catch (error) {
182
+ if (/Backup already exists/i.test(error.message)) return { conflict: error.message };
183
+ throw error;
184
+ }
185
+ const bytes = await readCanonicalBytes(action, packageRoot);
186
+ await replaceRegularFile(action.destinationPath, bytes, {
187
+ expectedIno: snap.ino, expectedHash: snap.hash, managedRoot, expectedParentRealpath: parent, trustedAnchor: homeDir
188
+ });
189
+ return { afterHash: hashBuffer(bytes), parentRealpath: parent, backup };
190
+ }
@@ -0,0 +1,95 @@
1
+ import { join } from "node:path";
2
+
3
+ export const SDD_SKILL_IDS = Object.freeze([
4
+ "sdd-init",
5
+ "sdd-explore",
6
+ "sdd-propose",
7
+ "sdd-spec",
8
+ "sdd-design",
9
+ "sdd-tasks",
10
+ "sdd-apply",
11
+ "sdd-verify",
12
+ "sdd-archive"
13
+ ]);
14
+
15
+ export const SDD_MANAGED_AGENT_IDS = Object.freeze(["cursor", "codex", "opencode", "claude"]);
16
+ export const SDD_SHARED_AGENT_IDS = Object.freeze(["cursor", "codex", "opencode"]);
17
+ export const SDD_PERSONA_IDS = Object.freeze(["off", "teaching"]);
18
+
19
+ export function resolveSddAgentSelection({
20
+ requestedIds = null,
21
+ detectedIds = [],
22
+ managedIds = SDD_MANAGED_AGENT_IDS
23
+ } = {}) {
24
+ const managed = [...managedIds];
25
+ if (requestedIds == null) {
26
+ const detected = new Set(detectedIds);
27
+ return managed.filter((id) => detected.has(id));
28
+ }
29
+ if (requestedIds.length === 1 && requestedIds[0] === "all") return [...managed];
30
+ return requestedIds.map((id) => {
31
+ if (!managed.includes(id)) {
32
+ throw new Error(`Agent "${id}" is not managed for SDD skills. Use: ${managed.join(", ")}.`);
33
+ }
34
+ return id;
35
+ });
36
+ }
37
+
38
+ export function resolveSddSkillRoot(agentId, homeDir) {
39
+ if (SDD_SHARED_AGENT_IDS.includes(agentId)) {
40
+ return join(homeDir, ".agents", "skills");
41
+ }
42
+ if (agentId === "claude") {
43
+ return join(homeDir, ".claude", "skills");
44
+ }
45
+ throw new Error(`Agent "${agentId}" has no SDD skill destination.`);
46
+ }
47
+
48
+ export function resolveSddSkillPath(skillId, agentId, homeDir) {
49
+ return join(resolveSddSkillRoot(agentId, homeDir), skillId, "SKILL.md");
50
+ }
51
+
52
+ /** One physical destination per root, with stable consumer agentIds. */
53
+ export function groupSddSkillDestinations(agentIds, homeDir) {
54
+ const selected = new Set(agentIds);
55
+ const groups = [];
56
+
57
+ const sharedConsumers = SDD_SHARED_AGENT_IDS.filter((id) => selected.has(id));
58
+ if (sharedConsumers.length > 0) {
59
+ groups.push({
60
+ kind: "shared",
61
+ root: join(homeDir, ".agents", "skills"),
62
+ agentIds: sharedConsumers
63
+ });
64
+ }
65
+
66
+ if (selected.has("claude")) {
67
+ groups.push({
68
+ kind: "claude",
69
+ root: join(homeDir, ".claude", "skills"),
70
+ agentIds: ["claude"]
71
+ });
72
+ }
73
+
74
+ return groups;
75
+ }
76
+
77
+ export function resolveCanonicalSddSkillDir(skillId, packageRoot) {
78
+ return join(packageRoot, "global-template", "components", "sdd-core", "skills", skillId);
79
+ }
80
+
81
+ export function resolveCanonicalSddSkillPath(skillId, packageRoot) {
82
+ return join(resolveCanonicalSddSkillDir(skillId, packageRoot), "SKILL.md");
83
+ }
84
+
85
+ export function resolveCanonicalSddSkillFile(skillId, relativePath, packageRoot) {
86
+ const parts = String(relativePath ?? "SKILL.md").split(/[/\\]/).filter(Boolean);
87
+ if (parts.length === 0 || parts.includes("..")) {
88
+ throw new Error(`Invalid skill relativePath "${relativePath}".`);
89
+ }
90
+ return join(resolveCanonicalSddSkillDir(skillId, packageRoot), ...parts);
91
+ }
92
+
93
+ export function resolveCanonicalTeachingPersonaPath(packageRoot) {
94
+ return join(packageRoot, "global-template", "components", "sdd-core", "personas", "teaching.md");
95
+ }
@@ -0,0 +1,89 @@
1
+ export const SDD_PLAN_ACTIONS = Object.freeze({
2
+ CREATE: "create",
3
+ NOOP: "noop",
4
+ UPDATE: "update",
5
+ CONFLICT: "conflict"
6
+ });
7
+
8
+ /** Per-file receipt outcomes after apply (Slice 2 evidence). */
9
+ export const SDD_FILE_OUTCOMES = Object.freeze({
10
+ APPLIED: "applied",
11
+ NOOP: "noop",
12
+ CONFLICT: "conflict",
13
+ FAILED: "failed",
14
+ SKIPPED: "skipped"
15
+ });
16
+
17
+ export const SDD_HEALTH = Object.freeze({
18
+ CONFIGURED: "configured",
19
+ MISSING: "missing",
20
+ DRIFTED: "drifted",
21
+ CONFLICT: "conflict"
22
+ });
23
+
24
+ /**
25
+ * Classify one destination file against canonical bytes and optional tracked hash.
26
+ * Untracked or user-modified files are conflicts and must never be overwritten.
27
+ */
28
+ export function classifySddSkillFile({
29
+ exists,
30
+ canonicalHash,
31
+ diskHash = null,
32
+ trackedHash = null
33
+ } = {}) {
34
+ if (!exists) {
35
+ return {
36
+ action: SDD_PLAN_ACTIONS.CREATE,
37
+ reason: "Destination missing; physical copy planned."
38
+ };
39
+ }
40
+
41
+ if (trackedHash == null) {
42
+ return {
43
+ action: SDD_PLAN_ACTIONS.CONFLICT,
44
+ reason: "Pre-existing untracked file; preserving byte-for-byte."
45
+ };
46
+ }
47
+
48
+ if (trackedHash !== diskHash) {
49
+ return {
50
+ action: SDD_PLAN_ACTIONS.CONFLICT,
51
+ reason: "User-modified after materialization; preserving byte-for-byte."
52
+ };
53
+ }
54
+
55
+ if (diskHash === canonicalHash) {
56
+ return {
57
+ action: SDD_PLAN_ACTIONS.NOOP,
58
+ reason: "Already matches canonical skill bytes."
59
+ };
60
+ }
61
+
62
+ return {
63
+ action: SDD_PLAN_ACTIONS.UPDATE,
64
+ reason: "Managed file drifted from canonical skill bytes."
65
+ };
66
+ }
67
+
68
+ /** Map plan classification to verify health, separating canonical vs disk drift. */
69
+ export function classifySddVerifyHealth({
70
+ exists,
71
+ canonicalHash,
72
+ diskHash = null,
73
+ trackedHash = null
74
+ } = {}) {
75
+ if (!exists) {
76
+ return { status: SDD_HEALTH.MISSING, drift: null, reason: "Destination missing on disk." };
77
+ }
78
+ if (trackedHash == null) {
79
+ return { status: SDD_HEALTH.CONFLICT, drift: null, reason: "Pre-existing untracked file." };
80
+ }
81
+ if (trackedHash !== diskHash) {
82
+ return { status: SDD_HEALTH.CONFLICT, drift: "disk", reason: "Disk drifted from tracked hash." };
83
+ }
84
+ if (diskHash !== canonicalHash) {
85
+ return { status: SDD_HEALTH.DRIFTED, drift: "canonical", reason: "Managed bytes drifted from canonical." };
86
+ }
87
+ return { status: SDD_HEALTH.CONFIGURED, drift: null, reason: "Matches tracked and canonical bytes." };
88
+ }
89
+
@@ -0,0 +1,183 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants as fsConstants, existsSync } from "node:fs";
3
+ import { link, lstat, open, realpath, rename, rm } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { hashBuffer } from "../../hash.js";
6
+ import { isPathInside } from "../component-paths.js";
7
+ import { SDD_SKILL_IDS, resolveSddSkillRoot } from "./sdd-destinations.js";
8
+
9
+ /** Threat model: local CLI accidental TOCTOU/symlinks — not a malicious same-uid peer. */
10
+ const PLATFORM_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
11
+ export let openNoFollowFlag = PLATFORM_NOFOLLOW;
12
+ let beforeFinalGateForTests = null, afterTombRenameForTests = null;
13
+ export function setOpenNoFollowFlagForTests(flag) { openNoFollowFlag = flag; }
14
+ export function setBeforeFinalGateForTests(fn) { beforeFinalGateForTests = fn; }
15
+ export function setAfterTombRenameForTests(fn) { afterTombRenameForTests = fn; }
16
+ export function resetOpenNoFollowFlagForTests() {
17
+ openNoFollowFlag = PLATFORM_NOFOLLOW; beforeFinalGateForTests = null; afterTombRenameForTests = null;
18
+ }
19
+ export function resolveExpectedSddDestination(file, homeDir) {
20
+ if (!SDD_SKILL_IDS.includes(file?.skillId)) {
21
+ return { ok: false, reason: `Unknown skillId "${file?.skillId}".` };
22
+ }
23
+ const agentId = file?.agentIds?.[0];
24
+ if (!agentId) return { ok: false, reason: "Receipt file missing agentIds." };
25
+ const relativePath = String(file?.relativePath ?? "SKILL.md");
26
+ const parts = relativePath.split(/[/\\]/).filter(Boolean);
27
+ if (parts.length === 0 || parts.includes("..")) {
28
+ return { ok: false, reason: "Receipt relativePath is invalid." };
29
+ }
30
+ const managedRoot = resolve(resolveSddSkillRoot(agentId, homeDir));
31
+ const expectedPath = resolve(join(managedRoot, file.skillId, ...parts));
32
+ const claimedPath = resolve(String(file.destinationPath ?? ""));
33
+ if (claimedPath !== expectedPath) {
34
+ return { ok: false, reason: "Receipt destinationPath does not match managed skill path." };
35
+ }
36
+ return { ok: true, path: expectedPath, managedRoot };
37
+ }
38
+ export function assertBackupPathContained(backupPath, backupDir) {
39
+ const expectedDir = resolve(backupDir);
40
+ const claimed = resolve(String(backupPath ?? ""));
41
+ if (!isPathInside(expectedDir, claimed) && claimed !== expectedDir) {
42
+ return { ok: false, reason: "Backup path escapes receipt backup directory." };
43
+ }
44
+ return { ok: true, path: claimed };
45
+ }
46
+ export async function assertSafePathChain(targetPath, managedRoot, trustedAnchor = null) {
47
+ const root = resolve(managedRoot);
48
+ const target = resolve(targetPath);
49
+ const anchor = resolve(trustedAnchor ?? root);
50
+ if (!isPathInside(root, target) && target !== root) {
51
+ return { ok: false, reason: "Path escapes managed root." };
52
+ }
53
+ for (let current = target; ; current = dirname(current)) {
54
+ if (existsSync(current) && (await lstat(current)).isSymbolicLink()) {
55
+ return { ok: false, reason: current === root ? "Managed root is a symlink; refusing." : `Symlink in path chain at ${current}; refusing.` };
56
+ }
57
+ if (current === anchor) break;
58
+ if (dirname(current) === current) return { ok: false, reason: "Path chain never reached trusted anchor." };
59
+ }
60
+ return { ok: true, path: target, managedRoot: root };
61
+ }
62
+ export async function parentRealpath(path) {
63
+ const parent = dirname(resolve(path));
64
+ return existsSync(parent) ? realpath(parent) : resolve(parent);
65
+ }
66
+ export async function refuseSymlink(path, label = "path") {
67
+ if (!existsSync(path)) return null;
68
+ if ((await lstat(path)).isSymbolicLink()) return `${label} is a symlink; refusing.`;
69
+ return null;
70
+ }
71
+ async function assertMutableTarget(path, { managedRoot = null, expectedParentRealpath = null, trustedAnchor = null } = {}) {
72
+ if (managedRoot) { const c = await assertSafePathChain(path, managedRoot, trustedAnchor); if (!c.ok) throw new Error(c.reason); }
73
+ if (expectedParentRealpath != null && (await parentRealpath(path)) !== expectedParentRealpath) {
74
+ throw new Error("Parent realpath changed before write; refusing.");
75
+ }
76
+ }
77
+ export async function openNoFollow(path, flags) {
78
+ let beforeIno = null;
79
+ if (existsSync(path)) {
80
+ const stats = await lstat(path);
81
+ if (stats.isSymbolicLink()) throw new Error("Path is a symlink; refusing.");
82
+ beforeIno = stats.ino;
83
+ }
84
+ const handle = await open(path, flags | openNoFollowFlag);
85
+ try {
86
+ const after = await lstat(path);
87
+ if (after.isSymbolicLink()) throw new Error("Path became a symlink during open; refusing.");
88
+ if (beforeIno != null && (after.ino !== beforeIno || (await handle.stat()).ino !== beforeIno)) {
89
+ throw new Error("Path changed during open; refusing.");
90
+ }
91
+ } catch (error) { await handle.close(); throw error; }
92
+ return handle;
93
+ }
94
+ export async function snapshotRegularFile(path) {
95
+ const handle = await openNoFollow(path, fsConstants.O_RDONLY);
96
+ try {
97
+ const stats = await handle.stat();
98
+ if (!stats.isFile()) throw new Error("Path is not a regular file.");
99
+ const bytes = await handle.readFile();
100
+ const again = await handle.stat();
101
+ if (!again.isFile() || again.ino !== stats.ino || again.size !== stats.size) {
102
+ throw new Error("File changed during read; refusing.");
103
+ }
104
+ return { bytes, hash: hashBuffer(bytes), ino: stats.ino, size: stats.size };
105
+ } finally {
106
+ await handle.close();
107
+ }
108
+ }
109
+ export async function readRegularFile(path) {
110
+ return (await snapshotRegularFile(path)).bytes;
111
+ }
112
+ export async function hashRegularFile(path) {
113
+ return (await snapshotRegularFile(path)).hash;
114
+ }
115
+ export async function replaceRegularFile(destinationPath, bytes, {
116
+ expectedIno = null, expectedHash = null, createExclusive = false,
117
+ managedRoot = null, expectedParentRealpath = null, trustedAnchor = null
118
+ } = {}) {
119
+ const symlink = await refuseSymlink(destinationPath, "Destination");
120
+ if (symlink) throw new Error(symlink);
121
+ const tempPath = join(dirname(destinationPath), `.sdd-write-${randomBytes(16).toString("hex")}.tmp`);
122
+ const handle = await open(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
123
+ try { await handle.writeFile(bytes); await handle.sync(); }
124
+ finally { await handle.close(); }
125
+ const gate = { managedRoot, expectedParentRealpath, trustedAnchor };
126
+ try {
127
+ if (createExclusive) {
128
+ await assertMutableTarget(destinationPath, gate);
129
+ if (existsSync(destinationPath)) throw new Error("Destination appeared before create; refusing overwrite.");
130
+ await link(tempPath, destinationPath);
131
+ await rm(tempPath, { force: true });
132
+ return;
133
+ }
134
+ if (expectedIno != null || expectedHash != null) {
135
+ const current = await snapshotRegularFile(destinationPath);
136
+ if (expectedIno != null && current.ino !== expectedIno) throw new Error("Destination inode changed before write; refusing.");
137
+ if (expectedHash != null && current.hash !== expectedHash) throw new Error("Destination hash changed before write; refusing.");
138
+ }
139
+ if (beforeFinalGateForTests) await beforeFinalGateForTests(destinationPath);
140
+ await assertMutableTarget(destinationPath, gate);
141
+ await rename(tempPath, destinationPath);
142
+ } catch (error) {
143
+ await rm(tempPath, { force: true });
144
+ throw error;
145
+ }
146
+ }
147
+ async function restoreTombExclusive(path, tomb) {
148
+ try { await link(tomb, path); await rm(tomb, { force: true }); return { restored: true }; }
149
+ catch (error) { return { restored: false, tombPath: tomb, reason: error.message }; }
150
+ }
151
+ export async function deleteRegularFileIfHash(path, expectedHash, {
152
+ managedRoot = null, expectedParentRealpath = null, trustedAnchor = null
153
+ } = {}) {
154
+ const symlink = await refuseSymlink(path, "Path");
155
+ if (symlink) return { ok: false, reason: symlink };
156
+ let snap;
157
+ try { snap = await snapshotRegularFile(path); }
158
+ catch (error) { return { ok: false, reason: error.message }; }
159
+ if (snap.hash !== expectedHash) {
160
+ return { ok: true, skipped: true, reason: "Created file edited after apply; refusing delete." };
161
+ }
162
+ const tomb = join(dirname(path), `.sdd-del-${randomBytes(16).toString("hex")}.tmp`);
163
+ try {
164
+ await assertMutableTarget(path, { managedRoot, expectedParentRealpath, trustedAnchor });
165
+ await rename(path, tomb);
166
+ if (afterTombRenameForTests) await afterTombRenameForTests(path, tomb);
167
+ } catch (error) { return { ok: false, reason: error.message }; }
168
+ try {
169
+ const moved = await snapshotRegularFile(tomb);
170
+ if (moved.ino !== snap.ino || moved.hash !== expectedHash) {
171
+ const recovery = await restoreTombExclusive(path, tomb);
172
+ return { ok: false, tombPath: recovery.restored ? null : recovery.tombPath,
173
+ reason: recovery.restored ? "File swapped before delete; refusing."
174
+ : `File swapped before delete; tomb retained at ${recovery.tombPath}` };
175
+ }
176
+ await rm(tomb, { force: true });
177
+ return { ok: true, skipped: false };
178
+ } catch (error) {
179
+ const recovery = await restoreTombExclusive(path, tomb);
180
+ return { ok: false, tombPath: recovery.restored ? null : recovery.tombPath,
181
+ reason: recovery.restored ? error.message : `${error.message}; tomb retained at ${recovery.tombPath}` };
182
+ }
183
+ }
@@ -0,0 +1,84 @@
1
+ import { ensureIntegrationProvidersRegistered } from "./index.js";
2
+ import { requireIntegrationProvider } from "./provider-registry.js";
3
+ import { SDD_MANAGED_AGENT_IDS } from "./sdd-destinations.js";
4
+ import { normalizeSddState, recordSddMaterialization } from "./sdd-state.js";
5
+
6
+ function skipped(reason) {
7
+ return {
8
+ status: "skipped",
9
+ reason,
10
+ partial: false,
11
+ conflicts: [],
12
+ receipt: null,
13
+ sessionRefreshRequired: false,
14
+ applied: false,
15
+ cancelled: false,
16
+ dryRun: false
17
+ };
18
+ }
19
+
20
+ /** Lifecycle materialization via sdd-core.apply; persona frozen; no second consent prompt. */
21
+ export async function applySddLifecycle({
22
+ homeDir,
23
+ packageRoot,
24
+ agentIds = [],
25
+ componentIds = [],
26
+ dryRun = false,
27
+ priorSdd = null,
28
+ yes = true
29
+ } = {}) {
30
+ if (!componentIds.includes("sdd-core")) return skipped("sdd-core not selected");
31
+ const managed = agentIds.filter((id) => SDD_MANAGED_AGENT_IDS.includes(id));
32
+ if (!managed.length) return skipped("no managed target agents");
33
+
34
+ ensureIntegrationProvidersRegistered();
35
+ const provider = requireIntegrationProvider("sdd-core");
36
+ const sdd = normalizeSddState(priorSdd);
37
+ const trackedFiles = Object.fromEntries(sdd.files.map((file) => [file.destinationPath, file.hash]));
38
+ const result = await provider.apply({
39
+ requestedAgentIds: managed,
40
+ detectedAgentIds: managed,
41
+ homeDir,
42
+ packageRoot,
43
+ persona: sdd.persona,
44
+ personaAgentIds: sdd.personaAgentIds,
45
+ preservePersona: true,
46
+ trackedFiles,
47
+ dryRun,
48
+ yes: dryRun ? false : yes,
49
+ json: true
50
+ });
51
+
52
+ const receipt = result.receipt ?? null;
53
+ return {
54
+ status: dryRun
55
+ ? "planned"
56
+ : (receipt?.partial ? "partial" : (result.applied ? "applied" : (receipt ? "failed" : "skipped"))),
57
+ reason: result.cancelled ? "cancelled" : undefined,
58
+ partial: Boolean(receipt?.partial),
59
+ conflicts: result.conflicts ?? receipt?.conflicts ?? [],
60
+ receipt,
61
+ sessionRefreshRequired: Boolean(result.sessionRefreshRequired),
62
+ summary: result.summary ?? null,
63
+ persona: result.persona ?? sdd.persona,
64
+ personaAgentIds: result.personaTransition?.after ?? sdd.personaAgentIds,
65
+ applied: Boolean(result.applied),
66
+ cancelled: Boolean(result.cancelled),
67
+ dryRun: Boolean(dryRun)
68
+ };
69
+ }
70
+
71
+ /** Persist receipt into SDD state only for ok/partial; otherwise keep prior block. */
72
+ export function nextSddState(priorSdd, lifecycle) {
73
+ const prior = normalizeSddState(priorSdd);
74
+ if (!lifecycle?.receipt || lifecycle.dryRun) return prior;
75
+ if (!lifecycle.receipt.ok && !lifecycle.receipt.partial) return prior;
76
+ return recordSddMaterialization({ sdd: prior }, { receipt: lifecycle.receipt }).sdd;
77
+ }
78
+
79
+ export function aggregateSessionRefreshRequired(result, sdd) {
80
+ const managedChanged = (result.configsCreated?.length ?? 0)
81
+ + (result.configsUpdated?.length ?? 0)
82
+ + (result.configsRepaired?.length ?? 0) > 0;
83
+ return Boolean(sdd?.sessionRefreshRequired) || managedChanged;
84
+ }