agent-trellis 0.14.0 → 0.15.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/dist/adapters/claude-code.js +4 -3
- package/dist/adapters/codex.js +4 -3
- package/dist/adapters/kimi-code.js +4 -3
- package/dist/adapters/kiro.js +5 -4
- package/dist/adapters/pi.js +4 -3
- package/dist/adapters/piMcp.d.ts +6 -3
- package/dist/adapters/piMcp.js +10 -3
- package/dist/adapters/zcode.d.ts +12 -1
- package/dist/adapters/zcode.js +7 -6
- package/dist/cli.js +38 -5
- package/dist/commands/doctor.d.ts +9 -1
- package/dist/commands/doctor.js +43 -6
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +24 -0
- package/dist/commands/mcp.d.ts +44 -1
- package/dist/commands/mcp.js +145 -33
- package/dist/commands/migrate.d.ts +12 -0
- package/dist/commands/migrate.js +8 -4
- package/dist/commands/migrateWorkspace.d.ts +40 -0
- package/dist/commands/migrateWorkspace.js +197 -0
- package/dist/commands/rollback.js +65 -35
- package/dist/commands/skill.d.ts +14 -0
- package/dist/commands/skill.js +71 -0
- package/dist/commands/sync.d.ts +16 -1
- package/dist/commands/sync.js +48 -15
- package/dist/core/adapter.d.ts +14 -0
- package/dist/core/canonical.d.ts +20 -32
- package/dist/core/canonical.js +18 -86
- package/dist/core/mcpServerDef.d.ts +39 -0
- package/dist/core/mcpServerDef.js +93 -0
- package/dist/core/types.d.ts +43 -4
- package/dist/lib/mcpMigrateRead.d.ts +25 -0
- package/dist/lib/mcpMigrateRead.js +9 -2
- package/dist/lib/skillProvider.js +18 -5
- package/dist/lib/workspace.d.ts +39 -0
- package/dist/lib/workspace.js +158 -0
- package/dist/lib/workspaceConfig.d.ts +45 -0
- package/dist/lib/workspaceConfig.js +98 -0
- package/dist/lib/workspaceDoctor.d.ts +44 -0
- package/dist/lib/workspaceDoctor.js +94 -0
- package/dist/lib/workspaceFiles.d.ts +38 -0
- package/dist/lib/workspaceFiles.js +264 -0
- package/dist/lib/workspaceMcp.d.ts +57 -0
- package/dist/lib/workspaceMcp.js +277 -0
- package/dist/lib/workspaceMerge.d.ts +37 -0
- package/dist/lib/workspaceMerge.js +98 -0
- package/dist/lib/workspaceMigrate.d.ts +118 -0
- package/dist/lib/workspaceMigrate.js +423 -0
- package/dist/pi-bridge/bundle.js +262 -96
- package/docs/architecture.md +111 -49
- package/docs/getting-started.md +102 -14
- package/docs/mcp-oauth-matrix.md +12 -1
- package/docs/overview.zh-CN.md +17 -0
- package/docs/research.md +28 -6
- package/docs/roadmap.md +17 -5
- package/package.json +1 -1
- package/docs/implementation-plan.md +0 -341
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis migrate --workspace` — absorbs a repo's existing project-level
|
|
3
|
+
* agent config (all six agents' MCP files + root AGENTS.md) into
|
|
4
|
+
* `.agents/`, then re-renders (trellis-workspace-scope group 4).
|
|
5
|
+
*
|
|
6
|
+
* Flow: plan (pure, `src/lib/workspaceMigrate.ts`) → refuse on unresolved
|
|
7
|
+
* conflicts → one backup session named `workspace-<repo-hash>` covering
|
|
8
|
+
* every write (task 4.6 — `.agents/` writes, the secret-store extraction,
|
|
9
|
+
* the marker-headed AGENTS.md rewrite, and the cascade render, so a
|
|
10
|
+
* single `trellis rollback` restores originals and removes generated
|
|
11
|
+
* `.agents/` content) → post-migration equivalence report (task 4.7).
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { mkdirSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { ALL_AGENTS } from "../core/types.js";
|
|
18
|
+
import { ensureGitignoreEntry, loadCanonicalSource, writeSecretsPolicyExtraction } from "../core/canonical.js";
|
|
19
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
20
|
+
import { writeLocalSecretValue } from "../lib/secretEnv.js";
|
|
21
|
+
import { findWorkspaceRoot } from "../lib/workspace.js";
|
|
22
|
+
import { upsertWorkspaceServer } from "../lib/workspaceConfig.js";
|
|
23
|
+
import { INSTRUCTIONS_MARKER } from "../lib/workspaceFiles.js";
|
|
24
|
+
import { recordWorkspaceRendered, renderWorkspaceManifestEntry } from "../lib/workspaceMcp.js";
|
|
25
|
+
import { collectWorkspaceMigratePlan, readWorkspaceAgentMcpDefsForApply } from "../lib/workspaceMigrate.js";
|
|
26
|
+
import { collectMcpSyncReport } from "./mcp.js";
|
|
27
|
+
import { collectSyncReport } from "./sync.js";
|
|
28
|
+
import { defaultLocalSecretsEnvFilePath, DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE } from "./migrate.js";
|
|
29
|
+
/** Task 4.6's `<repo-hash>`: stable per checkout path, short enough to
|
|
30
|
+
* stay readable in a backups listing. */
|
|
31
|
+
function repoHash(workspaceRoot) {
|
|
32
|
+
return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Applies one plan item. MCP items go through `upsertWorkspaceServer`
|
|
36
|
+
* (which serializes the snake_case shape and fills `version`); the
|
|
37
|
+
* extract-secret companion write mirrors the global migrate's
|
|
38
|
+
* `applyStaticEnvExtraction` — real value re-read from the SOURCE file at
|
|
39
|
+
* apply time, never carried on the plan (D9). Instructions import writes
|
|
40
|
+
* `.agents/AGENTS.md` and rewrites the root file marker-headed in place
|
|
41
|
+
* (a `file-overwrite` op — rollback restores the original unmarked file).
|
|
42
|
+
*/
|
|
43
|
+
function applyWorkspaceMigrateItem(item, plan, homeDir, backup) {
|
|
44
|
+
if (item.kind === "mcp" && (item.action === "create" || item.action === "extract-secret") && item.mcpDef) {
|
|
45
|
+
upsertWorkspaceServer(plan.workspaceRoot, item.name, item.mcpDef, backup);
|
|
46
|
+
// Adopt the source entries: every agent file this name came from gets
|
|
47
|
+
// a manifest ownership record, so the cascade render owns the entry
|
|
48
|
+
// (bringing it to Trellis's canonical render) instead of conflict-
|
|
49
|
+
// guarding on it as user-authored content. Without this the migration
|
|
50
|
+
// would import into .agents/ and then immediately conflict on the
|
|
51
|
+
// very files it absorbed.
|
|
52
|
+
for (const agent of item.sources) {
|
|
53
|
+
recordWorkspaceRendered(plan.workspaceRoot, agent, item.name, renderWorkspaceManifestEntry(agent, item.name, item.mcpDef), backup);
|
|
54
|
+
}
|
|
55
|
+
if (item.action === "extract-secret" && item.extractVarName && item.extractTargetPath && item.extractSourceKey && item.extractSourceAgent) {
|
|
56
|
+
const realValue = readWorkspaceAgentMcpDefsForApply(plan.workspaceRoot, item.extractSourceAgent)
|
|
57
|
+
.entries.find((e) => e.name === item.name)?.def.staticEnv?.[item.extractSourceKey];
|
|
58
|
+
if (realValue !== undefined) {
|
|
59
|
+
if (item.extractTargetPath === defaultLocalSecretsEnvFilePath(homeDir)) {
|
|
60
|
+
ensureGitignoreEntry(join(homeDir, ".trellis", ".gitignore"), "mcp/servers.local.env", backup);
|
|
61
|
+
}
|
|
62
|
+
writeLocalSecretValue(item.extractTargetPath, item.extractVarName, realValue, backup);
|
|
63
|
+
const policyResult = writeSecretsPolicyExtraction(join(homeDir, ".trellis", "secrets.policy.yaml"), { varName: item.extractVarName, envFilePath: DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE }, backup);
|
|
64
|
+
// A failed policy write leaves the extracted var unresolvable —
|
|
65
|
+
// the cascade render then refuses with a confusing conflict. Say
|
|
66
|
+
// why, plainly, while the run is still honest about it.
|
|
67
|
+
if (!policyResult.ok) {
|
|
68
|
+
console.error(`warning: extracted ${item.extractVarName} but could not record it in secrets.policy.yaml: ${policyResult.error}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// undefined = the source changed between plan and apply; the safe
|
|
72
|
+
// def (name-only reference) is still imported, matching the global
|
|
73
|
+
// migrate's "nothing left to extract" behavior.
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (item.kind === "instructions" && item.action === "create" && item.sourceContent !== undefined) {
|
|
78
|
+
const target = join(plan.workspaceRoot, ".agents", "AGENTS.md");
|
|
79
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
80
|
+
backup.writeFile(target, item.sourceContent);
|
|
81
|
+
// Rewrite the root file marker-headed NOW so the cascade instructions
|
|
82
|
+
// sync sees current === rendered (no-op) instead of conflicting on an
|
|
83
|
+
// unmarked file. Byte-identical to planWorkspaceInstructions' render.
|
|
84
|
+
backup.writeFile(join(plan.workspaceRoot, "AGENTS.md"), `${INSTRUCTIONS_MARKER}\n\n${item.sourceContent}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function runMigrateWorkspace(opts = {}) {
|
|
88
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
89
|
+
const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd()) ?? process.cwd();
|
|
90
|
+
// --- flag validation (raw strings arrive from cli.ts) ---
|
|
91
|
+
const choose = {};
|
|
92
|
+
for (const raw of opts.choose ?? []) {
|
|
93
|
+
const eq = raw.indexOf("=");
|
|
94
|
+
const agent = eq === -1 ? "" : raw.slice(eq + 1);
|
|
95
|
+
if (eq === -1 || !ALL_AGENTS.includes(agent)) {
|
|
96
|
+
console.error(`--choose must be name=agentId with agentId one of: ${ALL_AGENTS.join(", ")} (got "${raw}")`);
|
|
97
|
+
return { exitCode: 1 };
|
|
98
|
+
}
|
|
99
|
+
choose[raw.slice(0, eq)] = agent;
|
|
100
|
+
}
|
|
101
|
+
if (opts.dedup !== undefined && opts.dedup !== "drop" && opts.dedup !== "override") {
|
|
102
|
+
console.error(`--dedup must be drop or override (got ${opts.dedup})`);
|
|
103
|
+
return { exitCode: 1 };
|
|
104
|
+
}
|
|
105
|
+
const secretsPolicy = loadCanonicalSource({ homeDir, workspaceDir: null }).secretsPolicy;
|
|
106
|
+
const plan = collectWorkspaceMigratePlan({ workspaceRoot, homeDir, secretsPolicy, choose, dedup: opts.dedup });
|
|
107
|
+
// Task 4.3/4.2: a real run with ANY unresolved conflict writes nothing —
|
|
108
|
+
// the user resolves (via --choose, or by fixing the source) and re-runs.
|
|
109
|
+
const conflicts = plan.items.filter((i) => i.action === "conflict");
|
|
110
|
+
if (conflicts.length > 0 && !opts.dryRun) {
|
|
111
|
+
printWorkspaceMigratePlan(plan, false);
|
|
112
|
+
console.error(`\n${conflicts.length} unresolved conflict(s) — nothing was written. Resolve each and re-run.`);
|
|
113
|
+
return { exitCode: 1 };
|
|
114
|
+
}
|
|
115
|
+
// Task 4.7's "before" half: what each agent's project config held.
|
|
116
|
+
const beforeByAgent = new Map();
|
|
117
|
+
for (const item of plan.items) {
|
|
118
|
+
if (item.kind !== "mcp" || item.action === "skip-local")
|
|
119
|
+
continue;
|
|
120
|
+
for (const agent of item.sources) {
|
|
121
|
+
beforeByAgent.set(agent, [...new Set([...(beforeByAgent.get(agent) ?? []), item.name])]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let equivalence;
|
|
125
|
+
if (!opts.dryRun) {
|
|
126
|
+
const actionable = plan.items.filter((i) => i.action === "create" || i.action === "extract-secret");
|
|
127
|
+
const backup = openBackupSession(homeDir, `workspace-${repoHash(workspaceRoot)}`);
|
|
128
|
+
for (const item of actionable) {
|
|
129
|
+
applyWorkspaceMigrateItem(item, plan, homeDir, backup);
|
|
130
|
+
}
|
|
131
|
+
// Cascade render through the SAME session (task 4.6): MCP artifacts
|
|
132
|
+
// for every managed agent, so one `trellis rollback` undoes the whole
|
|
133
|
+
// migration. Instructions only cascade when an instructions item was
|
|
134
|
+
// actually applied — scoping the side effects to what migration
|
|
135
|
+
// touched, same discipline as `mcp add --workspace`'s MCP-only
|
|
136
|
+
// cascade.
|
|
137
|
+
await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, backupSession: backup });
|
|
138
|
+
if (actionable.some((i) => i.kind === "instructions")) {
|
|
139
|
+
await collectSyncReport({ target: "instructions", homeDir, workspaceDir: workspaceRoot, backupSession: backup });
|
|
140
|
+
}
|
|
141
|
+
backup.finalize();
|
|
142
|
+
// Task 4.7's "after" half: a fresh dry-run render against the
|
|
143
|
+
// migrated `.agents/` proves the absorbed config now delivers.
|
|
144
|
+
const after = await collectMcpSyncReport({ homeDir, workspaceDir: workspaceRoot, dryRun: true });
|
|
145
|
+
equivalence = {
|
|
146
|
+
agents: after.reports
|
|
147
|
+
.filter((r) => r.present)
|
|
148
|
+
.map((r) => ({
|
|
149
|
+
agent: r.agent,
|
|
150
|
+
before: (beforeByAgent.get(r.agent) ?? []).sort(),
|
|
151
|
+
after: r.items.filter((i) => i.kind === "mcp" && i.layer === "workspace" && i.mcpWrite).map((i) => i.mcpWrite.name).sort(),
|
|
152
|
+
}))
|
|
153
|
+
.filter((a) => a.before.length > 0 || a.after.length > 0),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (opts.json) {
|
|
157
|
+
console.log(JSON.stringify({ ...plan, ...(equivalence ? { equivalence } : {}) }, null, 2));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
printWorkspaceMigratePlan(plan, opts.dryRun ?? false);
|
|
161
|
+
if (equivalence)
|
|
162
|
+
printEquivalence(equivalence);
|
|
163
|
+
}
|
|
164
|
+
return { exitCode: conflicts.length > 0 ? 1 : 0 };
|
|
165
|
+
}
|
|
166
|
+
export function printWorkspaceMigratePlan(plan, dryRun) {
|
|
167
|
+
console.log(`${dryRun ? "[dry run] " : ""}migrate --workspace ${plan.workspaceRoot}`);
|
|
168
|
+
if (plan.items.length === 0) {
|
|
169
|
+
console.log(" nothing to migrate");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (const item of plan.items) {
|
|
173
|
+
const label = item.kind === "mcp" ? `mcp server "${item.name}"` : "instructions (AGENTS.md)";
|
|
174
|
+
console.log(` [${item.action}] ${label} — ${item.detail}`);
|
|
175
|
+
if (item.defs) {
|
|
176
|
+
for (const [agent, def] of Object.entries(item.defs)) {
|
|
177
|
+
console.log(` ${agent}: ${JSON.stringify(def)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (item.rotationAdvisory) {
|
|
181
|
+
console.log(` ⚠ ${item.rotationAdvisory}`);
|
|
182
|
+
}
|
|
183
|
+
if (item.remediation) {
|
|
184
|
+
console.log(` → ${item.remediation}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function printEquivalence(equivalence) {
|
|
189
|
+
console.log("equivalence (before → rendered after):");
|
|
190
|
+
if (equivalence.agents.length === 0) {
|
|
191
|
+
console.log(" no managed agent renders workspace servers on this machine");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const { agent, before, after } of equivalence.agents) {
|
|
195
|
+
console.log(` ${agent}: [${before.join(", ") || "—"}] → [${after.join(", ") || "—"}]`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* already hold themselves to for every other kind of conflict.
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { cpSync, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { cpSync, existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { readlink, rm, symlink } from "node:fs/promises";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
@@ -63,84 +63,110 @@ async function currentLinkTarget(path) {
|
|
|
63
63
|
return undefined;
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
-
|
|
66
|
+
/** Probes the path's real on-disk state once, then caches it in `sim`;
|
|
67
|
+
* later ops for the same path see the simulated rewinds, not the disk. */
|
|
68
|
+
async function simStateFor(path, sim) {
|
|
69
|
+
const cached = sim.get(path);
|
|
70
|
+
if (cached)
|
|
71
|
+
return cached;
|
|
72
|
+
let state;
|
|
73
|
+
// readlink first: it detects symlinks (even dangling ones) that
|
|
74
|
+
// existsSync would miss or misclassify.
|
|
75
|
+
const link = await currentLinkTarget(path);
|
|
76
|
+
if (link !== undefined) {
|
|
77
|
+
state = { kind: "symlink", target: link };
|
|
78
|
+
}
|
|
79
|
+
else if (!existsSync(path)) {
|
|
80
|
+
state = { kind: "absent" };
|
|
81
|
+
}
|
|
82
|
+
else if (statSync(path).isDirectory()) {
|
|
83
|
+
let digest;
|
|
84
|
+
try {
|
|
85
|
+
digest = directoryDigest(path);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
digest = undefined;
|
|
89
|
+
}
|
|
90
|
+
state = { kind: "dir", digest };
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
state = { kind: "file", hash: sha256(readFileSync(path, "utf-8")) };
|
|
94
|
+
}
|
|
95
|
+
sim.set(path, state);
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
async function planOperation(homeDir, runId, op, sim) {
|
|
67
99
|
const runDir = join(backupsRoot(homeDir), runId);
|
|
100
|
+
const state = await simStateFor(op.path, sim);
|
|
68
101
|
switch (op.kind) {
|
|
69
102
|
case "file-create":
|
|
70
103
|
case "file-overwrite": {
|
|
71
|
-
if (
|
|
104
|
+
if (state.kind === "absent") {
|
|
72
105
|
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
73
106
|
}
|
|
74
|
-
|
|
75
|
-
if (currentHash !== op.afterHash) {
|
|
107
|
+
if (state.kind !== "file" || state.hash !== op.afterHash) {
|
|
76
108
|
return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
|
|
77
109
|
}
|
|
78
110
|
if (op.kind === "file-create") {
|
|
111
|
+
sim.set(op.path, { kind: "absent" });
|
|
79
112
|
return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
|
|
80
113
|
}
|
|
114
|
+
sim.set(op.path, { kind: "file", hash: op.beforeHash });
|
|
81
115
|
return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeFile)})` };
|
|
82
116
|
}
|
|
83
117
|
case "symlink-create":
|
|
84
118
|
case "symlink-repair": {
|
|
85
|
-
|
|
86
|
-
if (current === undefined) {
|
|
119
|
+
if (state.kind === "absent") {
|
|
87
120
|
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
88
121
|
}
|
|
89
|
-
if (
|
|
122
|
+
if (state.kind !== "symlink" || state.target !== op.afterLinkTarget) {
|
|
90
123
|
return { action: "conflict", path: op.path, description: `${op.path} points somewhere else now — left untouched` };
|
|
91
124
|
}
|
|
92
125
|
if (op.kind === "symlink-create") {
|
|
126
|
+
sim.set(op.path, { kind: "absent" });
|
|
93
127
|
return { action: "restore", path: op.path, description: `remove ${op.path} (created by this run)` };
|
|
94
128
|
}
|
|
129
|
+
sim.set(op.path, { kind: "symlink", target: op.beforeLinkTarget });
|
|
95
130
|
return { action: "restore", path: op.path, description: `repoint ${op.path} back to ${op.beforeLinkTarget}` };
|
|
96
131
|
}
|
|
97
132
|
case "symlink-remove": {
|
|
98
|
-
if (
|
|
133
|
+
if (state.kind !== "absent") {
|
|
99
134
|
return { action: "conflict", path: op.path, description: `${op.path} exists again since this run — left untouched` };
|
|
100
135
|
}
|
|
136
|
+
sim.set(op.path, { kind: "symlink", target: op.beforeLinkTarget });
|
|
101
137
|
return { action: "restore", path: op.path, description: `recreate ${op.path} -> ${op.beforeLinkTarget}` };
|
|
102
138
|
}
|
|
103
139
|
// Historical `dir-remove` operations remain existence-only because they
|
|
104
140
|
// predate directory manifests. New directory creates/replacements carry a
|
|
105
141
|
// deterministic digest, so remote Skill rollback refuses an edited tree.
|
|
106
142
|
case "dir-create": {
|
|
107
|
-
if (
|
|
143
|
+
if (state.kind === "absent") {
|
|
108
144
|
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
109
145
|
}
|
|
110
|
-
if (op.afterDigest !== undefined) {
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
digest = directoryDigest(op.path);
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return { action: "conflict", path: op.path, description: `${op.path} can no longer be read safely — left untouched` };
|
|
117
|
-
}
|
|
118
|
-
if (digest !== op.afterDigest) {
|
|
119
|
-
return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
|
|
120
|
-
}
|
|
146
|
+
if (op.afterDigest !== undefined && (state.kind !== "dir" || state.digest !== op.afterDigest)) {
|
|
147
|
+
return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
|
|
121
148
|
}
|
|
149
|
+
sim.set(op.path, { kind: "absent" });
|
|
122
150
|
return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
|
|
123
151
|
}
|
|
124
152
|
case "dir-remove": {
|
|
125
|
-
if (
|
|
153
|
+
if (state.kind !== "absent") {
|
|
126
154
|
return { action: "conflict", path: op.path, description: `${op.path} exists again since this run — left untouched` };
|
|
127
155
|
}
|
|
156
|
+
// The rewind's content comes from the beforeDir snapshot at apply
|
|
157
|
+
// time; its digest isn't recorded, so mark it unknown for any
|
|
158
|
+
// (in practice nonexistent) earlier op on the same path.
|
|
159
|
+
sim.set(op.path, { kind: "dir", digest: undefined });
|
|
128
160
|
return { action: "restore", path: op.path, description: `recreate ${op.path} from its content before this run (${join(runDir, op.beforeDir)})` };
|
|
129
161
|
}
|
|
130
162
|
case "dir-replace": {
|
|
131
|
-
if (
|
|
163
|
+
if (state.kind === "absent") {
|
|
132
164
|
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
133
165
|
}
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
digest = directoryDigest(op.path);
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
return { action: "conflict", path: op.path, description: `${op.path} can no longer be read safely — left untouched` };
|
|
140
|
-
}
|
|
141
|
-
if (digest !== op.afterDigest) {
|
|
166
|
+
if (state.kind !== "dir" || state.digest !== op.afterDigest) {
|
|
142
167
|
return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
|
|
143
168
|
}
|
|
169
|
+
sim.set(op.path, { kind: "dir", digest: undefined });
|
|
144
170
|
return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeDir)})` };
|
|
145
171
|
}
|
|
146
172
|
}
|
|
@@ -149,11 +175,15 @@ export async function collectRollbackPlan(homeDirInput, runIdInput) {
|
|
|
149
175
|
const homeDir = homeDirInput ?? homedir();
|
|
150
176
|
const runId = resolveRunId(homeDir, runIdInput);
|
|
151
177
|
const manifest = loadManifest(homeDir, runId);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
178
|
+
// Plan newest-first against the simulated undo, then report in the
|
|
179
|
+
// manifest's original chronological order.
|
|
180
|
+
const sim = new Map();
|
|
181
|
+
const planned = [];
|
|
182
|
+
for (const op of [...manifest.operations].reverse()) {
|
|
183
|
+
planned.push(await planOperation(homeDir, runId, op, sim));
|
|
155
184
|
}
|
|
156
|
-
|
|
185
|
+
planned.reverse();
|
|
186
|
+
return { runId, items: planned };
|
|
157
187
|
}
|
|
158
188
|
async function restoreOperation(homeDir, runId, op) {
|
|
159
189
|
const runDir = join(backupsRoot(homeDir), runId);
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -49,6 +49,18 @@ export interface SkillAddPlan {
|
|
|
49
49
|
}
|
|
50
50
|
export declare function collectSkillAddPlan(name: string, fromPath: string, homeDir?: string): SkillAddPlan;
|
|
51
51
|
export declare function applySkillAddPlan(plan: SkillAddPlan, homeDir?: string, backup?: BackupSession): void;
|
|
52
|
+
/** The workspace twin of `collectSkillAddPlan` (trellis-workspace-scope
|
|
53
|
+
* task 3.4): same source validation, destination is `.agents/skills/`
|
|
54
|
+
* instead of the global canonical skills dir. */
|
|
55
|
+
export declare function collectSkillAddWorkspacePlan(name: string, fromPath: string, workspaceRoot: string): SkillAddPlan;
|
|
56
|
+
export declare function applySkillAddWorkspacePlan(plan: SkillAddPlan, workspaceRoot: string, backup?: BackupSession): void;
|
|
57
|
+
/** Same own-backup-session + cascade-sync posture as
|
|
58
|
+
* `applySkillAddWithSync`, scoped to the workspace layer. */
|
|
59
|
+
export declare function applySkillAddWorkspaceWithSync(plan: SkillAddPlan, workspaceRoot: string, opts?: {
|
|
60
|
+
homeDir?: string;
|
|
61
|
+
dryRun?: boolean;
|
|
62
|
+
backupSession?: BackupSession;
|
|
63
|
+
}): Promise<SkillAddOutcome>;
|
|
52
64
|
export interface SkillAddOutcome {
|
|
53
65
|
plan: SkillAddPlan;
|
|
54
66
|
sync?: SyncReport;
|
|
@@ -70,6 +82,8 @@ export declare function runSkillAdd(name: string, fromPath: string, opts?: {
|
|
|
70
82
|
homeDir?: string;
|
|
71
83
|
json?: boolean;
|
|
72
84
|
dryRun?: boolean;
|
|
85
|
+
workspace?: boolean;
|
|
86
|
+
workspaceDir?: string;
|
|
73
87
|
}): Promise<{
|
|
74
88
|
exitCode: number;
|
|
75
89
|
}>;
|
package/dist/commands/skill.js
CHANGED
|
@@ -21,6 +21,7 @@ import { openBackupSession } from "../lib/backup.js";
|
|
|
21
21
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
22
22
|
import { remoteSkillProvenance } from "./remoteSkill.js";
|
|
23
23
|
import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
|
|
24
|
+
import { findWorkspaceRoot } from "../lib/workspace.js";
|
|
24
25
|
function builtinSkillTemplate() {
|
|
25
26
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
|
|
@@ -114,6 +115,55 @@ export function applySkillAddPlan(plan, homeDir = homedir(), backup) {
|
|
|
114
115
|
mkdirSync(dest, { recursive: true });
|
|
115
116
|
cpSync(plan.sourceDir, dest, { recursive: true });
|
|
116
117
|
}
|
|
118
|
+
/** The workspace twin of `collectSkillAddPlan` (trellis-workspace-scope
|
|
119
|
+
* task 3.4): same source validation, destination is `.agents/skills/`
|
|
120
|
+
* instead of the global canonical skills dir. */
|
|
121
|
+
export function collectSkillAddWorkspacePlan(name, fromPath, workspaceRoot) {
|
|
122
|
+
const skillFile = findSkillFile(fromPath);
|
|
123
|
+
if (!skillFile) {
|
|
124
|
+
return { name, action: "invalid-source", detail: `${fromPath} has no SKILL.md` };
|
|
125
|
+
}
|
|
126
|
+
if (!skillFile.caseCorrect) {
|
|
127
|
+
return { name, action: "invalid-source", detail: `${fromPath} has skill.md, not case-correct SKILL.md — fix the case first` };
|
|
128
|
+
}
|
|
129
|
+
const dest = join(workspaceRoot, ".agents", "skills", name);
|
|
130
|
+
switch (decideDirImport(fromPath, dest)) {
|
|
131
|
+
case "create":
|
|
132
|
+
return { name, action: "create", detail: `will copy from ${fromPath} into .agents/skills/`, sourceDir: fromPath };
|
|
133
|
+
case "already-present":
|
|
134
|
+
return { name, action: "already-present", detail: ".agents/skills/ content is already byte-identical" };
|
|
135
|
+
case "conflict":
|
|
136
|
+
return { name, action: "conflict", detail: `.agents/skills/${name}/ already exists with different content — resolve by hand` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function applySkillAddWorkspacePlan(plan, workspaceRoot, backup) {
|
|
140
|
+
if (plan.action !== "create" || !plan.sourceDir)
|
|
141
|
+
return;
|
|
142
|
+
const dest = join(workspaceRoot, ".agents", "skills", plan.name);
|
|
143
|
+
if (backup) {
|
|
144
|
+
backup.createDirFromSource(dest, plan.sourceDir);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
mkdirSync(dest, { recursive: true });
|
|
148
|
+
cpSync(plan.sourceDir, dest, { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
/** Same own-backup-session + cascade-sync posture as
|
|
151
|
+
* `applySkillAddWithSync`, scoped to the workspace layer. */
|
|
152
|
+
export async function applySkillAddWorkspaceWithSync(plan, workspaceRoot, opts = {}) {
|
|
153
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
154
|
+
const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "create" ? openBackupSession(homeDir, "skill-add") : undefined;
|
|
155
|
+
const session = opts.backupSession ?? ownSession;
|
|
156
|
+
if (!opts.dryRun && plan.action === "create") {
|
|
157
|
+
applySkillAddWorkspacePlan(plan, workspaceRoot, session);
|
|
158
|
+
}
|
|
159
|
+
ownSession?.finalize();
|
|
160
|
+
const failed = plan.action === "conflict" || plan.action === "invalid-source";
|
|
161
|
+
let syncReport;
|
|
162
|
+
if (!failed && plan.action === "create" && existsSync(join(homeDir, ".trellis"))) {
|
|
163
|
+
syncReport = await collectSyncReport({ target: "skills", homeDir, workspaceDir: workspaceRoot, dryRun: opts.dryRun });
|
|
164
|
+
}
|
|
165
|
+
return { plan, ...(syncReport ? { sync: syncReport } : {}) };
|
|
166
|
+
}
|
|
117
167
|
/**
|
|
118
168
|
* See `applyMcpAddWithSync`'s doc comment (mcp.ts) — same split, same
|
|
119
169
|
* reason: a non-CLI caller needs the structured result a JSON-mode CLI
|
|
@@ -139,6 +189,27 @@ export async function applySkillAddWithSync(plan, opts = {}) {
|
|
|
139
189
|
}
|
|
140
190
|
export async function runSkillAdd(name, fromPath, opts = {}) {
|
|
141
191
|
const homeDir = opts.homeDir ?? homedir();
|
|
192
|
+
if (opts.workspace) {
|
|
193
|
+
const workspaceRoot = opts.workspaceDir ?? findWorkspaceRoot(process.cwd());
|
|
194
|
+
if (!workspaceRoot) {
|
|
195
|
+
console.error("no .agents/ found here or above — run `trellis init --workspace` first (or pass --workspace from inside the repo)");
|
|
196
|
+
return { exitCode: 1 };
|
|
197
|
+
}
|
|
198
|
+
const plan = collectSkillAddWorkspacePlan(name, fromPath, workspaceRoot);
|
|
199
|
+
const { sync: syncReport } = await applySkillAddWorkspaceWithSync(plan, workspaceRoot, { homeDir, dryRun: opts.dryRun });
|
|
200
|
+
const failed = plan.action === "conflict" || plan.action === "invalid-source";
|
|
201
|
+
if (opts.json) {
|
|
202
|
+
console.log(JSON.stringify(syncReport ? { ...plan, workspace: workspaceRoot, sync: syncReport } : { ...plan, workspace: workspaceRoot }, null, 2));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}skill add ${name} --workspace`);
|
|
206
|
+
console.log(` [${plan.action}] ${plan.detail}`);
|
|
207
|
+
if (syncReport)
|
|
208
|
+
printSyncReport(syncReport, opts.dryRun ?? false);
|
|
209
|
+
}
|
|
210
|
+
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
211
|
+
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
212
|
+
}
|
|
142
213
|
const plan = collectSkillAddPlan(name, fromPath, homeDir);
|
|
143
214
|
const { sync: syncReport } = await applySkillAddWithSync(plan, { homeDir, dryRun: opts.dryRun });
|
|
144
215
|
const failed = plan.action === "conflict" || plan.action === "invalid-source";
|
package/dist/commands/sync.d.ts
CHANGED
|
@@ -34,14 +34,29 @@ export interface RunSyncOptions {
|
|
|
34
34
|
* does NOT finalize it — only whoever opened it does. Never a CLI
|
|
35
35
|
* flag — standalone `trellis sync` always opens and finalizes its own. */
|
|
36
36
|
backupSession?: BackupSession;
|
|
37
|
+
/** Force global-only behavior even inside a workspace (`--global-only`). */
|
|
38
|
+
globalOnly?: boolean;
|
|
39
|
+
/** Explicit workspace root — the test/sandbox seam. CLI discovery walks
|
|
40
|
+
* up from cwd via `findWorkspaceRoot`; this overrides it (null = none). */
|
|
41
|
+
workspaceDir?: string | null;
|
|
42
|
+
/** `--check`: compute the plan, write nothing, exit non-zero when any
|
|
43
|
+
* rendered artifact is stale or missing (CI/pre-commit freshness gate,
|
|
44
|
+
* trellis-workspace-scope design D7). Implies dry-run. */
|
|
45
|
+
check?: boolean;
|
|
37
46
|
}
|
|
38
47
|
export interface AgentSyncReport {
|
|
39
48
|
agent: AgentId;
|
|
40
49
|
present: boolean;
|
|
41
50
|
items: AdapterPlanItem[];
|
|
51
|
+
/** zcode's reverse-precedence notices (workspace shadowing that zcode
|
|
52
|
+
* resolves user-level-first) — warnings, not conflicts: the write still
|
|
53
|
+
* happens. */
|
|
54
|
+
warnings?: string[];
|
|
42
55
|
}
|
|
43
56
|
export interface SyncReport {
|
|
44
57
|
reports: AgentSyncReport[];
|
|
58
|
+
/** Workspace root when a `.agents/` layer participated in this run. */
|
|
59
|
+
workspaceRoot?: string;
|
|
45
60
|
}
|
|
46
61
|
export declare function collectSyncReport(opts?: RunSyncOptions): Promise<SyncReport>;
|
|
47
62
|
export declare function runSync(opts?: RunSyncOptions): Promise<{
|
|
@@ -50,4 +65,4 @@ export declare function runSync(opts?: RunSyncOptions): Promise<{
|
|
|
50
65
|
/** Exported so `onboard` prints a sync report identically to running
|
|
51
66
|
* `sync` standalone, instead of a second, easily-drifting copy of this
|
|
52
67
|
* formatting. */
|
|
53
|
-
export declare function printReport(report: SyncReport, dryRun: boolean): void;
|
|
68
|
+
export declare function printReport(report: SyncReport, dryRun: boolean, check?: boolean): void;
|
package/dist/commands/sync.js
CHANGED
|
@@ -14,6 +14,9 @@ import { PiAdapter } from "../adapters/pi.js";
|
|
|
14
14
|
import { KimiCodeAdapter } from "../adapters/kimi-code.js";
|
|
15
15
|
import { ZcodeAdapter } from "../adapters/zcode.js";
|
|
16
16
|
import { openBackupSession } from "../lib/backup.js";
|
|
17
|
+
import { findWorkspaceRoot } from "../lib/workspace.js";
|
|
18
|
+
import { applyWorkspaceFiles, planWorkspaceFiles } from "../lib/workspaceFiles.js";
|
|
19
|
+
import { workspacePrecedenceWarnings } from "../lib/workspaceMcp.js";
|
|
17
20
|
const ADAPTER_FACTORY = {
|
|
18
21
|
"claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
|
|
19
22
|
codex: (homeDir) => new CodexAdapter(homeDir),
|
|
@@ -30,7 +33,11 @@ function buildAdapters(homeDir, managedAgents) {
|
|
|
30
33
|
}
|
|
31
34
|
export async function collectSyncReport(opts = {}) {
|
|
32
35
|
const homeDir = opts.homeDir ?? homedir();
|
|
33
|
-
|
|
36
|
+
// Workspace participation is automatic inside a repo carrying `.agents/`
|
|
37
|
+
// and revocable via --global-only; outside any workspace (or when it
|
|
38
|
+
// resolves null) the load is byte-identical to before.
|
|
39
|
+
const workspaceDir = opts.globalOnly ? null : (opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd()));
|
|
40
|
+
const loaded = loadCanonicalSource({ homeDir, workspaceDir });
|
|
34
41
|
// A single override point, not two: every downstream read of "who's
|
|
35
42
|
// managed" — the adapter list built here AND each adapter's own
|
|
36
43
|
// in-scope filtering via `canonical.managedAgents` — must agree, or a
|
|
@@ -39,9 +46,10 @@ export async function collectSyncReport(opts = {}) {
|
|
|
39
46
|
const canonical = opts.managedAgents ? { ...loaded, managedAgents: opts.managedAgents } : loaded;
|
|
40
47
|
const reports = [];
|
|
41
48
|
// Own session only when the caller didn't share one (trellis-backup-
|
|
42
|
-
// rollback D2) — dry-run never opens or writes one, there's
|
|
43
|
-
// record.
|
|
44
|
-
const
|
|
49
|
+
// rollback D2) — dry-run/check never opens or writes one, there's
|
|
50
|
+
// nothing to record.
|
|
51
|
+
const writeMode = !opts.dryRun && !opts.check;
|
|
52
|
+
const ownSession = writeMode && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
|
|
45
53
|
const backup = opts.backupSession ?? ownSession;
|
|
46
54
|
for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
|
|
47
55
|
const probeResult = await adapter.probe();
|
|
@@ -53,6 +61,7 @@ export async function collectSyncReport(opts = {}) {
|
|
|
53
61
|
// `trellis sync` never touches MCP, that's `trellis mcp sync`'s own
|
|
54
62
|
// command, so exclude it unconditionally before any target filter.
|
|
55
63
|
let items = (await adapter.plan(canonical)).filter((item) => item.kind !== "mcp");
|
|
64
|
+
let wsItems = planWorkspaceFiles(adapter.id, canonical);
|
|
56
65
|
if (opts.target) {
|
|
57
66
|
// CLI/option vocabulary is plural ("skills"/"instructions",
|
|
58
67
|
// matching `trellis sync skills`); AdapterPlanItem.kind is singular
|
|
@@ -63,14 +72,18 @@ export async function collectSyncReport(opts = {}) {
|
|
|
63
72
|
// sync" even with real create items pending).
|
|
64
73
|
const kind = opts.target === "skills" ? "skill" : "instructions";
|
|
65
74
|
items = items.filter((item) => item.kind === kind || (kind === "skill" && item.kind === "zcode-skill-control"));
|
|
75
|
+
wsItems = wsItems.filter((item) => item.kind === kind);
|
|
66
76
|
}
|
|
67
|
-
|
|
77
|
+
const allItems = [...items, ...wsItems];
|
|
78
|
+
if (writeMode) {
|
|
68
79
|
await adapter.apply(items, backup);
|
|
80
|
+
applyWorkspaceFiles(adapter.id, canonical, wsItems, backup);
|
|
69
81
|
}
|
|
70
|
-
|
|
82
|
+
const warnings = workspacePrecedenceWarnings(adapter.id, canonical);
|
|
83
|
+
reports.push({ agent: adapter.id, present: true, items: allItems, ...(warnings.length > 0 ? { warnings } : {}) });
|
|
71
84
|
}
|
|
72
85
|
ownSession?.finalize();
|
|
73
|
-
return { reports };
|
|
86
|
+
return { reports, ...(canonical.workspace ? { workspaceRoot: canonical.workspace.root } : {}) };
|
|
74
87
|
}
|
|
75
88
|
export async function runSync(opts = {}) {
|
|
76
89
|
let report;
|
|
@@ -85,22 +98,34 @@ export async function runSync(opts = {}) {
|
|
|
85
98
|
console.log(JSON.stringify(report, null, 2));
|
|
86
99
|
}
|
|
87
100
|
else {
|
|
88
|
-
printReport(report, opts.dryRun ?? false);
|
|
101
|
+
printReport(report, opts.dryRun ?? false, opts.check ?? false);
|
|
89
102
|
}
|
|
90
103
|
const hasConflict = report.reports.some((r) => r.items.some((i) => i.action === "conflict"));
|
|
104
|
+
if (opts.check) {
|
|
105
|
+
// Freshness gate: any pending write (create/remove) or conflict means
|
|
106
|
+
// the rendered artifacts are stale — exit non-zero, having written
|
|
107
|
+
// nothing.
|
|
108
|
+
const hasPending = report.reports.some((r) => r.items.some((i) => i.action !== "conflict"));
|
|
109
|
+
return { exitCode: hasPending || hasConflict ? 1 : 0 };
|
|
110
|
+
}
|
|
91
111
|
return { exitCode: hasConflict ? 1 : 0 };
|
|
92
112
|
}
|
|
93
113
|
/** Exported so `onboard` prints a sync report identically to running
|
|
94
114
|
* `sync` standalone, instead of a second, easily-drifting copy of this
|
|
95
115
|
* formatting. */
|
|
96
|
-
export function printReport(report, dryRun) {
|
|
116
|
+
export function printReport(report, dryRun, check = false) {
|
|
97
117
|
if (dryRun)
|
|
98
118
|
console.log("[dry run]");
|
|
119
|
+
if (check)
|
|
120
|
+
console.log("[check]");
|
|
121
|
+
if (report.workspaceRoot) {
|
|
122
|
+
console.log(`Workspace layer: ${report.workspaceRoot} (.agents/)`);
|
|
123
|
+
}
|
|
99
124
|
if (report.reports.length === 0) {
|
|
100
125
|
console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
|
|
101
126
|
return;
|
|
102
127
|
}
|
|
103
|
-
for (const { agent, present, items } of report.reports) {
|
|
128
|
+
for (const { agent, present, items, warnings } of report.reports) {
|
|
104
129
|
if (!present) {
|
|
105
130
|
console.log(`— ${agent} (not installed)`);
|
|
106
131
|
continue;
|
|
@@ -110,12 +135,20 @@ export function printReport(report, dryRun) {
|
|
|
110
135
|
const conflicts = items.filter((i) => i.action === "conflict");
|
|
111
136
|
if (created.length === 0 && removed.length === 0 && conflicts.length === 0) {
|
|
112
137
|
console.log(`✅ ${agent} — already in sync`);
|
|
113
|
-
continue;
|
|
114
138
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
139
|
+
else {
|
|
140
|
+
const icon = conflicts.length > 0 ? "⚠️ " : "✅";
|
|
141
|
+
console.log(`${icon} ${agent} — ${created.length} created, ${removed.length} removed, ${conflicts.length} conflict(s)`);
|
|
142
|
+
for (const item of [...created, ...removed, ...conflicts]) {
|
|
143
|
+
console.log(` - [${item.action}]${item.layer === "workspace" ? " (workspace)" : ""} ${item.description}`);
|
|
144
|
+
// Same as mcp.ts's printer: a conflict's remediation IS the fix —
|
|
145
|
+
// never leave the user at "left untouched" with no next step.
|
|
146
|
+
if (item.remediation)
|
|
147
|
+
console.log(` → ${item.remediation}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const warning of warnings ?? []) {
|
|
151
|
+
console.log(` ⚠ ${warning}`);
|
|
119
152
|
}
|
|
120
153
|
}
|
|
121
154
|
}
|