@pennixrv/trellis 0.6.45 → 0.7.0-beta.5
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/index.d.ts.map +1 -1
- package/dist/cli/index.js +21 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/mem.d.ts.map +1 -1
- package/dist/commands/mem.js +6 -13
- package/dist/commands/mem.js.map +1 -1
- package/dist/commands/workflow.d.ts +17 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +222 -0
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/claude.d.ts.map +1 -1
- package/dist/configurators/claude.js.map +1 -1
- package/dist/configurators/dsh.d.ts +16 -19
- package/dist/configurators/dsh.d.ts.map +1 -1
- package/dist/configurators/dsh.js +38 -26
- package/dist/configurators/dsh.js.map +1 -1
- package/dist/configurators/gemini.d.ts.map +1 -1
- package/dist/configurators/gemini.js.map +1 -1
- package/dist/configurators/opencode.d.ts.map +1 -1
- package/dist/configurators/opencode.js +4 -0
- package/dist/configurators/opencode.js.map +1 -1
- package/dist/migrations/manifests/0.6.17.json +2 -2
- package/dist/migrations/manifests/0.7.0-beta.4.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.4.pennix.1.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.5.json +9 -0
- package/dist/templates/claude/hooks/statusline.py +7 -0
- package/dist/templates/claude/settings.json +22 -0
- package/dist/templates/codex/hooks/session-start.py +20 -1
- package/dist/templates/codex/hooks.json +25 -0
- package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +2 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +3 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +5 -4
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +1 -0
- package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +2 -1
- package/dist/templates/copilot/hooks/session-start.py +20 -1
- package/dist/templates/dsh/DSH.md +61 -37
- package/dist/templates/dsh/agents/trellis-check.md +99 -0
- package/dist/templates/dsh/agents/trellis-implement.md +106 -0
- package/dist/templates/dsh/agents/trellis-research.md +134 -0
- package/dist/templates/dsh/index.d.ts +12 -11
- package/dist/templates/dsh/index.d.ts.map +1 -1
- package/dist/templates/dsh/index.js +14 -12
- package/dist/templates/dsh/index.js.map +1 -1
- package/dist/templates/opencode/plugins/inject-spec-context.js +121 -0
- package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
- package/dist/templates/pi/extensions/trellis/index.ts.txt +164 -10
- package/dist/templates/shared-hooks/index.d.ts +8 -2
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +13 -1
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-spec-context.py +844 -0
- package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
- package/dist/templates/shared-hooks/session-start.py +22 -1
- package/dist/templates/trellis/config.yaml +47 -0
- package/dist/templates/trellis/index.d.ts +3 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +6 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/active_task.py +24 -2
- package/dist/templates/trellis/scripts/common/cli_adapter.py +38 -6
- package/dist/templates/trellis/scripts/common/config.py +18 -0
- package/dist/templates/trellis/scripts/common/context_projection.py +2 -2
- package/dist/templates/trellis/scripts/common/git_context.py +34 -2
- package/dist/templates/trellis/scripts/common/paths.py +28 -0
- package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
- package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
- package/dist/templates/trellis/scripts/common/task_store.py +88 -1
- package/dist/templates/trellis/scripts/common/trellis_config.py +46 -7
- package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
- package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
- package/dist/templates/trellis/scripts/task.py +83 -0
- package/dist/templates/trellis/workflow.md +35 -30
- package/dist/types/ai-tools.d.ts.map +1 -1
- package/dist/types/ai-tools.js +26 -13
- package/dist/types/ai-tools.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/* global process */
|
|
2
|
+
/**
|
|
3
|
+
* Trellis Dynamic Spec Injection Plugin
|
|
4
|
+
*
|
|
5
|
+
* OpenCode tool hooks cannot add context to the current model turn. A FULL
|
|
6
|
+
* spec emission therefore blocks the write once with a model-visible error;
|
|
7
|
+
* the shared Python engine records the emission, so the model's retry proceeds.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "child_process";
|
|
11
|
+
import { existsSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { debugLog } from "../lib/trellis-context.js";
|
|
14
|
+
|
|
15
|
+
const PYTHON_CMD = process.platform === "win32" ? "python" : "python3";
|
|
16
|
+
|
|
17
|
+
function runSpecHook(directory, payload) {
|
|
18
|
+
const hookPath = join(
|
|
19
|
+
directory,
|
|
20
|
+
".opencode",
|
|
21
|
+
"hooks",
|
|
22
|
+
"inject-spec-context.py",
|
|
23
|
+
);
|
|
24
|
+
if (!existsSync(hookPath)) return null;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const stdout = execFileSync(PYTHON_CMD, [hookPath], {
|
|
28
|
+
cwd: directory,
|
|
29
|
+
encoding: "utf-8",
|
|
30
|
+
input: JSON.stringify(payload),
|
|
31
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
32
|
+
timeout: 15000,
|
|
33
|
+
});
|
|
34
|
+
if (!stdout.trim()) return null;
|
|
35
|
+
const parsed = JSON.parse(stdout);
|
|
36
|
+
return parsed?.hookSpecificOutput ?? null;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
debugLog(
|
|
39
|
+
"spec",
|
|
40
|
+
"Spec hook failed open:",
|
|
41
|
+
error instanceof Error ? error.message : String(error),
|
|
42
|
+
);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildToolPayload(directory, input, output) {
|
|
48
|
+
const tool = input?.tool?.toLowerCase();
|
|
49
|
+
const args = output?.args;
|
|
50
|
+
if (!args) return null;
|
|
51
|
+
|
|
52
|
+
if (tool === "edit" || tool === "write") {
|
|
53
|
+
if (typeof args.filePath !== "string" || !args.filePath.trim()) return null;
|
|
54
|
+
return {
|
|
55
|
+
hook_event_name: "PreToolUse",
|
|
56
|
+
session_id: input?.sessionID,
|
|
57
|
+
cwd: directory,
|
|
58
|
+
tool_name: tool === "edit" ? "Edit" : "Write",
|
|
59
|
+
tool_input: { file_path: args.filePath },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (tool === "apply_patch") {
|
|
64
|
+
if (typeof args.patchText !== "string" || !args.patchText.trim())
|
|
65
|
+
return null;
|
|
66
|
+
return {
|
|
67
|
+
hook_event_name: "PreToolUse",
|
|
68
|
+
session_id: input?.sessionID,
|
|
69
|
+
cwd: directory,
|
|
70
|
+
tool_name: "apply_patch",
|
|
71
|
+
tool_input: { command: args.patchText },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default async ({ directory }) => ({
|
|
79
|
+
event: async ({ event }) => {
|
|
80
|
+
if (
|
|
81
|
+
process.env.TRELLIS_HOOKS === "0" ||
|
|
82
|
+
process.env.TRELLIS_DISABLE_HOOKS === "1"
|
|
83
|
+
) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (event?.type !== "session.compacted" || !event?.properties?.sessionID) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
runSpecHook(directory, {
|
|
91
|
+
hook_event_name: "SessionStart",
|
|
92
|
+
session_id: event.properties.sessionID,
|
|
93
|
+
source: "compact",
|
|
94
|
+
cwd: directory,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
"tool.execute.before": async (input, output) => {
|
|
99
|
+
if (
|
|
100
|
+
process.env.TRELLIS_HOOKS === "0" ||
|
|
101
|
+
process.env.TRELLIS_DISABLE_HOOKS === "1"
|
|
102
|
+
) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const payload = buildToolPayload(directory, input, output);
|
|
107
|
+
if (!payload) return;
|
|
108
|
+
|
|
109
|
+
const result = runSpecHook(directory, payload);
|
|
110
|
+
const context = result?.additionalContext;
|
|
111
|
+
if (result?.permissionDecision !== "deny" || typeof context !== "string") {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw new Error(
|
|
116
|
+
"Trellis loaded governing specs before this tool call. " +
|
|
117
|
+
"Follow them, then retry the same tool call.\n\n" +
|
|
118
|
+
context,
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* global process */
|
|
1
|
+
/* global process, console */
|
|
2
2
|
/**
|
|
3
3
|
* Trellis Workflow State Injection Plugin
|
|
4
4
|
*
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* `experimental.chat.messages.transform`. Stored history and the TUI
|
|
10
10
|
* are not modified (issue #553). Breadcrumb text is pulled exclusively
|
|
11
11
|
* from the project's workflow.md [workflow-state:STATUS] tag blocks —
|
|
12
|
-
* workflow.md is the single source of truth.
|
|
12
|
+
* workflow.md is the single source of truth. When the active task's
|
|
13
|
+
* task.json selects a workflow variant ("workflow": "<id>"), the tag
|
|
14
|
+
* blocks are read from .trellis/workflows/<id>.md instead (missing
|
|
15
|
+
* variant file → one stderr warning + fallback through personal/team/global
|
|
16
|
+
* defaults; see
|
|
17
|
+
* resolveWorkflowMd). There are no fallback
|
|
13
18
|
* tables in this plugin: when workflow.md is missing or a tag is
|
|
14
19
|
* absent, the breadcrumb degrades to a generic
|
|
15
20
|
* "Refer to workflow.md for current step." line so users see (and fix)
|
|
@@ -21,7 +26,7 @@
|
|
|
21
26
|
* - task.json malformed or missing status
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
|
-
import { existsSync, readFileSync } from "fs"
|
|
29
|
+
import { existsSync, readFileSync, statSync } from "fs"
|
|
25
30
|
import { join } from "path"
|
|
26
31
|
import {
|
|
27
32
|
MESSAGES_TRANSFORM_HOOK,
|
|
@@ -111,17 +116,113 @@ function promptHasSkipKeyword(text, keyword) {
|
|
|
111
116
|
return pattern.test(text)
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
// Per-task workflow selection (mirrors the Python resolver in
|
|
120
|
+
// .trellis/scripts/common/workflow_selection.py). Ids are restricted to
|
|
121
|
+
// [A-Za-z0-9_-]+ so a task.json value can never traverse outside
|
|
122
|
+
// .trellis/workflows/.
|
|
123
|
+
const WORKFLOW_ID_RE = /^[A-Za-z0-9_-]+$/
|
|
124
|
+
|
|
125
|
+
// Map a workflow id to its .trellis/workflows/<id>.md file if valid and
|
|
126
|
+
// present, else "". Shared by every resolution layer. Never throws.
|
|
127
|
+
function libraryVariant(directory, workflowId) {
|
|
128
|
+
if (!workflowId || !WORKFLOW_ID_RE.test(workflowId)) return ""
|
|
129
|
+
const variantPath = join(directory, ".trellis", "workflows", `${workflowId}.md`)
|
|
130
|
+
try {
|
|
131
|
+
if (statSync(variantPath).isFile()) return variantPath
|
|
132
|
+
} catch {
|
|
133
|
+
// ENOENT etc. — missing variant file.
|
|
134
|
+
}
|
|
135
|
+
return ""
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Personal override id from the gitignored .developer file (`workflow=<id>`
|
|
139
|
+
// line). "" when absent/unreadable. Never throws.
|
|
140
|
+
function developerWorkflowId(directory) {
|
|
141
|
+
try {
|
|
142
|
+
const text = readFileSync(join(directory, ".trellis", ".developer"), "utf-8")
|
|
143
|
+
for (const line of text.split(/\r?\n/)) {
|
|
144
|
+
if (line.startsWith("workflow=")) return line.slice("workflow=".length).trim()
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// No .developer / unreadable — no personal override.
|
|
148
|
+
}
|
|
149
|
+
return ""
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Team-shared default id from config.yaml's top-level `default_workflow:` key.
|
|
153
|
+
// Commented lines (starting with '#') are naturally excluded by the anchor.
|
|
154
|
+
// "" when unset/unreadable. Never throws.
|
|
155
|
+
const DEFAULT_WORKFLOW_RE = /^default_workflow:\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/m
|
|
156
|
+
function configDefaultWorkflowId(directory) {
|
|
157
|
+
try {
|
|
158
|
+
const text = readFileSync(join(directory, ".trellis", "config.yaml"), "utf-8")
|
|
159
|
+
const m = text.match(DEFAULT_WORKFLOW_RE)
|
|
160
|
+
if (m) return m[2]
|
|
161
|
+
} catch {
|
|
162
|
+
// No config.yaml / unreadable — no team default.
|
|
163
|
+
}
|
|
164
|
+
return ""
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve which workflow markdown file feeds this turn's breadcrumbs.
|
|
169
|
+
*
|
|
170
|
+
* Precedence (identical across all Trellis consumers), highest to lowest —
|
|
171
|
+
* each layer resolves an id to .trellis/workflows/<id>.md and falls through
|
|
172
|
+
* when unset, invalid, or naming a missing file:
|
|
173
|
+
* 1. Per-task pin — active task's task.json "workflow" (session-bound; a
|
|
174
|
+
* bad id / missing file warns once on stderr, then falls through).
|
|
175
|
+
* 2. Personal — .developer `workflow=<id>` (gitignored, per-developer).
|
|
176
|
+
* 3. Team default — config.yaml `default_workflow` (git-tracked, shared).
|
|
177
|
+
* 4. Global — .trellis/workflow.md.
|
|
178
|
+
* With neither a pin nor the personal/team keys set, this is the global path.
|
|
179
|
+
* Never throws.
|
|
180
|
+
*/
|
|
181
|
+
function resolveWorkflowMd(ctx, directory, platformInput) {
|
|
182
|
+
const globalPath = join(directory, ".trellis", "workflow.md")
|
|
183
|
+
|
|
184
|
+
// 1. Per-task pin (session-bound explicit choice).
|
|
185
|
+
let workflowId = ""
|
|
186
|
+
try {
|
|
187
|
+
const active = ctx.getActiveTask(platformInput)
|
|
188
|
+
if (active.taskPath && !active.stale) {
|
|
189
|
+
const taskDir = ctx.resolveTaskDir(active.taskPath)
|
|
190
|
+
if (taskDir) {
|
|
191
|
+
const data = JSON.parse(readFileSync(join(taskDir, "task.json"), "utf-8"))
|
|
192
|
+
if (data && typeof data.workflow === "string") workflowId = data.workflow
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// No active task / unreadable task.json — not a selection.
|
|
197
|
+
workflowId = ""
|
|
198
|
+
}
|
|
199
|
+
if (workflowId) {
|
|
200
|
+
const pinned = libraryVariant(directory, workflowId)
|
|
201
|
+
if (pinned) return pinned
|
|
202
|
+
console.error(
|
|
203
|
+
`Warning: active task selects workflow ${JSON.stringify(workflowId)} but .trellis/workflows/ has no matching file; using default workflow resolution`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. Personal override, then 3. team default — silent on miss (defaults,
|
|
208
|
+
// not an explicit per-task choice). 4. Global fallback.
|
|
209
|
+
const personal = libraryVariant(directory, developerWorkflowId(directory))
|
|
210
|
+
if (personal) return personal
|
|
211
|
+
const team = libraryVariant(directory, configDefaultWorkflowId(directory))
|
|
212
|
+
if (team) return team
|
|
213
|
+
return globalPath
|
|
214
|
+
}
|
|
215
|
+
|
|
114
216
|
/**
|
|
115
|
-
* Parse workflow
|
|
217
|
+
* Parse the resolved workflow markdown for [workflow-state:STATUS] blocks.
|
|
116
218
|
*
|
|
117
|
-
* Returns {status: body}. workflow
|
|
118
|
-
* there are no fallback tables here. Missing tags (or a missing /
|
|
119
|
-
* unreadable workflow
|
|
219
|
+
* Returns {status: body}. The workflow file is the single source of
|
|
220
|
+
* truth — there are no fallback tables here. Missing tags (or a missing /
|
|
221
|
+
* unreadable workflow file) fall back to a generic line in
|
|
120
222
|
* buildBreadcrumb so users see the broken state and fix workflow.md
|
|
121
223
|
* rather than the plugin silently masking it.
|
|
122
224
|
*/
|
|
123
|
-
function loadBreadcrumbs(
|
|
124
|
-
const workflowPath = join(directory, ".trellis", "workflow.md")
|
|
225
|
+
function loadBreadcrumbs(workflowPath) {
|
|
125
226
|
if (!existsSync(workflowPath)) return {}
|
|
126
227
|
let content
|
|
127
228
|
try {
|
|
@@ -228,7 +329,7 @@ export default async ({ directory }) => {
|
|
|
228
329
|
return
|
|
229
330
|
}
|
|
230
331
|
|
|
231
|
-
const templates = loadBreadcrumbs(directory)
|
|
332
|
+
const templates = loadBreadcrumbs(resolveWorkflowMd(ctx, directory, platformInput))
|
|
232
333
|
const task = getActiveTask(ctx, platformInput)
|
|
233
334
|
const breadcrumb = task
|
|
234
335
|
? buildBreadcrumb(task.id, task.status, templates, task.candidates)
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
2
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
9
|
import {
|
|
4
10
|
delimiter,
|
|
@@ -8,6 +14,7 @@ import {
|
|
|
8
14
|
relative,
|
|
9
15
|
resolve,
|
|
10
16
|
} from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
11
18
|
import { spawn, spawnSync } from "node:child_process";
|
|
12
19
|
import { isUtf8 } from "node:buffer";
|
|
13
20
|
|
|
@@ -773,10 +780,11 @@ function truncateUtf8(buf: Buffer, cap: number): Buffer {
|
|
|
773
780
|
if ((lead & 0xe0) === 0xc0) seqLen = 2;
|
|
774
781
|
else if ((lead & 0xf0) === 0xe0) seqLen = 3;
|
|
775
782
|
else if ((lead & 0xf8) === 0xf0) seqLen = 4;
|
|
776
|
-
//
|
|
777
|
-
|
|
783
|
+
// Cut before the lead byte when its full sequence didn't fit;
|
|
784
|
+
// otherwise the trailing sequence is complete — keep it whole.
|
|
785
|
+
if (i - 1 + seqLen > cap) return buf.subarray(0, i - 1);
|
|
778
786
|
}
|
|
779
|
-
return buf.subarray(0,
|
|
787
|
+
return buf.subarray(0, cap);
|
|
780
788
|
}
|
|
781
789
|
|
|
782
790
|
function stripInlineComment(value: string): string {
|
|
@@ -1115,10 +1123,66 @@ function readTaskDir(root: string, key: string | null): string | null {
|
|
|
1115
1123
|
}
|
|
1116
1124
|
|
|
1117
1125
|
// ── Workflow State Breadcrumb ─────────────────────────────────────────
|
|
1126
|
+
const WORKFLOW_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
1127
|
+
const DEFAULT_WORKFLOW_RE =
|
|
1128
|
+
/^default_workflow:\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/m;
|
|
1129
|
+
|
|
1130
|
+
function workflowVariant(root: string, workflowId: string): string {
|
|
1131
|
+
if (!WORKFLOW_ID_RE.test(workflowId)) return "";
|
|
1132
|
+
const path = join(root, ".trellis", "workflows", `${workflowId}.md`);
|
|
1133
|
+
return exists(path) ? path : "";
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function developerWorkflowId(root: string): string {
|
|
1137
|
+
for (const line of readText(join(root, ".trellis", ".developer")).split(
|
|
1138
|
+
/\r?\n/,
|
|
1139
|
+
)) {
|
|
1140
|
+
if (line.startsWith("workflow="))
|
|
1141
|
+
return line.slice("workflow=".length).trim();
|
|
1142
|
+
}
|
|
1143
|
+
return "";
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function configDefaultWorkflowId(root: string): string {
|
|
1147
|
+
return (
|
|
1148
|
+
readText(join(root, ".trellis", "config.yaml")).match(
|
|
1149
|
+
DEFAULT_WORKFLOW_RE,
|
|
1150
|
+
)?.[2] ?? ""
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Mirrors common/workflow_selection.py without spawning another Python
|
|
1155
|
+
* process on every Pi turn. Invalid or missing selections fall through. */
|
|
1156
|
+
function resolveWorkflowMd(root: string, key: string | null): string {
|
|
1157
|
+
const taskDir = readTaskDir(root, key);
|
|
1158
|
+
let workflowId = "";
|
|
1159
|
+
if (taskDir) {
|
|
1160
|
+
try {
|
|
1161
|
+
const task = JSON.parse(
|
|
1162
|
+
readText(join(taskDir, "task.json")),
|
|
1163
|
+
) as JsonObject;
|
|
1164
|
+
workflowId = typeof task.workflow === "string" ? task.workflow : "";
|
|
1165
|
+
} catch {}
|
|
1166
|
+
}
|
|
1167
|
+
if (workflowId) {
|
|
1168
|
+
const pinned = workflowVariant(root, workflowId);
|
|
1169
|
+
if (pinned) return pinned;
|
|
1170
|
+
console.error(
|
|
1171
|
+
`Warning: active task selects workflow ${JSON.stringify(workflowId)} but .trellis/workflows/ has no matching file; using default workflow resolution`,
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const personal = workflowVariant(root, developerWorkflowId(root));
|
|
1176
|
+
if (personal) return personal;
|
|
1177
|
+
const team = workflowVariant(root, configDefaultWorkflowId(root));
|
|
1178
|
+
if (team) return team;
|
|
1179
|
+
return join(root, ".trellis", "workflow.md");
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1118
1182
|
const WF_RE =
|
|
1119
1183
|
/\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
|
|
1120
1184
|
function workflowBreadcrumb(root: string, key: string | null): string {
|
|
1121
|
-
const wf = readText(
|
|
1185
|
+
const wf = readText(resolveWorkflowMd(root, key));
|
|
1122
1186
|
if (!wf) return "";
|
|
1123
1187
|
const templates: Record<string, string> = {};
|
|
1124
1188
|
for (const m of wf.matchAll(WF_RE)) {
|
|
@@ -1413,6 +1477,93 @@ function formatPiOutput(stdout: string, stderr: string): string {
|
|
|
1413
1477
|
return ft || stdout || stderr;
|
|
1414
1478
|
}
|
|
1415
1479
|
|
|
1480
|
+
// ── Permission-forwarding parent session (issue #610) ───────────
|
|
1481
|
+
// Headless `pi --mode json` children have no UI. `@gotgenes/pi-permission-system`
|
|
1482
|
+
// forwards `ask` to the parent only when the child env names the *serving*
|
|
1483
|
+
// parent session and is marked as a subagent. Three layers, all required:
|
|
1484
|
+
// 1. PI_SUBAGENT_PARENT_SESSION — out-of-process convention
|
|
1485
|
+
// 2. PI_SUBAGENT_CHILD=1 — stops pi-subagents overwriting that parent id
|
|
1486
|
+
// with the child's own session id
|
|
1487
|
+
// 3. serving heartbeat id, not stale PI_SESSION_ID (compaction can fork
|
|
1488
|
+
// the live id while the permission extension still listens on the
|
|
1489
|
+
// activation-time id). Drop inherited PI_SESSION_ID so the child
|
|
1490
|
+
// mints its own instead of colliding with the parent.
|
|
1491
|
+
let lastLiveSessionId: string | null = null;
|
|
1492
|
+
|
|
1493
|
+
function currentSessionId(ctx?: PiExtensionContext): string | null {
|
|
1494
|
+
const viaCtx = callStr(
|
|
1495
|
+
ctx?.sessionManager?.getSessionId,
|
|
1496
|
+
ctx?.sessionManager,
|
|
1497
|
+
);
|
|
1498
|
+
if (viaCtx) {
|
|
1499
|
+
lastLiveSessionId = viaCtx;
|
|
1500
|
+
return viaCtx;
|
|
1501
|
+
}
|
|
1502
|
+
return (
|
|
1503
|
+
lastLiveSessionId ??
|
|
1504
|
+
str(process.env.PI_SESSION_ID) ??
|
|
1505
|
+
str(process.env.PI_SESSIONID)
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function piSessionsRoot(): string {
|
|
1510
|
+
const sessionDir = str(process.env.PI_CODING_AGENT_SESSION_DIR);
|
|
1511
|
+
if (sessionDir) return sessionDir;
|
|
1512
|
+
const agentDir = str(process.env.PI_CODING_AGENT_DIR);
|
|
1513
|
+
if (agentDir) return join(agentDir, "sessions");
|
|
1514
|
+
return join(homedir(), ".pi", "agent", "sessions");
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function servingSessionId(): string | null {
|
|
1518
|
+
try {
|
|
1519
|
+
const dir = join(piSessionsRoot(), "permission-forwarding", "serving");
|
|
1520
|
+
let best: { sessionId: string; updatedAt: number } | null = null;
|
|
1521
|
+
for (const name of readdirSync(dir)) {
|
|
1522
|
+
if (!name.endsWith(".json")) continue;
|
|
1523
|
+
let parsed: unknown;
|
|
1524
|
+
try {
|
|
1525
|
+
parsed = JSON.parse(readFileSync(join(dir, name), "utf-8"));
|
|
1526
|
+
} catch {
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
if (!isObj(parsed) || parsed.pid !== process.pid) continue;
|
|
1530
|
+
const sessionId = str(parsed.sessionId);
|
|
1531
|
+
const updatedAt =
|
|
1532
|
+
typeof parsed.updatedAt === "number" && Number.isFinite(parsed.updatedAt)
|
|
1533
|
+
? parsed.updatedAt
|
|
1534
|
+
: 0;
|
|
1535
|
+
if (!sessionId) continue;
|
|
1536
|
+
if (!best || updatedAt > best.updatedAt) best = { sessionId, updatedAt };
|
|
1537
|
+
}
|
|
1538
|
+
return best?.sessionId ?? null;
|
|
1539
|
+
} catch {
|
|
1540
|
+
return null;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function parentSessionIdForChild(ctx?: PiExtensionContext): string | null {
|
|
1545
|
+
return servingSessionId() ?? currentSessionId(ctx);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function buildChildEnv(
|
|
1549
|
+
key?: string | null,
|
|
1550
|
+
parentSessionId?: string | null,
|
|
1551
|
+
): NodeJS.ProcessEnv {
|
|
1552
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
1553
|
+
...process.env,
|
|
1554
|
+
TRELLIS_SUBAGENT_CHILD: "1",
|
|
1555
|
+
// Stops pi-subagents from overwriting PI_SUBAGENT_PARENT_SESSION with the
|
|
1556
|
+
// child's own session id, and lets the permission system treat this process
|
|
1557
|
+
// as a subagent (ParentAuthorizer forwarding) even when no parent id is known.
|
|
1558
|
+
PI_SUBAGENT_CHILD: "1",
|
|
1559
|
+
...(key ? { TRELLIS_CONTEXT_ID: key } : {}),
|
|
1560
|
+
...(parentSessionId ? { PI_SUBAGENT_PARENT_SESSION: parentSessionId } : {}),
|
|
1561
|
+
};
|
|
1562
|
+
delete childEnv.PI_SESSION_ID;
|
|
1563
|
+
delete childEnv.PI_SESSIONID;
|
|
1564
|
+
return childEnv;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1416
1567
|
// ── runPi: subprocess execution + event processing ───────────────────
|
|
1417
1568
|
function runPi(
|
|
1418
1569
|
root: string,
|
|
@@ -1422,6 +1573,7 @@ function runPi(
|
|
|
1422
1573
|
emit: () => void,
|
|
1423
1574
|
key?: string | null,
|
|
1424
1575
|
signal?: AbortSignal,
|
|
1576
|
+
parentSessionId?: string | null,
|
|
1425
1577
|
): Promise<{ output: string; failed: boolean }> {
|
|
1426
1578
|
return new Promise((resolve) => {
|
|
1427
1579
|
if (signal?.aborted) {
|
|
@@ -1433,11 +1585,7 @@ function runPi(
|
|
|
1433
1585
|
return;
|
|
1434
1586
|
}
|
|
1435
1587
|
const inv = resolvePiCli();
|
|
1436
|
-
const childEnv =
|
|
1437
|
-
...process.env,
|
|
1438
|
-
TRELLIS_SUBAGENT_CHILD: "1",
|
|
1439
|
-
...(key ? { TRELLIS_CONTEXT_ID: key } : {}),
|
|
1440
|
-
};
|
|
1588
|
+
const childEnv = buildChildEnv(key, parentSessionId);
|
|
1441
1589
|
const cli = spawn(inv.command, [...inv.args, ...buildPiArgs(cfg)], {
|
|
1442
1590
|
cwd: root,
|
|
1443
1591
|
env: childEnv,
|
|
@@ -1538,7 +1686,9 @@ async function runSubagent(
|
|
|
1538
1686
|
onUpdate?: (r: PiToolResult) => void,
|
|
1539
1687
|
inheritedThinking?: string,
|
|
1540
1688
|
inheritedModel?: string,
|
|
1689
|
+
ctx?: PiExtensionContext,
|
|
1541
1690
|
): Promise<{ output: string; details: ProgressDetails; failed: boolean }> {
|
|
1691
|
+
const parentSessionId = parentSessionIdForChild(ctx);
|
|
1542
1692
|
const agentName = normalizeAgent(input.agent);
|
|
1543
1693
|
const agentRaw = readText(join(root, ".pi", "agents", `${agentName}.md`));
|
|
1544
1694
|
const agentCfg = parseAgentFM(agentRaw);
|
|
@@ -1606,6 +1756,7 @@ async function runSubagent(
|
|
|
1606
1756
|
emit,
|
|
1607
1757
|
key,
|
|
1608
1758
|
signal,
|
|
1759
|
+
parentSessionId,
|
|
1609
1760
|
),
|
|
1610
1761
|
),
|
|
1611
1762
|
);
|
|
@@ -1638,6 +1789,7 @@ async function runSubagent(
|
|
|
1638
1789
|
emit,
|
|
1639
1790
|
key,
|
|
1640
1791
|
signal,
|
|
1792
|
+
parentSessionId,
|
|
1641
1793
|
);
|
|
1642
1794
|
prev = result.output;
|
|
1643
1795
|
failed = failed || result.failed;
|
|
@@ -1657,6 +1809,7 @@ async function runSubagent(
|
|
|
1657
1809
|
emit,
|
|
1658
1810
|
key,
|
|
1659
1811
|
signal,
|
|
1812
|
+
parentSessionId,
|
|
1660
1813
|
);
|
|
1661
1814
|
return finish(result.output, result.failed);
|
|
1662
1815
|
} catch (e) {
|
|
@@ -1875,6 +2028,7 @@ export default function trellisExtension(pi: {
|
|
|
1875
2028
|
onUpdate,
|
|
1876
2029
|
inheritedThinking,
|
|
1877
2030
|
inheritedModel,
|
|
2031
|
+
ctx,
|
|
1878
2032
|
);
|
|
1879
2033
|
return {
|
|
1880
2034
|
content: [{ type: "text", text: result.output }],
|
|
@@ -11,8 +11,8 @@ export interface HookScript {
|
|
|
11
11
|
/** Script content — no placeholders, ready to write directly */
|
|
12
12
|
content: string;
|
|
13
13
|
}
|
|
14
|
-
export type SharedHookName = "session-start.py" | "inject-shell-session-context.py" | "inject-workflow-state.py" | "inject-subagent-context.py";
|
|
15
|
-
export type SharedHookPlatform = "claude" | "cursor" | "codex" | "gemini" | "qoder" | "copilot" | "codebuddy" | "droid" | "kiro" | "trae" | "zcode";
|
|
14
|
+
export type SharedHookName = "session-start.py" | "inject-shell-session-context.py" | "inject-workflow-state.py" | "inject-subagent-context.py" | "inject-spec-context.py";
|
|
15
|
+
export type SharedHookPlatform = "claude" | "cursor" | "codex" | "opencode" | "gemini" | "qoder" | "copilot" | "codebuddy" | "droid" | "kiro" | "trae" | "zcode";
|
|
16
16
|
/**
|
|
17
17
|
* Which shared hooks each platform actually invokes. Single source of truth
|
|
18
18
|
* for shared-hook distribution — `collectSharedHooks` reads this table, and
|
|
@@ -51,6 +51,12 @@ export type SharedHookPlatform = "claude" | "cursor" | "codex" | "gemini" | "qod
|
|
|
51
51
|
* (per-turn breadcrumb), and `inject-subagent-context.py` (sub-agent
|
|
52
52
|
* spawn). The scripts emit a plain-text Kiro branch — Kiro adds a hook's
|
|
53
53
|
* stdout directly to the conversation context (no JSON envelope).
|
|
54
|
+
* - `inject-spec-context.py` — path-scoped spec injection. Claude Code uses
|
|
55
|
+
* PostToolUse Read/Edit/Write/MultiEdit; Codex and OpenCode use PreToolUse.
|
|
56
|
+
* Codex passes the native apply_patch payload directly. OpenCode's plugin
|
|
57
|
+
* adapts write/edit/apply_patch calls and blocks a FULL emission once so the
|
|
58
|
+
* model sees the specs before retrying. Class-2 platforms use
|
|
59
|
+
* `get_context.py --mode spec` pull mode instead.
|
|
54
60
|
* - Claude Code `statusLine` is intentionally not installed by default.
|
|
55
61
|
* Users can add their own statusLine command in `.claude/settings.json`,
|
|
56
62
|
* or opt in to the Trellis one via `trellis init --with-statusline`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/templates/shared-hooks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAaH,MAAM,WAAW,UAAU;IACzB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GACtB,kBAAkB,GAClB,iCAAiC,GACjC,0BAA0B,GAC1B,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/templates/shared-hooks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAaH,MAAM,WAAW,UAAU;IACzB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GACtB,kBAAkB,GAClB,iCAAiC,GACjC,0BAA0B,GAC1B,4BAA4B,GAC5B,wBAAwB,CAAC;AAE7B,MAAM,MAAM,kBAAkB,GAC1B,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,UAAU,GACV,QAAQ,GACR,OAAO,GACP,SAAS,GACT,WAAW,GACX,OAAO,GACP,MAAM,GACN,MAAM,GACN,OAAO,CAAC;AAEZ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,eAAO,MAAM,wBAAwB,EAAE,MAAM,CAC3C,kBAAkB,EAClB,SAAS,cAAc,EAAE,CA0D1B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,UAAU,EAAE,CAWnD;AAED;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,kBAAkB,GAC3B,UAAU,EAAE,CAGd"}
|
|
@@ -51,6 +51,12 @@ function readTemplate(relativePath) {
|
|
|
51
51
|
* (per-turn breadcrumb), and `inject-subagent-context.py` (sub-agent
|
|
52
52
|
* spawn). The scripts emit a plain-text Kiro branch — Kiro adds a hook's
|
|
53
53
|
* stdout directly to the conversation context (no JSON envelope).
|
|
54
|
+
* - `inject-spec-context.py` — path-scoped spec injection. Claude Code uses
|
|
55
|
+
* PostToolUse Read/Edit/Write/MultiEdit; Codex and OpenCode use PreToolUse.
|
|
56
|
+
* Codex passes the native apply_patch payload directly. OpenCode's plugin
|
|
57
|
+
* adapts write/edit/apply_patch calls and blocks a FULL emission once so the
|
|
58
|
+
* model sees the specs before retrying. Class-2 platforms use
|
|
59
|
+
* `get_context.py --mode spec` pull mode instead.
|
|
54
60
|
* - Claude Code `statusLine` is intentionally not installed by default.
|
|
55
61
|
* Users can add their own statusLine command in `.claude/settings.json`,
|
|
56
62
|
* or opt in to the Trellis one via `trellis init --with-statusline`
|
|
@@ -67,13 +73,19 @@ export const SHARED_HOOKS_BY_PLATFORM = {
|
|
|
67
73
|
"session-start.py",
|
|
68
74
|
"inject-workflow-state.py",
|
|
69
75
|
"inject-subagent-context.py",
|
|
76
|
+
"inject-spec-context.py",
|
|
70
77
|
],
|
|
71
78
|
cursor: [
|
|
72
79
|
"session-start.py",
|
|
73
80
|
"inject-shell-session-context.py",
|
|
74
81
|
"inject-subagent-context.py",
|
|
75
82
|
],
|
|
76
|
-
codex: [
|
|
83
|
+
codex: [
|
|
84
|
+
"inject-workflow-state.py",
|
|
85
|
+
"inject-subagent-context.py",
|
|
86
|
+
"inject-spec-context.py",
|
|
87
|
+
],
|
|
88
|
+
opencode: ["inject-spec-context.py"],
|
|
77
89
|
gemini: [
|
|
78
90
|
"session-start.py",
|
|
79
91
|
"inject-shell-session-context.py",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/shared-hooks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC,SAAS,YAAY,CAAC,YAAoB;IACxC,OAAO,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC;AAC9D,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/shared-hooks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC,SAAS,YAAY,CAAC,YAAoB;IACxC,OAAO,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,EAAE,OAAO,CAAC,CAAC;AAC9D,CAAC;AA8BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAGjC;IACF,MAAM,EAAE;QACN,kBAAkB;QAClB,0BAA0B;QAC1B,4BAA4B;QAC5B,wBAAwB;KACzB;IACD,MAAM,EAAE;QACN,kBAAkB;QAClB,iCAAiC;QACjC,4BAA4B;KAC7B;IACD,KAAK,EAAE;QACL,0BAA0B;QAC1B,4BAA4B;QAC5B,wBAAwB;KACzB;IACD,QAAQ,EAAE,CAAC,wBAAwB,CAAC;IACpC,MAAM,EAAE;QACN,kBAAkB;QAClB,iCAAiC;QACjC,0BAA0B;KAC3B;IACD,KAAK,EAAE;QACL,kBAAkB;QAClB,iCAAiC;QACjC,0BAA0B;KAC3B;IACD,OAAO,EAAE,CAAC,0BAA0B,CAAC;IACrC,SAAS,EAAE;QACT,kBAAkB;QAClB,iCAAiC;QACjC,0BAA0B;QAC1B,4BAA4B;KAC7B;IACD,KAAK,EAAE;QACL,kBAAkB;QAClB,iCAAiC;QACjC,0BAA0B;QAC1B,4BAA4B;KAC7B;IACD,IAAI,EAAE;QACJ,kBAAkB;QAClB,0BAA0B;QAC1B,4BAA4B;KAC7B;IACD,IAAI,EAAE;QACJ,kBAAkB;QAClB,iCAAiC;QACjC,0BAA0B;KAC3B;IACD,KAAK,EAAE;QACL,kBAAkB;QAClB,iCAAiC;QACjC,0BAA0B;QAC1B,4BAA4B;KAC7B;CACF,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,MAAM,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC;SACjC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;SAChC,IAAI,EAAE,CAAC;IAEV,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC5D,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,+BAA+B,CAC7C,QAA4B;IAE5B,MAAM,OAAO,GAAG,IAAI,GAAG,CAAS,wBAAwB,CAAC,QAAQ,CAAC,CAAC,CAAC;IACpE,OAAO,oBAAoB,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AACnE,CAAC"}
|