@gtrabanco/pi-agentic-workflow 0.15.3 → 0.17.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.
@@ -7,6 +7,11 @@
7
7
  // a scope whose file does not parse cannot be overwritten (that file is evidence
8
8
  // the operator must fix, not noise to clobber), and the project scope is refused
9
9
  // while the project is untrusted — the same gate the loader applies (AC13).
10
+ //
11
+ // P5 (feature 63): the flow is scope → profile → routes. Every command operates
12
+ // on the active profile's route (for "default" this is top-level; for a named
13
+ // profile it is `profiles.<name>`). The profile defaults to "default", so
14
+ // existing tests that never answer a profile prompt keep working unchanged.
10
15
  import { loadConfig, configFilePaths } from "../config/load.js";
11
16
  import { effectiveRoute } from "../config/merge.js";
12
17
  import { parseConfigFile, parseModelReference } from "../config/schema.js";
@@ -32,6 +37,13 @@ export const prompts = {
32
37
  clearOverride: "Clear a command override",
33
38
  bulkApply: "Apply one route to several commands",
34
39
  bulkClear: "Clear several overrides",
40
+ switchProfile: "Switch the profile being edited",
41
+ rotateProfile: "Rotate the active profile",
42
+ toggleRecommended: "Toggle recommended models",
43
+ profile: "Which profile?",
44
+ newProfile: "Name the new profile",
45
+ profileBuiltIn: (name) => `The "${name}" profile is built into the package and cannot be edited — create a profile to override it.`,
46
+ profileNamed: (name) => `Editing profile "${name}"`,
35
47
  addAnother: "Add another?",
36
48
  policy: "Set the unavailable-route policy",
37
49
  settle: "Set the post-command model keep/restore policy",
@@ -57,23 +69,27 @@ const INHERIT = "inherit";
57
69
  export async function runSettingsConsole(deps) {
58
70
  const paths = configFilePaths(deps.agentDir, deps.cwd);
59
71
  const merged = loadConfig({ agentDir: deps.agentDir, cwd: deps.cwd, projectTrusted: deps.projectTrusted, readFile: deps.readFile });
60
- deps.ui.notify(renderMergedConfig(merged, deps.commands).join("\n"), "info");
61
- /** The value in force for a target (the merged route, or the default when the target is "the default route"). */
62
- const currentFor = (target) => effectiveRoute(merged.config, target);
72
+ deps.ui.notify(renderMergedConfig(merged, deps.commands, merged.config.profiles?.nan !== undefined || (deps.models ?? []).some((m) => m.startsWith("nan/"))
73
+ ? { providerAvailable: (provider) => (deps.models ?? []).some((m) => m.startsWith(`${provider}/`)) }
74
+ : undefined).join("\n"), "info");
63
75
  const opened = await openAScope(deps, paths.global, paths.project);
64
76
  if (!opened)
65
77
  return { status: "cancelled", edited: false };
66
78
  const { scope, path, original } = opened;
67
79
  let draft = original;
80
+ let active = "default";
68
81
  for (;;) {
69
82
  // Only offered while a turn actually holds the latch: an option that does
70
83
  // nothing is how a console becomes noise.
71
84
  const choice = await deps.ui.select(prompts.menu, [
85
+ prompts.switchProfile,
72
86
  prompts.setDefaultRoute,
73
87
  prompts.setOverride,
74
88
  prompts.clearOverride,
75
89
  prompts.bulkApply,
76
90
  prompts.bulkClear,
91
+ prompts.rotateProfile,
92
+ prompts.toggleRecommended,
77
93
  prompts.policy,
78
94
  prompts.settle,
79
95
  ...(deps.routing?.inFlight() ? [prompts.undoInFlight] : []),
@@ -88,57 +104,172 @@ export async function runSettingsConsole(deps) {
88
104
  if (choice === undefined || choice === prompts.cancel) {
89
105
  return { status: "cancelled", edited: dirty(draft, original) ? await discard(deps.ui) : false };
90
106
  }
107
+ // Profile: switch to another profile (scope → profile → routes)
108
+ if (choice === prompts.switchProfile) {
109
+ const picked = await pickProfile(deps, draft, { allowNew: true });
110
+ if (picked === undefined)
111
+ continue;
112
+ // P5: the built-in "nan" profile cannot be edited — create a profile
113
+ // to override it.
114
+ if (picked === "nan" && draft.profiles?.nan === undefined) {
115
+ deps.ui.notify(prompts.profileBuiltIn("nan"), "warning");
116
+ continue;
117
+ }
118
+ if (picked !== "default" && !Object.prototype.hasOwnProperty.call(draft.profiles ?? {}, picked)) {
119
+ draft = { ...draft, profiles: { ...(draft.profiles ?? {}), [picked]: {} } };
120
+ }
121
+ active = picked;
122
+ deps.ui.notify(prompts.profileNamed(active), "info");
123
+ continue;
124
+ }
125
+ // Rotate: move a profile to the front of profileOrder, clear demotion
126
+ if (choice === prompts.rotateProfile) {
127
+ const picked = await pickProfile(deps, draft, { allowNew: false });
128
+ if (picked === undefined)
129
+ continue;
130
+ const base = draft.profileOrder && draft.profileOrder.length > 0 ? draft.profileOrder : ["default"];
131
+ draft = { ...draft, profileOrder: [picked, ...base.filter((n) => n !== picked)] };
132
+ deps.routing?.clearProfileDemotion?.();
133
+ continue;
134
+ }
135
+ // Toggle recommended models
136
+ if (choice === prompts.toggleRecommended) {
137
+ const current = draft.recommendedModels ?? merged.config.recommendedModels;
138
+ draft = { ...draft, recommendedModels: !current };
139
+ continue;
140
+ }
141
+ // Set default route on the active profile
91
142
  if (choice === prompts.setDefaultRoute) {
92
- const edited = await editRoute(deps, DEFAULT_ROUTE, currentFor(DEFAULT_ROUTE));
93
- if (edited)
94
- draft = { ...draft, default: edited };
143
+ const target = active === "default" ? DEFAULT_ROUTE : `profile ${active} default`;
144
+ const routeFile = active === "default"
145
+ ? (draft.default ?? {})
146
+ : (draft.profiles?.[active]?.default ?? {});
147
+ const currentRoute = {
148
+ model: routeFile.model ?? "inherit",
149
+ thinking: routeFile.thinking ?? "inherit",
150
+ };
151
+ const edited = await editRoute(deps, target, currentRoute);
152
+ if (edited) {
153
+ if (active === "default") {
154
+ draft = { ...draft, default: edited };
155
+ }
156
+ else {
157
+ draft = {
158
+ ...draft,
159
+ profiles: { ...(draft.profiles ?? {}), [active]: { ...(draft.profiles?.[active] ?? {}), default: edited } },
160
+ };
161
+ }
162
+ }
95
163
  continue;
96
164
  }
165
+ // Set override on active profile's commands
97
166
  if (choice === prompts.setOverride) {
98
- const name = await pickCommand(deps, commandChoices(deps, draft));
167
+ // Use the full command registry so operators can add new overrides (AC4/OB-3).
168
+ const cmdNames = [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
169
+ const name = await pickCommand(deps, cmdNames);
99
170
  if (name === undefined)
100
171
  continue;
101
- const route = await editRoute(deps, name, currentFor(name));
102
- if (route)
103
- draft = { ...draft, commands: { ...draft.commands, [name]: route } };
172
+ // The initial value comes from the merged config so the picker shows the
173
+ // value in force, matching the original `currentFor(name)` behavior.
174
+ const effective = effectiveRoute(merged.config, name);
175
+ const routeFile = active === "default"
176
+ ? (draft.commands?.[name] ?? {})
177
+ : (draft.profiles?.[active]?.commands?.[name] ?? {});
178
+ const currentRoute = {
179
+ model: typeof routeFile?.model !== "undefined" ? routeFile.model : effective.model,
180
+ thinking: typeof routeFile?.thinking !== "undefined" ? routeFile.thinking : effective.thinking,
181
+ };
182
+ const route = await editRoute(deps, name, currentRoute);
183
+ if (route) {
184
+ if (active === "default") {
185
+ draft = { ...draft, commands: { ...draft.commands, [name]: route } };
186
+ }
187
+ else {
188
+ draft = {
189
+ ...draft,
190
+ profiles: {
191
+ ...(draft.profiles ?? {}),
192
+ [active]: { ...(draft.profiles?.[active] ?? {}), commands: { ...(draft.profiles?.[active]?.commands ?? {}), [name]: route } },
193
+ },
194
+ };
195
+ }
196
+ }
104
197
  continue;
105
198
  }
199
+ // Clear override on active profile's commands
106
200
  if (choice === prompts.clearOverride) {
107
- const name = await pickCommand(deps, Object.keys(draft.commands ?? {}));
201
+ const cmdNames = Object.keys(profileOf(draft, active).commands ?? {});
202
+ const name = await pickCommand(deps, cmdNames);
108
203
  if (name === undefined)
109
204
  continue;
110
- const rest = { ...draft.commands };
111
- delete rest[name];
112
- draft = { ...draft, commands: rest };
205
+ if (active === "default") {
206
+ const rest = { ...draft.commands };
207
+ delete rest[name];
208
+ draft = { ...draft, commands: rest };
209
+ }
210
+ else {
211
+ const profile = draft.profiles?.[active] ?? {};
212
+ const rest = { ...(profile.commands ?? {}) };
213
+ delete rest[name];
214
+ draft = { ...draft, profiles: { ...(draft.profiles ?? {}), [active]: { ...profile, commands: rest } } };
215
+ }
113
216
  continue;
114
217
  }
218
+ // Bulk apply on active profile's commands
115
219
  if (choice === prompts.bulkApply) {
116
- // One field pass (model via the chain builder + thinking), applied to every
117
- // selected command. A reference missing from the live registry warns per
118
- // command but never blocks the write (OB-4 — dispatch's probe stays the
119
- // authoritative availability gate).
120
- const selected = await pickCommandsMulti(deps, commandChoices(deps, draft));
220
+ // Use the full command registry so operators can apply to new commands (AC4/OB-3).
221
+ const cmdNames = [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
222
+ const selected = await pickCommandsMulti(deps, cmdNames);
121
223
  if (selected === undefined || selected.length === 0)
122
224
  continue;
123
- const route = await editRoute(deps, selected[0], currentFor(selected[0]));
225
+ const routeFile = active === "default"
226
+ ? (draft.commands?.[selected[0]] ?? {})
227
+ : (draft.profiles?.[active]?.commands?.[selected[0]] ?? {});
228
+ const currentRoute = {
229
+ model: routeFile.model ?? "inherit",
230
+ thinking: routeFile.thinking ?? "inherit",
231
+ };
232
+ const route = await editRoute(deps, selected[0], currentRoute);
124
233
  if (route === undefined)
125
234
  continue;
126
- const nextCommands = { ...draft.commands };
127
- for (const name of selected) {
128
- nextCommands[name] = route;
129
- warnMissingModel(deps, name, route.model ?? INHERIT);
235
+ if (active === "default") {
236
+ const nextCommands = { ...draft.commands };
237
+ for (const name of selected) {
238
+ nextCommands[name] = route;
239
+ warnMissingModel(deps, name, route.model ?? INHERIT);
240
+ }
241
+ draft = { ...draft, commands: nextCommands };
242
+ }
243
+ else {
244
+ const profile = draft.profiles?.[active] ?? {};
245
+ const nextCommands = { ...profile.commands };
246
+ for (const name of selected) {
247
+ nextCommands[name] = route;
248
+ warnMissingModel(deps, name, route.model ?? INHERIT);
249
+ }
250
+ draft = { ...draft, profiles: { ...(draft.profiles ?? {}), [active]: { ...profile, commands: nextCommands } } };
130
251
  }
131
- draft = { ...draft, commands: nextCommands };
132
252
  continue;
133
253
  }
254
+ // Bulk clear on active profile's commands
134
255
  if (choice === prompts.bulkClear) {
135
- const names = await pickCommandsMulti(deps, Object.keys(draft.commands ?? {}));
256
+ const cmdNames = Object.keys(profileOf(draft, active).commands ?? {});
257
+ const names = await pickCommandsMulti(deps, cmdNames);
136
258
  if (names === undefined || names.length === 0)
137
259
  continue;
138
- const rest = { ...draft.commands };
139
- for (const name of names)
140
- delete rest[name];
141
- draft = { ...draft, commands: rest };
260
+ if (active === "default") {
261
+ const rest = { ...draft.commands };
262
+ for (const name of names)
263
+ delete rest[name];
264
+ draft = { ...draft, commands: rest };
265
+ }
266
+ else {
267
+ const profile = draft.profiles?.[active] ?? {};
268
+ const rest = { ...(profile.commands ?? {}) };
269
+ for (const name of names)
270
+ delete rest[name];
271
+ draft = { ...draft, profiles: { ...(draft.profiles ?? {}), [active]: { ...profile, commands: rest } } };
272
+ }
142
273
  continue;
143
274
  }
144
275
  if (choice === prompts.policy) {
@@ -154,7 +285,7 @@ export async function runSettingsConsole(deps) {
154
285
  continue;
155
286
  }
156
287
  if (choice === prompts.save) {
157
- const saved = await saveScope(deps, scope, path, draft);
288
+ const saved = await saveScope(deps, scope, path, draft, merged);
158
289
  if (saved)
159
290
  return { status: "saved", scope, path, file: clean(draft) };
160
291
  continue;
@@ -167,6 +298,38 @@ export async function runSettingsConsole(deps) {
167
298
  * without being read, and a scope whose file does not parse is refused so the
168
299
  * operator's own file survives (AC10, AC13).
169
300
  */
301
+ // ---------------------------------------------------------------------------
302
+ // Profile helpers (P5, feature 63)
303
+ // ---------------------------------------------------------------------------
304
+ /** The profile file for a named profile or the default. */
305
+ function profileOf(draft, name) {
306
+ if (name === "default")
307
+ return { ...(draft.default ? { default: draft.default } : {}), ...(draft.commands ? { commands: draft.commands } : {}) };
308
+ return draft.profiles?.[name] ?? {};
309
+ }
310
+ /**
311
+ * Pick a profile from the list. `allowNew` adds "Name the new profile" to the
312
+ * options; the input must match a lowercase slug pattern.
313
+ */
314
+ async function pickProfile(deps, draft, { allowNew }) {
315
+ const names = ["default", ...Object.keys(draft.profiles ?? {}).filter((n) => n !== "default")];
316
+ // "nan" is always listed (built-in unless user-defined)
317
+ if (!names.includes("nan"))
318
+ names.push("nan");
319
+ const options = allowNew ? [...names, prompts.newProfile] : names;
320
+ const picked = await pagedSelect((title, list) => deps.ui.select(title, list), prompts.profile, options);
321
+ if (picked === undefined)
322
+ return undefined;
323
+ if (picked === prompts.newProfile) {
324
+ const name = await deps.ui.input(prompts.newProfile, "lowercase slug");
325
+ if (name === undefined || !/^[a-z0-9][a-z0-9._-]*$/u.test(name.trim())) {
326
+ deps.ui.notify("Rejected: a profile name must be a lowercase slug.", "error");
327
+ return undefined;
328
+ }
329
+ return name.trim();
330
+ }
331
+ return picked;
332
+ }
170
333
  async function openAScope(deps, globalPath, projectPath) {
171
334
  for (;;) {
172
335
  const label = await deps.ui.select(prompts.scope, [GLOBAL_LABEL, PROJECT_LABEL]);
@@ -439,11 +602,14 @@ function warnMissingModel(deps, target, model) {
439
602
  }
440
603
  }
441
604
  }
442
- function commandChoices(deps, draft) {
443
- return [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
444
- }
445
- async function saveScope(deps, scope, path, draft) {
605
+ async function saveScope(deps, scope, path, draft, merged) {
446
606
  const file = clean(draft);
607
+ // P5: materialize recommendedModels when the nan provider is available but
608
+ // the key is absent — the save writes the effective value (AC11).
609
+ const nanAvailable = (deps.models ?? []).some((m) => m.startsWith("nan/"));
610
+ if (file.recommendedModels === undefined && nanAvailable) {
611
+ file.recommendedModels = merged.config.recommendedModels;
612
+ }
447
613
  const text = `${JSON.stringify(file, null, 2)}\n`;
448
614
  const problems = problemsFor(file, scope);
449
615
  if (problems.length > 0) {
@@ -477,6 +643,8 @@ function describeRouting(file) {
477
643
  const routes = [
478
644
  file.default ? `default: ${file.default.model ?? "inherit"} / ${file.default.thinking ?? "inherit"}` : "default: inherit / inherit",
479
645
  ...Object.entries(file.commands ?? {}).map(([name, route]) => `${name}: ${route.model ?? "inherit"} / ${route.thinking ?? "inherit"}`),
646
+ ...(file.profileOrder && file.profileOrder.length > 0 ? [`profiles: ${file.profileOrder.join(" → ")}`] : []),
647
+ ...(file.recommendedModels !== undefined ? [`recommended models: ${file.recommendedModels}`] : []),
480
648
  `unavailable: ${file.onUnavailableRoute ?? "stop"}`,
481
649
  `settle: ${file.onSettle ?? "keep"}`,
482
650
  ];
@@ -504,6 +672,17 @@ function clean(draft) {
504
672
  file.onUnavailableRoute = draft.onUnavailableRoute;
505
673
  if (draft.onSettle)
506
674
  file.onSettle = draft.onSettle;
675
+ if (draft.recommendedModels !== undefined)
676
+ file.recommendedModels = draft.recommendedModels;
677
+ if (draft.profiles && Object.keys(draft.profiles).length > 0) {
678
+ file.profiles = structuredClone(draft.profiles);
679
+ }
680
+ if (draft.profileOrder && draft.profileOrder.length > 0) {
681
+ file.profileOrder = [...draft.profileOrder];
682
+ }
683
+ if (draft.profileFallback && Object.keys(draft.profileFallback).length > 0) {
684
+ file.profileFallback = { ...draft.profileFallback };
685
+ }
507
686
  return file;
508
687
  }
509
688
  function parseFile(text, scope) {
@@ -8,4 +8,6 @@ export declare const DEFAULT_ROUTE = "the default route";
8
8
  */
9
9
  export declare function routePath(target: string): string;
10
10
  /** What the operator reads when the console opens. */
11
- export declare function renderMergedConfig(loaded: LoadedConfig, commands: readonly string[]): string[];
11
+ export declare function renderMergedConfig(loaded: LoadedConfig, commands: readonly string[], opts?: {
12
+ providerAvailable?: (provider: string) => boolean;
13
+ }): string[];
@@ -1,6 +1,7 @@
1
1
  // src/settings/view.ts
2
2
  // The settings console's display half (AC10, SPEC S4): what is in effect right
3
3
  // now, and which files the values came from.
4
+ import { effectiveProfileOrder } from "../config/profiles.js";
4
5
  /** The label for the unqualified route, so `default` is never mistaken for a command name. */
5
6
  export const DEFAULT_ROUTE = "the default route";
6
7
  /**
@@ -12,9 +13,15 @@ export function routePath(target) {
12
13
  return target === DEFAULT_ROUTE ? "$.default" : `$.commands.${target}`;
13
14
  }
14
15
  /** What the operator reads when the console opens. */
15
- export function renderMergedConfig(loaded, commands) {
16
+ export function renderMergedConfig(loaded, commands, opts) {
17
+ const order = effectiveProfileOrder(loaded.config, {
18
+ providerAvailable: opts?.providerAvailable ?? (() => false),
19
+ });
16
20
  return [
17
21
  "agentic-workflow routing — what each command runs on right now",
22
+ ` profiles: ${order.join(" → ")}`,
23
+ ` active profile: ${order[0] ?? "default"}`,
24
+ ` recommended models: ${loaded.config.recommendedModels ? "on" : "off"}${loaded.config.recommendedModels && opts?.providerAvailable?.("nan") ? " (built-in nan profile in the chain)" : ""}`,
18
25
  ...configLines(loaded.config, commands),
19
26
  ...problems(loaded.problems),
20
27
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-agentic-workflow",
3
- "version": "0.15.3",
3
+ "version": "0.17.0",
4
4
  "description": "Pi package: canonical agentic-workflow skills, friendly slash commands, and per-command model routing.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: init-workspace
3
3
  user-invocable: true
4
- version: 3.0.1
4
+ version: 3.2.0
5
5
  argument-hint: <target-dir>
6
6
  author: "Gabriel Trabanco <1969593+gtrabanco@users.noreply.github.com>"
7
7
  license: MIT
@@ -58,7 +58,7 @@ Inspect the target dir (`[target-dir]`, default cwd) before touching anything:
58
58
 
59
59
  ## Progressive loading — bootstrap or upgrade, never both
60
60
 
61
- The reference allowlist is exactly the four linked paths below. Never invent or
61
+ The reference allowlist is exactly the five linked paths below. Never invent or
62
62
  read another `references/` path. Step 0 selects exactly one route:
63
63
 
64
64
  **Hard rule for an existing Claude Code, Cursor, Copilot, or OpenCode
@@ -69,8 +69,12 @@ or portability unless the runtime platform has no named adapter at all.
69
69
  - Bare/foreign repository: read
70
70
  [bootstrap discovery](references/BOOTSTRAP_DISCOVERY.md), complete its
71
71
  interview, then read [bootstrap write](references/BOOTSTRAP_WRITE.md).
72
+ Both the discovery and write routes load
73
+ [SERENA language servers](references/SERENA.md) for stack detection.
72
74
  - Existing agentic-workflow scaffold: read [upgrade](references/UPGRADE.md)
73
75
  only. Its additive-only rule is absolute; do not replay bootstrap.
76
+ The upgrade route loads [SERENA language servers](references/SERENA.md)
77
+ for the additive provisioning step.
74
78
  - No named adapter exists for the detected runtime platform: add
75
79
  [portability](references/PORTABILITY.md) only for that fallback. Claude Code,
76
80
  Cursor, Copilot, and OpenCode are named supported adapters; a missing adapter
@@ -99,6 +103,12 @@ loads exactly upgrade and skips bootstrap plus portability.
99
103
  on its own. **The urgency label seeding is additive-only the same way:**
100
104
  create a missing `urgent`/`fix-next` label, never touch one that already
101
105
  exists (name, color, or description a project already customized).
106
+ - **Serena convention.** `.serena/project.yml` is committed; `project.local.yml`
107
+ and `cache` stay local. Never write `.serena/` config without consent. LSP
108
+ never verifies documents — false positives happen when a file type's server
109
+ is not listed (markdown/yaml/json are never auto-detected), and a listed
110
+ server that fails to start must be removed (one failure disables every
111
+ server in the project). Document verification is a script's job.
102
112
  - **Never redefine the urgency label vocabulary here.** `skills/triage-issue
103
113
  /SKILL.md` is the sole owner of the `urgent`/`fix-next` names, colors, and
104
114
  apply rules — this skill only seeds those two labels into the repo; it never
@@ -178,6 +188,8 @@ OpenCode adapter does not load this fallback.
178
188
  listed as a residual when its hook API/dependency was unavailable.
179
189
  The adaptive lane conventions (unit document, triage, catalog steps, guards,
180
190
  evidence, commit formats) are written into AGENTS.md.
191
+ - The Serena project config is provisioned (or reported as a residual with the
192
+ reason, and the health check ran).
181
193
  - **The closing `→ Next:` block is printed** (plus the offer to install the skills):
182
194
 
183
195
  ```
@@ -67,4 +67,5 @@
67
67
  means Starlight). If no, leave the block commented out —
68
68
  then reports NOT-CONFIGURED instead of guessing. Never scaffold the
69
69
  website itself.
70
- - **Naming conventions** and **MCP servers**, if any.
70
+ - **Naming conventions**, **MCP servers**, and **Serena language servers** (see
71
+ [SERENA](SERENA.md) for the mapping), if any.
@@ -21,11 +21,17 @@
21
21
  optional: an absent document means no project invariants are declared, not
22
22
  that the scaffold failed. If retained, add its documentation-map row and
23
23
  name the project's explicit architectural-decision authority.
24
- 6. **Propagate the unit doc template.** Ensure
24
+ 7. **Propagate the unit doc template.** Ensure
25
25
  `docs/features/_TEMPLATE/SPEC.md` exists with all 13 mandatory sections.
26
26
  This is the template used when creating new unit documents.
27
-
28
- 7. **Install accepted agent safety hooks.** Keep the canonical policy and
27
+ 8. **Provision the Serena project config.** Load
28
+ [SERENA](SERENA.md), ask consent, then: (a) append the `.gitignore`
29
+ block additively (skip when already present); (b) write `.serena/project.yml`
30
+ with the `language_servers` list for every detected language (never overwrite
31
+ an existing file — report it); (c) run the health check from SERENA.md and
32
+ record the result. If Serena is not installed, offer install or record as a
33
+ residual.
34
+ 9. **Install accepted agent safety hooks.** Keep the canonical policy and
29
35
  fullauto wrapper at `.agentic-workflow/hooks/`. Seed the shipped
30
36
  path-protection policy at `.agentic-workflow/path-policy.json` and its doc
31
37
  page at `.agentic-workflow/path-protection.md`, beside the hooks — the
@@ -38,10 +44,10 @@
38
44
  an unknown payload, or an existing customized hook file becomes a residual,
39
45
  never a silent overwrite. Direct merges remain blocked; do not create a
40
46
  `.automerge` exception.
41
- 8. **Offer the workflow skills.** Propose installing them:
47
+ 10. **Offer the workflow skills.** Propose installing them:
42
48
  `npx skills add gtrabanco/agentic-workflow` (note the SSH/local-path variant if
43
49
  the source is private). Don't install without a yes.
44
- 9. **State that reviews are self-contained; offer optional extras.** The
50
+ 11. **State that reviews are self-contained; offer optional extras.** The
45
51
  workflow ships its **own internal review pack** (`review-code`,
46
52
  `review-security`, `review-verify`, `review-debt`, `review-design`,
47
53
  `review-a11y`, `review-brand`, `review-perf`, `review-seo`) — it installs
@@ -51,7 +57,7 @@
51
57
  under a short "Optional review extras" note so `review-change` and
52
58
  `product-audit` run them **in addition** — never as a dependency. Don't
53
59
  install anything without a yes.
54
- 10. **Seed the urgency labels (feature 15, injection-safe urgency).** Create the
60
+ 12. **Seed the urgency labels (feature 15, injection-safe urgency).** Create the
55
61
  two capability-gated GitHub labels `triage-issue` owns and applies
56
62
  (`skills/triage-issue/SKILL.md` is the sole owner of the name/color
57
63
  vocabulary — this step only seeds it, never redefines it):
@@ -64,7 +70,7 @@
64
70
  forge is unavailable or the user declines forge setup, skip this step and
65
71
  list the two labels as a residual for the user to create manually later
66
72
  (never fail the whole scaffold on it).
67
- 12. **Report.** List what was created, which placeholders still need human input,
73
+ 13. **Report.** List what was created, which placeholders still need human input,
68
74
  the companion skills recorded/installed, the urgency labels seeded (or
69
75
  skipped, with reason), and the next step:
70
76
  `discover-repository-state` → `/unit-lane` (or `workflow-status`) → catalog
@@ -0,0 +1,133 @@
1
+ ## Why this step exists
2
+
3
+ LSP-backed navigation and verification must actually work. A project-local
4
+ `.serena/project.yml` that pins the wrong `language_servers` silently disables
5
+ the server, and every symbol call then times out with
6
+ `Language server status: not initialized` (verified 2026-09-24 in this
7
+ repository).
8
+
9
+ ## The convention this file owns
10
+
11
+ - `.serena/project.yml` is **committed** to version control (the project's
12
+ config, not a dev-local override).
13
+ - `.serena/project.local.yml` and `.serena/cache/` stay local (tool-internal
14
+ state).
15
+ - The target's `.gitignore` must therefore ignore every `.serena` tree **except
16
+ the root `project.yml`** — the block below enforces that.
17
+
18
+ ## What to write into the target .gitignore
19
+
20
+ When the target's `.gitignore` does not already contain the block below, append
21
+ it (additive only — see rules below):
22
+
23
+ ```text
24
+ # Ignore every .serena tree (tool-local state); version ONLY the root project
25
+ # config. Serena's own .serena/.gitignore keeps cache and per-dev overrides local.
26
+ **/.serena/
27
+ !.serena/
28
+ .serena/*
29
+ !.serena/project.yml
30
+ ```
31
+
32
+ **Rules:**
33
+
34
+ - **Additive only** — append the block when it is absent.
35
+ - If the target already ignores `.serena` wholesale (e.g. `.serena/` without
36
+ an `!` carve-out), **propose the narrowing** and apply it **only with consent**.
37
+ - **Never rewrite unrelated lines.** Only touch the `.serena` block.
38
+ - **Idempotent** — skip when the exact block (all four lines) is already
39
+ present.
40
+
41
+ ## Language servers for the detected stack
42
+
43
+ Write language servers in this order: **fixed entries first** (always written,
44
+ regardless of the target), then **detected entries** (from Step 0 signals).
45
+
46
+ ### Fixed entries — always written
47
+
48
+ | Entry | Why always |
49
+ |---|---|
50
+ | `markdown` | Every scaffolded project has `docs/` and `.md` files (this workflow's own documentation is Markdown) |
51
+ | `json` | Manifests and tool config (`package.json`, `tsconfig.json`, settings) exist in nearly every stack |
52
+
53
+ ### Never auto-detected rule
54
+
55
+ **`markdown`, `yaml`, and `json` are never auto-detected by Serena — they must
56
+ appear in `language_servers` explicitly, or requests for those file types fall
57
+ back to another server and produce false positives** (e.g. TypeScript diagnostics
58
+ on a `.yml`). Every server listed above (and `yaml` when `.yml`/`.yaml` files
59
+ are present) is explicit-only.
60
+
61
+ ### Detected entries — append when present
62
+
63
+ | Detected | Entry |
64
+ |---|---|
65
+ | `.yml` / `.yaml` files present (CI workflows, config) | `yaml` |
66
+ | `package.json` / `tsconfig.json` (TypeScript or JavaScript) | `typescript` |
67
+ | `pyproject.toml` / `requirements.txt` | `python` |
68
+ | `go.mod` | `go` |
69
+ | `Cargo.toml` | `rust` |
70
+ | `pom.xml` / `build.gradle` | `java` |
71
+ | `Gemfile` | `ruby` |
72
+ | `composer.json` | `php` |
73
+ | `CMakeLists.txt` / `Makefile` (C/C++) | `cpp` |
74
+ | `.csproj` / `.sln` | `csharp` |
75
+ | shell-dominant repo (`*.sh` prevalent) | `bash` |
76
+
77
+ The authoritative list of valid values is Serena's own `ls_config.py`
78
+ (`LanguageServerId`). An id Serena does not know about is silently ignored by
79
+ Serena — **the health check below is what proves the list is correct.**
80
+
81
+ ## Ask first
82
+
83
+ Never write `.serena/` or `.gitignore` without an explicit yes, and report
84
+ exactly what was written. (This matches the skill's existing installation-consent
85
+ rule.)
86
+
87
+ ## Health check — fail closed at the claim
88
+
89
+ After activation, confirm **each listed server** initialised by asking Serena
90
+ for the project status and verifying `Language server status: initialized` for
91
+ every language in `language_servers`. If a server did not initialise, the
92
+ check names the offending entry. Removing a failing entry (per the startup
93
+ rule below) is the correct remediation — never leave a dead entry in the file.
94
+ **Never claim LSP evidence for a run whose server never started.**
95
+
96
+ ### Startup rule (critical)
97
+
98
+ > **A server that fails to start must not stay listed.** One failing entry
99
+ > disables the whole language-server manager — every symbol call for the
100
+ > project then fails with `Language server manager is not initialized`.
101
+ > After writing the config, the health check must confirm **every** listed
102
+ > server started; any entry that fails is removed from `language_servers`
103
+ > and recorded as a residual with its exact error (e.g. `markdown`: marksman
104
+ > needs the system `libicu` package).
105
+
106
+ ## When a server is missing or fails
107
+
108
+ `markdown`, `yaml`, and `json` servers exist but are explicit-only and
109
+ experimental: they must be listed by hand or requests fall back and produce
110
+ false positives.
111
+
112
+ - **Missing (not listed)** → fallback server answers → false positives for that
113
+ file type (e.g. TypeScript diagnostics on a `.yml`).
114
+ - **Failing (listed but broken)** → whole manager down → every symbol call
115
+ fails with `Language server manager is not initialized`. Remediation: remove
116
+ the entry from `language_servers` and record the residual (e.g. `markdown`:
117
+ marksman needs the system `libicu` package).
118
+
119
+ Document verification (schema rules like required sections/columns) stays a
120
+ deterministic script's job regardless — LSP serves navigation and diagnostics,
121
+ not our document grammar. `node`+`npm` are required for json/yaml; `libicu` is
122
+ required for marksman.
123
+
124
+ ## Tool install is a different step
125
+
126
+ This file owns the **project config**, not the tool. If Serena is not installed,
127
+ offer the install (ask-first; `uv`/`uvx` preferred, `pipx` fallback) or record
128
+ it as a residual. The install half is tracked by #219.
129
+
130
+ ## Degraded path
131
+
132
+ Serena unavailable → warn once, continue, and record the absence in the unit's
133
+ evidence. **Never block a run on it.**