agent-trellis 0.16.0 → 0.17.1
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/cli.js +2 -1
- package/dist/commands/doctor.d.ts +6 -1
- package/dist/commands/doctor.js +10 -0
- package/dist/commands/mcpAuth.d.ts +2 -0
- package/dist/commands/mcpAuth.js +25 -6
- package/dist/lib/cliEntrypoints.d.ts +23 -0
- package/dist/lib/cliEntrypoints.js +79 -0
- package/dist/lib/oauth/refresh.d.ts +2 -0
- package/dist/lib/oauth/refresh.js +17 -2
- package/dist/lib/oauth/renew.js +7 -1
- package/dist/pi-bridge/bundle.js +14 -2
- package/docs/mcp-oauth-renewal.md +10 -0
- package/docs/roadmap.md +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -172,7 +172,8 @@ Commands:
|
|
|
172
172
|
Authorize a remote (http/sse) MCP server that uses OAuth —
|
|
173
173
|
opens a browser once, stores the result 0600 under
|
|
174
174
|
~/.trellis/mcp/oauth/. A still-valid token is a no-op; an
|
|
175
|
-
expired one is refreshed without a browser.
|
|
175
|
+
expired one is refreshed without a browser. If the refresh
|
|
176
|
+
grant is rejected, this command starts a new login. The gateway
|
|
176
177
|
refreshes silently on its own and never prompts.
|
|
177
178
|
--force re-authorize even if the stored token is valid
|
|
178
179
|
--json machine-readable output, no report text
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
8
8
|
import type { AgentId, AgentSnapshot, CanonicalSource } from "../core/types.js";
|
|
9
|
+
import { type VersionExecutor } from "../lib/cliEntrypoints.js";
|
|
9
10
|
import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
|
|
10
11
|
/**
|
|
11
12
|
* Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
|
|
@@ -15,7 +16,7 @@ import { type WorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
|
|
|
15
16
|
* machine.
|
|
16
17
|
*/
|
|
17
18
|
export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
|
|
18
|
-
export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable" | "workspace-toml-invalid";
|
|
19
|
+
export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable" | "workspace-toml-invalid" | "cli-version-split";
|
|
19
20
|
export interface Finding {
|
|
20
21
|
kind: FindingKind;
|
|
21
22
|
/** Omitted for a finding that names more than one agent (drift). */
|
|
@@ -52,6 +53,10 @@ export interface RunDoctorOptions {
|
|
|
52
53
|
* seam. CLI discovery walks up from cwd via `findWorkspaceRoot`;
|
|
53
54
|
* null = no workspace layer. */
|
|
54
55
|
workspaceDir?: string | null;
|
|
56
|
+
/** PATH to scan for `trellis` entrypoints; defaults to the real one.
|
|
57
|
+
* Test seam, with `execVersion`. */
|
|
58
|
+
pathEnv?: string;
|
|
59
|
+
execVersion?: VersionExecutor;
|
|
55
60
|
}
|
|
56
61
|
export interface OAuthHealthEntry {
|
|
57
62
|
server: string;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -17,6 +17,7 @@ import { ALL_AGENTS, isOAuthAuth, oauthOwner } from "../core/types.js";
|
|
|
17
17
|
import { classifyStoredToken, remediationFor } from "../lib/oauth/renew.js";
|
|
18
18
|
import { readToken } from "../lib/oauth/store.js";
|
|
19
19
|
import { findWorkspaceRoot } from "../lib/workspace.js";
|
|
20
|
+
import { detectCliVersionSplit, findCliEntrypoints } from "../lib/cliEntrypoints.js";
|
|
20
21
|
import { collectWorkspaceDoctorSection } from "../lib/workspaceDoctor.js";
|
|
21
22
|
/**
|
|
22
23
|
* Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
|
|
@@ -48,6 +49,9 @@ const EXIT_NONZERO_KINDS = new Set([
|
|
|
48
49
|
// *agent*'s own startup regardless of ownership — catch it here before
|
|
49
50
|
// the next session does (trellis-doctor-workspace-toml-validation).
|
|
50
51
|
"workspace-toml-invalid",
|
|
52
|
+
// A stale `trellis` that wins on PATH silently changes behavior (e.g.
|
|
53
|
+
// rejecting config keys a newer copy accepts) — trellis-doctor-cli-version-split.
|
|
54
|
+
"cli-version-split",
|
|
51
55
|
]);
|
|
52
56
|
/** Exported so tests can assert on this resolution directly, without
|
|
53
57
|
* routing through real per-agent probes `runDoctor` also runs. */
|
|
@@ -66,6 +70,12 @@ export function resolveKnownHostInjected(opts) {
|
|
|
66
70
|
export async function runDoctor(opts = {}) {
|
|
67
71
|
const workspaceDir = opts.workspaceDir !== undefined ? opts.workspaceDir : findWorkspaceRoot(process.cwd());
|
|
68
72
|
const report = await collectDoctorReport(opts.homeDir, resolveKnownHostInjected(opts), opts.probeMcp, workspaceDir);
|
|
73
|
+
// CLI boundary only: spawning `trellis --version` belongs here, not in
|
|
74
|
+
// collectDoctorReport, so library callers and their tests never depend
|
|
75
|
+
// on the developer's real PATH.
|
|
76
|
+
const split = detectCliVersionSplit(findCliEntrypoints({ pathEnv: opts.pathEnv, execVersion: opts.execVersion }));
|
|
77
|
+
if (split)
|
|
78
|
+
report.findings.push({ kind: "cli-version-split", message: split });
|
|
69
79
|
const exitCode = report.findings.some((f) => EXIT_NONZERO_KINDS.has(f.kind)) ? 1 : 0;
|
|
70
80
|
if (opts.json) {
|
|
71
81
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -19,6 +19,8 @@ export interface RunMcpAuthOptions {
|
|
|
19
19
|
signal?: AbortSignal;
|
|
20
20
|
onProgress?: (phase: "discovering" | "registering" | "waiting" | "exchanging" | "saving") => void;
|
|
21
21
|
timeoutMs?: number;
|
|
22
|
+
/** An expired grant was rejected; the explicit auth command will log in again. */
|
|
23
|
+
onReauthorization?: () => void;
|
|
22
24
|
/** Used by the desktop; native grants belong to each Agent. */
|
|
23
25
|
requireHosted?: boolean;
|
|
24
26
|
}
|
package/dist/commands/mcpAuth.js
CHANGED
|
@@ -12,7 +12,8 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
13
13
|
import { discoverAuthorizationServer, parseResourceMetadataUrl } from "../lib/oauth/discovery.js";
|
|
14
14
|
import { authorize, abortable } from "../lib/oauth/flow.js";
|
|
15
|
-
import { ensureFreshToken } from "../lib/oauth/refresh.js";
|
|
15
|
+
import { ensureFreshToken, RefreshError } from "../lib/oauth/refresh.js";
|
|
16
|
+
import { classifyRefreshFailure } from "../lib/oauth/renew.js";
|
|
16
17
|
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
17
18
|
import { isExpired, readToken, tokenPath, writeToken } from "../lib/oauth/store.js";
|
|
18
19
|
import { withServerLock } from "../lib/oauth/lock.js";
|
|
@@ -21,7 +22,14 @@ export async function runMcpAuth(opts) {
|
|
|
21
22
|
const homeDir = opts.homeDir ?? homedir();
|
|
22
23
|
let result;
|
|
23
24
|
try {
|
|
24
|
-
result = await authorizeMcpServer(
|
|
25
|
+
result = await authorizeMcpServer({
|
|
26
|
+
...opts,
|
|
27
|
+
onReauthorization: () => {
|
|
28
|
+
if (!opts.json)
|
|
29
|
+
console.error(`OAuth grant for "${opts.serverName}" can no longer be refreshed; starting a new login.`);
|
|
30
|
+
opts.onReauthorization?.();
|
|
31
|
+
},
|
|
32
|
+
});
|
|
25
33
|
}
|
|
26
34
|
catch (err) {
|
|
27
35
|
console.error(err instanceof Error ? err.message : String(err));
|
|
@@ -72,10 +80,21 @@ export async function authorizeMcpServer(opts) {
|
|
|
72
80
|
return { server: opts.serverName, status: "already-valid", tokenFile: file, ...(existing.expiresAt ? { expiresAt: existing.expiresAt } : {}) };
|
|
73
81
|
}
|
|
74
82
|
if (existing?.refreshToken && !opts.requireHosted) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
try {
|
|
84
|
+
// Expired but renewable — no browser needed.
|
|
85
|
+
const refreshed = await ensureFreshToken(homeDir, opts.serverName);
|
|
86
|
+
if (refreshed) {
|
|
87
|
+
return { server: opts.serverName, status: "refreshed", tokenFile: file, ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (!(err instanceof RefreshError) || classifyRefreshFailure(err) !== "needs-auth")
|
|
92
|
+
throw err;
|
|
93
|
+
opts.signal?.throwIfAborted();
|
|
94
|
+
// This is an explicit authorization request, so a dead grant can
|
|
95
|
+
// continue into login. Keep the old file until the new grant is
|
|
96
|
+
// saved; cancellation must not erase the prior credential.
|
|
97
|
+
opts.onReauthorization?.();
|
|
79
98
|
}
|
|
80
99
|
}
|
|
81
100
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locates every `trellis` executable on PATH and compares their versions
|
|
3
|
+
* (trellis-doctor-cli-version-split). Read-only: it never edits, removes
|
|
4
|
+
* or upgrades an entrypoint — a wrapper script may be deliberately
|
|
5
|
+
* maintained by the user, so Trellis only reports.
|
|
6
|
+
*/
|
|
7
|
+
export interface CliEntrypoint {
|
|
8
|
+
path: string;
|
|
9
|
+
version: string;
|
|
10
|
+
}
|
|
11
|
+
export type VersionExecutor = (path: string) => string | undefined;
|
|
12
|
+
/** Runs `<path> --version` via argv (no shell). `undefined` on any
|
|
13
|
+
* failure or unrecognizable output, so a broken entry is skipped rather
|
|
14
|
+
* than reported as a split. */
|
|
15
|
+
export declare const defaultExecVersion: VersionExecutor;
|
|
16
|
+
export declare function findCliEntrypoints(opts?: {
|
|
17
|
+
pathEnv?: string;
|
|
18
|
+
execVersion?: VersionExecutor;
|
|
19
|
+
name?: string;
|
|
20
|
+
}): CliEntrypoint[];
|
|
21
|
+
/** A finding message when two or more entries disagree; `undefined`
|
|
22
|
+
* otherwise. The first entry is the one a bare `trellis` runs. */
|
|
23
|
+
export declare function detectCliVersionSplit(entries: readonly CliEntrypoint[]): string | undefined;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locates every `trellis` executable on PATH and compares their versions
|
|
3
|
+
* (trellis-doctor-cli-version-split). Read-only: it never edits, removes
|
|
4
|
+
* or upgrades an entrypoint — a wrapper script may be deliberately
|
|
5
|
+
* maintained by the user, so Trellis only reports.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
9
|
+
import { delimiter, join } from "node:path";
|
|
10
|
+
const VERSION_RE = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/;
|
|
11
|
+
/** Runs `<path> --version` via argv (no shell). `undefined` on any
|
|
12
|
+
* failure or unrecognizable output, so a broken entry is skipped rather
|
|
13
|
+
* than reported as a split. */
|
|
14
|
+
export const defaultExecVersion = (path) => {
|
|
15
|
+
try {
|
|
16
|
+
const out = execFileSync(path, ["--version"], { encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] });
|
|
17
|
+
return VERSION_RE.exec(out)?.[0];
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function isExecutableFile(path) {
|
|
24
|
+
try {
|
|
25
|
+
if (!statSync(path).isFile())
|
|
26
|
+
return false;
|
|
27
|
+
accessSync(path, constants.X_OK);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function findCliEntrypoints(opts = {}) {
|
|
35
|
+
const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
|
|
36
|
+
const execVersion = opts.execVersion ?? defaultExecVersion;
|
|
37
|
+
const name = opts.name ?? "trellis";
|
|
38
|
+
const seenDirs = new Set();
|
|
39
|
+
const seenReal = new Set();
|
|
40
|
+
const entries = [];
|
|
41
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
42
|
+
if (!dir || seenDirs.has(dir))
|
|
43
|
+
continue;
|
|
44
|
+
seenDirs.add(dir);
|
|
45
|
+
const candidate = join(dir, name);
|
|
46
|
+
if (!isExecutableFile(candidate))
|
|
47
|
+
continue;
|
|
48
|
+
let real;
|
|
49
|
+
try {
|
|
50
|
+
real = realpathSync(candidate);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (seenReal.has(real))
|
|
56
|
+
continue;
|
|
57
|
+
seenReal.add(real);
|
|
58
|
+
// Spawn the PATH-visible path, not the resolved target: a shim's
|
|
59
|
+
// behavior depends on the name it is invoked by.
|
|
60
|
+
const version = execVersion(candidate);
|
|
61
|
+
if (version)
|
|
62
|
+
entries.push({ path: candidate, version });
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
/** A finding message when two or more entries disagree; `undefined`
|
|
67
|
+
* otherwise. The first entry is the one a bare `trellis` runs. */
|
|
68
|
+
export function detectCliVersionSplit(entries) {
|
|
69
|
+
if (entries.length < 2)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (new Set(entries.map((e) => e.version)).size < 2)
|
|
72
|
+
return undefined;
|
|
73
|
+
const lines = entries.map((e, i) => ` ${i === 0 ? "▶ runs" : " also"} ${e.path} ${e.version}`);
|
|
74
|
+
return [
|
|
75
|
+
`${entries.length} \`trellis\` entrypoints on PATH report different versions — a bare \`trellis\` runs ${entries[0].path} (${entries[0].version}), so upgrading another copy has no effect:`,
|
|
76
|
+
...lines,
|
|
77
|
+
"Reconcile them (upgrade or remove the stale one, or reorder PATH) — Trellis does not modify entrypoints.",
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
@@ -19,6 +19,8 @@ export interface RefreshOptions {
|
|
|
19
19
|
lockTimeoutMs?: number;
|
|
20
20
|
}
|
|
21
21
|
export declare class RefreshError extends Error {
|
|
22
|
+
readonly oauthError?: string | undefined;
|
|
23
|
+
constructor(message: string, oauthError?: string | undefined);
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* Returns a token that is valid now, refreshing first if it isn't.
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
import { isExpired, readToken, writeToken } from "./store.js";
|
|
12
12
|
import { withServerLock } from "./lock.js";
|
|
13
13
|
export class RefreshError extends Error {
|
|
14
|
+
oauthError;
|
|
15
|
+
constructor(message, oauthError) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.oauthError = oauthError;
|
|
18
|
+
}
|
|
14
19
|
}
|
|
15
20
|
/**
|
|
16
21
|
* Returns a token that is valid now, refreshing first if it isn't.
|
|
@@ -67,7 +72,17 @@ export async function performRefresh(serverName, token, opts = {}) {
|
|
|
67
72
|
body: body.toString(),
|
|
68
73
|
});
|
|
69
74
|
if (!response.ok) {
|
|
70
|
-
|
|
75
|
+
const detail = await safeText(response);
|
|
76
|
+
let oauthError;
|
|
77
|
+
try {
|
|
78
|
+
const payload = JSON.parse(detail);
|
|
79
|
+
if (typeof payload?.error === "string")
|
|
80
|
+
oauthError = payload.error;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Providers may return plain text or an empty body.
|
|
84
|
+
}
|
|
85
|
+
throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${detail.slice(0, 200)}`, oauthError);
|
|
71
86
|
}
|
|
72
87
|
const payload = (await response.json());
|
|
73
88
|
if (!payload.access_token) {
|
|
@@ -87,7 +102,7 @@ export async function performRefresh(serverName, token, opts = {}) {
|
|
|
87
102
|
}
|
|
88
103
|
async function safeText(response) {
|
|
89
104
|
try {
|
|
90
|
-
return
|
|
105
|
+
return await response.text();
|
|
91
106
|
}
|
|
92
107
|
catch {
|
|
93
108
|
return "<unreadable body>";
|
package/dist/lib/oauth/renew.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync } from "node:fs";
|
|
19
19
|
import { isExpired, readToken, tokenPath } from "./store.js";
|
|
20
|
+
import { RefreshError } from "./refresh.js";
|
|
20
21
|
/** Statuses that mean "a human step is required" — the non-zero-exit set
|
|
21
22
|
* (design.md D6). */
|
|
22
23
|
export const FAILURE_STATUSES = new Set([
|
|
@@ -77,8 +78,13 @@ export function hasRefreshToken(token) {
|
|
|
77
78
|
* varies by provider, so message sniffing stays deliberately narrow.
|
|
78
79
|
*/
|
|
79
80
|
export function classifyRefreshFailure(err) {
|
|
81
|
+
// A structured OAuth error takes precedence over the HTTP fallback:
|
|
82
|
+
// invalid_request also uses 400, but another login cannot fix it.
|
|
83
|
+
if (err instanceof RefreshError && err.oauthError !== undefined) {
|
|
84
|
+
return err.oauthError === "invalid_grant" ? "needs-auth" : "refresh-failed";
|
|
85
|
+
}
|
|
80
86
|
const message = err instanceof Error ? err.message : String(err);
|
|
81
|
-
if (/there is no refresh token/.test(message))
|
|
87
|
+
if (/there is no refresh token|no recorded token endpoint/.test(message))
|
|
82
88
|
return "needs-auth";
|
|
83
89
|
if (/invalid_grant|HTTP 400|HTTP 401/.test(message))
|
|
84
90
|
return "needs-auth";
|
package/dist/pi-bridge/bundle.js
CHANGED
|
@@ -46200,6 +46200,11 @@ async function withServerLock(homeDir, serverName, fn, opts = {}) {
|
|
|
46200
46200
|
|
|
46201
46201
|
// src/lib/oauth/refresh.ts
|
|
46202
46202
|
var RefreshError = class extends Error {
|
|
46203
|
+
constructor(message, oauthError) {
|
|
46204
|
+
super(message);
|
|
46205
|
+
this.oauthError = oauthError;
|
|
46206
|
+
}
|
|
46207
|
+
oauthError;
|
|
46203
46208
|
};
|
|
46204
46209
|
async function ensureFreshToken(homeDir, serverName, opts = {}) {
|
|
46205
46210
|
const now = opts.now ?? Date.now;
|
|
@@ -46242,7 +46247,14 @@ async function performRefresh(serverName, token, opts = {}) {
|
|
|
46242
46247
|
body: body.toString()
|
|
46243
46248
|
});
|
|
46244
46249
|
if (!response.ok) {
|
|
46245
|
-
|
|
46250
|
+
const detail = await safeText(response);
|
|
46251
|
+
let oauthError;
|
|
46252
|
+
try {
|
|
46253
|
+
const payload2 = JSON.parse(detail);
|
|
46254
|
+
if (typeof payload2?.error === "string") oauthError = payload2.error;
|
|
46255
|
+
} catch {
|
|
46256
|
+
}
|
|
46257
|
+
throw new RefreshError(`refresh_token grant for "${serverName}" failed with HTTP ${response.status}: ${detail.slice(0, 200)}`, oauthError);
|
|
46246
46258
|
}
|
|
46247
46259
|
const payload = await response.json();
|
|
46248
46260
|
if (!payload.access_token) {
|
|
@@ -46262,7 +46274,7 @@ async function performRefresh(serverName, token, opts = {}) {
|
|
|
46262
46274
|
}
|
|
46263
46275
|
async function safeText(response) {
|
|
46264
46276
|
try {
|
|
46265
|
-
return
|
|
46277
|
+
return await response.text();
|
|
46266
46278
|
} catch {
|
|
46267
46279
|
return "<unreadable body>";
|
|
46268
46280
|
}
|
|
@@ -6,6 +6,16 @@ on a schedule, it refreshes whatever is expiring and reports — with a
|
|
|
6
6
|
non-zero exit code — anything that needs a human, before an Agent session
|
|
7
7
|
discovers it as a 401.
|
|
8
8
|
|
|
9
|
+
When a refresh token has expired, been revoked, or been rotated out, the
|
|
10
|
+
provider can reject renewal with OAuth `invalid_grant`. Run
|
|
11
|
+
`trellis mcp auth <name>`: it attempts a silent refresh first, then starts
|
|
12
|
+
a new browser login if the grant is no longer usable. No `--force` is
|
|
13
|
+
required. Network failures, rate limits, and temporary provider failures
|
|
14
|
+
remain errors to retry; they do not trigger another login. The prior
|
|
15
|
+
credential is replaced only after the new authorization succeeds, so
|
|
16
|
+
cancelling login preserves the existing file. Background renewal and the
|
|
17
|
+
gateway report the failure and never open a browser.
|
|
18
|
+
|
|
9
19
|
```
|
|
10
20
|
trellis mcp renew # refresh what's expiring, report health
|
|
11
21
|
trellis mcp renew --dry-run # report only; no token-endpoint call
|
package/docs/roadmap.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Roadmap
|
|
2
2
|
|
|
3
|
-
*Current release: v0.
|
|
3
|
+
*Current release: v0.17.1 (2026-10-21). This file tracks phase-level scope;
|
|
4
4
|
per-release detail lives in [`releases/`](releases/).*
|
|
5
5
|
|
|
6
6
|
Each phase must ship with a `trellis doctor` check that verifies its own
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-trellis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Paul Leo",
|