spectoflow 0.30.0 → 0.31.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/README.md CHANGED
@@ -101,6 +101,7 @@ spectoflow list agents, skills and the workflow a
101
101
  spectoflow agents list the team personas
102
102
  spectoflow skills list the procedures
103
103
  spectoflow workflow show the enabled pipeline steps
104
+ spectoflow workflow suggest [--apply] which steps fit the project now (code yet? tests? E2E? infra/data?)
104
105
 
105
106
  spectoflow --version (-v) print the version
106
107
  spectoflow --help (-h) show help (append -h to any command for its help)
@@ -204,6 +205,11 @@ that another website sent — a malicious page, a DNS-rebinding trick, or a tunn
204
205
  machine can't drive it. Clicking a link to it from another site still opens it. To reach your projects from
205
206
  another device, use the online dashboard ([below](#going-online-optional-local-hub-vs-relay-server)).
206
207
 
208
+ **A custom agent command needs your OK.** `config.json → runners` sets the command that launches each agent,
209
+ and that file is committed — it can come from a cloned repository or a teammate's change. A command other than
210
+ the agent's default only runs once you allow it on this machine: Personalize → *Agent & automation* → **Allow
211
+ on this machine**, or `spectoflow runners allow <agent>`. Change the command and it asks again.
212
+
207
213
  ### One hub, every project
208
214
 
209
215
  There is only ever **one dashboard process on your machine**, no matter how many projects you have.
@@ -280,6 +286,8 @@ off by default):
280
286
  noted yourself — edit / resolve / delete, or **validate → task**.
281
287
  - **Backlog** — a flat sortable/filterable, paginated table of every task, defaulting to open work.
282
288
  - **Workflow** — the pipeline as step cards; click one to enable/disable it, which edits `workflow.md`.
289
+ **Analyze the project** proposes the steps that fit it now, each with its reason — you apply the ones you
290
+ tick (see [A workflow that fits the project](#a-workflow-that-fits-the-project)).
283
291
  - **Agents & Skills** — enriched cards that open a full-body markdown drawer.
284
292
  - **Files** — a project file tree with read / write / create, syntax-highlighted (self-hosted Prism.js).
285
293
  - **Bloc note** *(off by default)* — a per-project post-it Markdown scratchpad.
@@ -398,6 +406,26 @@ agents then read and grow your second brain through that server: nothing is copi
398
406
  A run started from either of those can't write into it, and a learned fact never goes into a project's chat
399
407
  log.
400
408
 
409
+ ## A workflow that fits the project
410
+
411
+ The workflow (`.spectoflow/workflow.md`) isn't one-size-fits-all anymore.
412
+
413
+ - **At `init`**, spectoflow looks at the files: is there code yet, tests, an end-to-end setup (Playwright,
414
+ Cypress), infrastructure (Terraform, Helm) or data (dbt, notebooks)? A project still in design gets
415
+ Brainstorm, Analysis, Spec and Plan — and *Develop*, *Unit tests* and *Review* stay off until there is code.
416
+ Integration and end-to-end tests are switched on only if the project already has them. `init` tells you
417
+ what it switched off and why. An existing `workflow.md` is never touched.
418
+ - **At the first session**, your agent reviews it with you: a file scan can't tell a prototype from a
419
+ product, the agent can, from your docs, specs and plans.
420
+ - **As the project moves on**, when a request needs a step that is off (asked to write code while *Develop*
421
+ is off…), the agent asks you first — or, with Personalize → *Let the agent enable workflow steps when
422
+ needed* (`workflowAutoEnable` in `config.json`), it enables the step itself and tells you. It never
423
+ disables a step on its own.
424
+ - **For a project initialized before this**, run `spectoflow workflow suggest` (add `--apply` to apply), or
425
+ use Workflow → **Analyze the project** in the dashboard and tick the changes you want.
426
+
427
+ You can still switch any step on or off by hand, anytime.
428
+
401
429
  ## Agents vs skills
402
430
 
403
431
  Agents (`.spectoflow/agents/`) are **stable team personas** (Product Manager, Developer, QA Engineer…).
package/bin/spectoflow.js CHANGED
@@ -374,6 +374,50 @@ function configCmd() {
374
374
  } catch (e) { console.log(`${c.y('!')} ${e.message}`); process.exitCode = 1; }
375
375
  }
376
376
 
377
+ // ---- workflow: show it, or suggest the steps that fit the project now (D75) ----
378
+ function workflowCmd() {
379
+ if (argv[1] !== 'suggest') { console.log(wordmark()); printWorkflow(false); return; }
380
+ const root = process.cwd();
381
+ if (!fs.existsSync(path.join(root, '.spectoflow', 'workflow.md'))) { console.log('No spectoflow project here. Run: spectoflow init'); process.exitCode = 1; return; }
382
+ const wd = require('../lib/workflow-detect');
383
+ const cfg = store.readConfig(root);
384
+ const s = wd.suggest(root, { projectType: cfg.projectType });
385
+ const apply = argv.includes('--apply');
386
+ console.log(wordmark());
387
+ console.log(` ${c.bold('spectoflow workflow suggest')}${apply ? c.dim(' (apply)') : ''}`);
388
+ console.log(` ${c.dim('detected:')} ${s.projectType} project · ${s.phase === 'design' ? 'design phase (no code yet)' : 'has code'}${s.truncated ? c.dim(' (large project: scan stopped early)') : ''}\n`);
389
+ if (!s.changes.length) { console.log(` ${c.g('✓')} The workflow already matches the project.\n`); return; }
390
+ const w = Math.max(...s.changes.map((ch) => ch.name.length));
391
+ for (const ch of s.changes) console.log(` ${ch.to ? c.g('●') : c.dim('○')} ${ch.name.padEnd(w)} ${ch.from ? 'on' : 'off'} ${c.amber('→')} ${ch.to ? c.g('on ') : c.y('off')} ${c.dim(wd.REASONS[ch.reason])}`);
392
+ if (!apply) { console.log(`\n ${c.dim('apply them:')} ${c.g('spectoflow workflow suggest --apply')} ${c.dim('— or pick in the dashboard: Workflow → Analyze the project')}\n`); return; }
393
+ const changed = wd.applySteps(root, Object.fromEntries(s.changes.map((ch) => [ch.name, ch.to])));
394
+ console.log(`\n ${c.g('✓')} ${changed.length} step(s) updated in .spectoflow/workflow.md\n`);
395
+ }
396
+
397
+ // ---- runners: the commands that launch agents, and which custom ones are allowed on this machine (D76) ----
398
+ function runnersCmd() {
399
+ const root = process.cwd();
400
+ if (!fs.existsSync(path.join(root, '.spectoflow', 'config.json'))) { console.log('No spectoflow project here. Run: spectoflow init'); process.exitCode = 1; return; }
401
+ const trust = require('../lib/runner-trust');
402
+ const runners = store.readConfig(root).runners || {};
403
+ if (argv[1] === 'allow') {
404
+ const which = argv[2];
405
+ if (!which || typeof runners[which] !== 'string') { console.log(`${c.y('!')} Usage: spectoflow runners allow <agent> ${c.dim('— one of: ' + (Object.keys(runners).join(', ') || 'none'))}`); process.exitCode = 1; return; }
406
+ if (trust.isDefault(which, runners[which])) { console.log(`${c.dim('·')} ${which} uses the default command — nothing to allow.`); return; }
407
+ trust.trust(root, which, runners[which]);
408
+ console.log(`${c.g('✓')} allowed on this machine for this project: ${c.bold(which)} → ${runners[which]}`);
409
+ return;
410
+ }
411
+ console.log(wordmark());
412
+ const w = Math.max(4, ...Object.keys(runners).map((k) => k.length));
413
+ for (const [which, cmd] of Object.entries(runners)) {
414
+ const state = trust.isDefault(which, cmd) ? c.dim('default') : trust.isTrusted(root, which, cmd) ? c.g('allowed') : c.y('needs your OK');
415
+ console.log(` ${which.padEnd(w)} ${state.padEnd(24)} ${cmd}`);
416
+ }
417
+ if (!Object.keys(runners).length) console.log(c.dim(' (no runners in config.json)'));
418
+ if (trust.untrusted(root, { runners }).length) console.log(`\n ${c.dim('a custom command only runs once allowed:')} ${c.g('spectoflow runners allow <agent>')}\n`);
419
+ }
420
+
377
421
  // ---- brain: the user's second brain (~/.spectoflow/brain.md), shared by every project ----
378
422
  function brainCmd() {
379
423
  const brain = require('../lib/brain');
@@ -699,7 +743,7 @@ const HELP = {
699
743
  (or the bundled kit when run outside a project) at a glance.`,
700
744
  agents: `${c.bold('spectoflow agents')}\n List the stable team personas (name · capability · role).`,
701
745
  skills: `${c.bold('spectoflow skills')}\n List the evolving procedures (name · capability · what it does).`,
702
- workflow: `${c.bold('spectoflow workflow')}\n Show the pipeline steps, marking which are enabled (●) or disabled (○).`,
746
+ workflow: `${c.bold('spectoflow workflow')} ${c.dim('[suggest [--apply]]')}\n Show the pipeline steps, marking which are enabled (●) or disabled (○).\n ${c.g('suggest')} look at the project (is there code yet? tests? an E2E setup? infra or data?) and\n list the steps worth switching on or off, with the reason — nothing is written\n ${c.g('suggest --apply')} apply them (only those lines of .spectoflow/workflow.md change)`,
703
747
  brain: `${c.bold('spectoflow brain')} ${c.dim('[setup [--dry-run]]')}\n
704
748
  Your second brain — what spectoflow has learned about you (profile, preferences, working style,
705
749
  things to avoid), in ${c.dim('~/.spectoflow/brain.md')}, shared by all your projects and editable in the
@@ -708,6 +752,7 @@ const HELP = {
708
752
  ${c.g('brain setup')} register the MCP server in each installed agent's USER-level config (once
709
753
  per machine; never touches an existing entry; Goose gets a snippet to paste)
710
754
  Learned facts are added directly by default: ${c.g('spectoflow config set brain.autoAdd false')} to confirm them first.`,
755
+ runners: `${c.bold('spectoflow runners')} ${c.dim('[allow <agent>]')}\n\n The commands that launch each agent (${c.dim('.spectoflow/config.json → runners')}). A command other than the\n agent's default only runs once you allow it on this machine — config.json is committed, so it can come\n from a cloned repository, a teammate, or an agent's edit.\n ${c.g('runners')} list them: default · allowed · needs your OK\n ${c.g('runners allow <agent>')} allow that agent's current command, for this project, on this machine`,
711
756
  stop: `${c.bold('spectoflow stop')}\n Stop the running dashboard (alias for ${c.g('spectoflow dashboard stop')}).`,
712
757
  config: `${c.bold('spectoflow config')} ${c.dim('[get <key> | set <key> <value>]')}\n
713
758
  Global settings that apply to every project on this machine, stored in ${c.dim('~/.spectoflow/config.json')}:
@@ -725,9 +770,10 @@ const fns = {
725
770
  config: configCmd,
726
771
  mcp: () => require('../lib/mcp-server').serve({ version: VERSION }),
727
772
  brain: brainCmd,
773
+ runners: runnersCmd,
728
774
  agents: () => { console.log(wordmark()); printAgents(false); },
729
775
  skills: () => { console.log(wordmark()); printSkills(false); },
730
- workflow: () => { console.log(wordmark()); printWorkflow(false); },
776
+ workflow: workflowCmd,
731
777
  skill: () => runCustomize('skill'),
732
778
  agent: () => runCustomize('agent'),
733
779
  };
package/lib/adapters.js CHANGED
@@ -30,6 +30,10 @@ learn one (a stated preference, a correction of how you work, their role), recor
30
30
  \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
31
31
  secrets or sensitive data.
32
32
 
33
+ **Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
34
+ *Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
35
+ otherwise ask first. Never disable a step without the user.
36
+
33
37
  - Command: \`/spectoflow\` (\`init\` / \`status\` / or just a request).
34
38
  - Dashboard: \`spectoflow dashboard\` → http://localhost:4319
35
39
  - Artifacts are markdown in \`specs/\` and \`plans/\`; volatile state in \`.spectoflow/runtime.json\`.
@@ -48,6 +52,10 @@ is clear — then execute. See the Clarify reflex in \`.spectoflow/SPECTOFLOW.md
48
52
  learn one (a stated preference, a correction of how you work, their role), record it with \`brain_learn\`, or print
49
53
  \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
50
54
  secrets or sensitive data.
55
+
56
+ **Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
57
+ *Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
58
+ otherwise ask first. Never disable a step without the user.
51
59
  `;
52
60
 
53
61
  const GEMINI_MD = `# GEMINI.md — spectoflow
@@ -64,6 +72,10 @@ is clear — then execute. See the Clarify reflex in \`.spectoflow/SPECTOFLOW.md
64
72
  learn one (a stated preference, a correction of how you work, their role), record it with \`brain_learn\`, or print
65
73
  \`::spectoflow learn category=<profile|preferences|workflow|avoid> msg=<fact>\` if that tool is unavailable. Never
66
74
  secrets or sensitive data.
75
+
76
+ **Workflow.** If a request needs a step that is disabled in \`.spectoflow/workflow.md\` (e.g. writing code while
77
+ *Develop* is off): enable it yourself and say so when \`.spectoflow/config.json\` → \`workflowAutoEnable\` is \`true\`;
78
+ otherwise ask first. Never disable a step without the user.
67
79
  `;
68
80
 
69
81
  const SLASH_CMD = `---
@@ -38,6 +38,15 @@ function realUnderRoot(root, abs) {
38
38
  return real;
39
39
  }
40
40
 
41
+ // Files that make tools run commands on their own — the agent launcher (config.json runners), hooks,
42
+ // MCP server definitions, editor tasks. Someone editing through the online dashboard must never be able
43
+ // to change them: that would turn a project write into running anything on the owner's machine (D76).
44
+ const EXEC_CONFIG = ['.git', '.spectoflow/config.json', '.spectoflow/hooks', '.spectoflow/lib', '.claude', '.mcp.json', '.cursor', '.codex', '.gemini', '.kiro', '.vscode', '.idea', '.husky'];
45
+ function isExecConfig(rel) {
46
+ const n = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase();
47
+ return EXEC_CONFIG.some((p) => n === p || n.startsWith(p + '/'));
48
+ }
49
+
41
50
  function isUnderGit(rel) {
42
51
  const n = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
43
52
  return n === '.git' || n.startsWith('.git/');
@@ -83,9 +92,10 @@ function readFile(root, rel) {
83
92
  return { content: buf.toString('utf8') };
84
93
  }
85
94
 
86
- function writeFile(root, rel, content) {
95
+ function writeFile(root, rel, content, { remote = false } = {}) {
87
96
  if (typeof content !== 'string') return { error: 'Missing content.' };
88
97
  if (isUnderGit(rel)) return { error: 'Writes under .git are blocked.' };
98
+ if (remote && isExecConfig(rel)) return { error: 'This file controls commands run on the owner\'s machine: it can only be edited locally.' };
89
99
  const abs = safePath(root, rel);
90
100
  if (!abs) return { error: 'Invalid path.' };
91
101
  const parentDir = path.dirname(abs);
@@ -95,8 +105,9 @@ function writeFile(root, rel, content) {
95
105
  return { ok: true };
96
106
  }
97
107
 
98
- function mkdir(root, rel) {
108
+ function mkdir(root, rel, { remote = false } = {}) {
99
109
  if (isUnderGit(rel)) return { error: 'Cannot create folders under .git.' };
110
+ if (remote && isExecConfig(rel)) return { error: 'This folder controls commands run on the owner\'s machine: it can only be changed locally.' };
100
111
  const abs = safePath(root, rel);
101
112
  if (!abs) return { error: 'Invalid path.' };
102
113
  const parentDir = path.dirname(abs);
@@ -105,4 +116,4 @@ function mkdir(root, rel) {
105
116
  return { ok: true };
106
117
  }
107
118
 
108
- module.exports = { tree, readFile, writeFile, mkdir };
119
+ module.exports = { tree, readFile, writeFile, mkdir, isExecConfig };
@@ -11,6 +11,7 @@ const { spawn } = require('child_process');
11
11
  const store = require('../store');
12
12
  const files = require('./files');
13
13
  const { resolveRunnerCommand } = require('./runner');
14
+ const runnerTrust = require('../runner-trust');
14
15
  const { formatLog } = require('./summarize'); // reused as-is rather than reimplemented — see report
15
16
 
16
17
  const TASK_LIMIT = 20; // mirrors summarize.js's own DEFAULT_LIMIT philosophy (cap, most-recent-last)
@@ -82,6 +83,8 @@ function runMeetingGenerate(root, { agent, date } = {}, emit) {
82
83
  const which = agent || cfg.agent || 'claude';
83
84
  const cmdStr = resolveRunnerCommand(root, cfg, which);
84
85
  if (!cmdStr) return { error: `No runner configured for "${which}".` };
86
+ const blocked = runnerTrust.blockedMessage(root, which, cmdStr);
87
+ if (blocked) return { error: blocked, untrustedRunner: { agent: which, command: cmdStr } };
85
88
 
86
89
  const day = date || todayLocal();
87
90
  if (!isValidDate(day)) return { error: 'Invalid date.' };
@@ -20,6 +20,8 @@ const detect = require('../detect');
20
20
  const brain = require('../brain');
21
21
  const brainSetup = require('../brain-setup');
22
22
  const globalConfig = require('../global-config');
23
+ const workflowDetect = require('../workflow-detect');
24
+ const runnerTrust = require('../runner-trust');
23
25
 
24
26
  const PKG_VERSION = require('../../package.json').version;
25
27
 
@@ -71,6 +73,7 @@ function writeConfig(root, patch, detectOpts) {
71
73
  if (Array.isArray(patch.expandedPhases)) cfg.expandedPhases = patch.expandedPhases.filter((v) => typeof v === 'string');
72
74
  if (typeof patch.activeTab === 'string' && patch.activeTab.trim()) cfg.activeTab = patch.activeTab.trim();
73
75
  if (typeof patch.chatOpen === 'boolean') cfg.chatOpen = patch.chatOpen;
76
+ if (typeof patch.workflowAutoEnable === 'boolean') cfg.workflowAutoEnable = patch.workflowAutoEnable;
74
77
  // kanbanColumns: reject the whole patch (leave the current value untouched) rather than silently
75
78
  // filtering out bad entries — an invalid/unknown status id here means the client sent something it
76
79
  // shouldn't have, and a real product-safety rule (never persist zero visible columns) applies too.
@@ -161,14 +164,25 @@ const ops = {
161
164
  // todayLocal() header comment for why this, not the browser's date, is the one source of truth
162
165
  // for which .spectoflow/meetings/<date>.md "today" resolves to.
163
166
  p.todayDate = todayLocal();
167
+ p.untrustedRunners = runnerTrust.untrusted(root, p.config);
164
168
  return p;
165
169
  },
166
170
  'agentfile.read': async (root, { path: rel }) => readAgentFile(root, rel),
167
171
 
168
172
  'files.tree': async (root) => ({ tree: files.tree(root) }),
169
173
  'files.read': async (root, { path: rel }) => filesResult(files.readFile(root, rel || '')),
170
- 'files.write': async (root, { path: rel, content }, ctx) => changed(ctx, filesResult(files.writeFile(root, rel, content))),
171
- 'files.mkdir': async (root, { path: rel }, ctx) => changed(ctx, filesResult(files.mkdir(root, rel))),
174
+ 'files.write': async (root, { path: rel, content }, ctx) => changed(ctx, filesResult(files.writeFile(root, rel, content, { remote: !!ctx.remote }))),
175
+ 'files.mkdir': async (root, { path: rel }, ctx) => changed(ctx, filesResult(files.mkdir(root, rel, { remote: !!ctx.remote }))),
176
+
177
+ // Allow, on this machine, the custom command config.json sets for one agent (D76). Local only: the relay
178
+ // doesn't list this op, and ctx.remote is refused here too.
179
+ 'runners.trust': async (root, { agent }, ctx) => {
180
+ if (ctx && ctx.remote) throw new OpError(404, 'Unknown operation.');
181
+ const cmd = ((store.readConfig(root).runners) || {})[agent];
182
+ if (typeof cmd !== 'string' || !cmd.trim()) notFound(`No custom command set for "${agent}".`);
183
+ runnerTrust.trust(root, agent, cmd);
184
+ return changed(ctx, { ok: true, agent, command: cmd });
185
+ },
172
186
 
173
187
  'task.add': async (root, { title, phase, file, owner, level }, ctx) => {
174
188
  const t = store.addTask(root, { title: text(title, 'A title is required.'), phase, file, owner, level });
@@ -200,6 +214,20 @@ const ops = {
200
214
  return changed(ctx, { ok: true });
201
215
  },
202
216
 
217
+ // Re-analysis (D75): what would fit the project now, and applying the steps the user picked. Apply
218
+ // recomputes the suggestion server-side and writes only those of the listed names it still contains.
219
+ 'workflow.suggest': async (root) => {
220
+ const s = workflowDetect.suggest(root, { projectType: store.readConfig(root).projectType });
221
+ return { ...s, reasons: workflowDetect.REASONS };
222
+ },
223
+ 'workflow.apply': async (root, { names }, ctx) => {
224
+ if (!Array.isArray(names) || !names.every((n) => typeof n === 'string')) bad('names must be a list of step names.');
225
+ const s = workflowDetect.suggest(root);
226
+ const picked = s.changes.filter((c) => names.includes(c.name));
227
+ const written = workflowDetect.applySteps(root, Object.fromEntries(picked.map((c) => [c.name, c.to])));
228
+ return written.length ? changed(ctx, { changed: written }) : { changed: written };
229
+ },
230
+
203
231
  'run.start': async (root, { prompt, agent, display }, ctx) => {
204
232
  text(prompt, 'Empty request.');
205
233
  const r = startRun(root, { prompt, agent, display, learn: !ctx.remote }, ctx.emit);
@@ -90,7 +90,7 @@ function defaultRunStep({ root, step, agent, skill, request, learn = true }, emi
90
90
  // logPrompt:false — the orchestrator already posts a clean "→ step (agent)" line; the raw
91
91
  // priming prompt would otherwise show as a noisy user bubble.
92
92
  const r = startRun(root, { prompt, agent: tool, logPrompt: false, learn }, (e) => { emit(e); if (e.type === 'run-end') resolve(e.code); });
93
- if (r.error) { emit({ type: 'message', message: { role: 'orchestrator', kind: 'status', text: r.error } }); resolve(1); }
93
+ if (r.error) { post(root, 'orchestrator', 'status', r.error, emit); resolve(1); }
94
94
  });
95
95
  }
96
96
 
@@ -376,6 +376,21 @@ function renderApproval(container){
376
376
  const acts=el('div','c-actions'); acts.append(a,c); row.append(acts);
377
377
  container.append(row); scrollChat(container);
378
378
  }
379
+ // A launch refused before any agent ran (no runner, a custom command not allowed yet…) has no run-start/
380
+ // run-end to show for it: say so in the chat, without persisting anything.
381
+ function showChatError(message){
382
+ chatContainers().forEach(container=>{
383
+ clearIdle(container);
384
+ const st=stateFor(container); st.rawBlock=null;
385
+ container.append(el('div','msg chat-error',message));
386
+ scrollChat(container);
387
+ });
388
+ }
389
+ async function postRunRequest(url,body){
390
+ const r=await fetch(withProject(url),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
391
+ if(!r.ok){ const d=await r.json().catch(()=>({})); showChatError(d.error||t('files.saveError')); if(/needs your OK/.test(d.error||'')) scheduleLoad(); }
392
+ return r.ok;
393
+ }
379
394
  function appendRaw(chunk){
380
395
  chatContainers().forEach(container=>{
381
396
  const st=stateFor(container);
@@ -413,7 +428,7 @@ async function doRun(promptEl,agentEl){
413
428
  const agent=agentEl.value;
414
429
  const ex=expandForSend(raw);
415
430
  const body=ex ? {prompt:ex.prompt, display:ex.display, agent} : {prompt:raw, agent};
416
- await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
431
+ if(!(await postRunRequest('/api/run',body))) return;
417
432
  promptEl.value=''; hideCmdMenu(); // the prompt renders as a bubble from the message log
418
433
  }
419
434
  async function doOrchestrate(promptEl){
@@ -421,7 +436,7 @@ async function doOrchestrate(promptEl){
421
436
  promptEl=promptEl||$('#runPrompt');
422
437
  const raw=promptEl.value.trim(); if(!raw) return;
423
438
  const ex=expandForSend(raw);
424
- await fetch(withProject('/api/orchestrate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request: ex?ex.prompt:raw})});
439
+ if(!(await postRunRequest('/api/orchestrate',{request: ex?ex.prompt:raw}))) return;
425
440
  promptEl.value=''; hideCmdMenu();
426
441
  }
427
442
  // ---- slash-command autocomplete: a single reused #cmdMenu popover, anchored above whichever chat
@@ -488,7 +503,7 @@ async function summarizeChat(agentEl){
488
503
  if(isChatBusy()) return;
489
504
  const agent=(agentEl||$('#tabRunAgent'))?.value;
490
505
  flash();
491
- await fetch(withProject('/api/chat/summarize'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent})});
506
+ await postRunRequest('/api/chat/summarize',{agent});
492
507
  }
493
508
  async function clearChat(){
494
509
  flash();
@@ -1009,6 +1024,72 @@ function renderWorkflow(){
1009
1024
  box.append(pipe);
1010
1025
  if(wfPopStep) renderWfPop(); // re-anchor / refresh an open popover after a live re-render
1011
1026
  }
1027
+ // ---- Workflow → Analyze the project (D75): what the file analysis would switch on or off now. Nothing is
1028
+ // written until the user applies the ticked changes. Lives outside #wfDiagram so SSE re-renders keep it open.
1029
+ let wfSuggestion=null, wfSuggestPicked=null, wfSuggestNote='';
1030
+ async function analyzeWorkflow(){
1031
+ const box=$('#wfSuggest'); if(!box) return;
1032
+ box.hidden=false; box.innerHTML=''; box.append(el('div','empty',t('drawer.loading')));
1033
+ try{
1034
+ const r=await fetch(withProject('/api/workflow/suggest'));
1035
+ const d=await r.json().catch(()=>({}));
1036
+ if(!r.ok) throw new Error(d.error||'error');
1037
+ wfSuggestion=d; wfSuggestPicked=new Set(d.changes.map(c=>c.name)); wfSuggestNote='';
1038
+ }catch(err){ wfSuggestion=null; wfSuggestNote=t('workflow.analyzeError'); }
1039
+ renderWfSuggest();
1040
+ }
1041
+ function closeWfSuggest(){ wfSuggestion=null; wfSuggestNote=''; const box=$('#wfSuggest'); if(box){ box.hidden=true; box.innerHTML=''; } }
1042
+ function renderWfSuggest(){
1043
+ const box=$('#wfSuggest'); if(!box) return;
1044
+ box.innerHTML='';
1045
+ if(!wfSuggestion){ if(wfSuggestNote){ box.hidden=false; box.append(el('div','wf-suggest-note',wfSuggestNote)); } return; }
1046
+ const d=wfSuggestion; box.hidden=false;
1047
+ const head=el('div','wf-suggest-head');
1048
+ head.append(el('span','wf-suggest-detected',t('workflow.detected',{type:t('workflow.type.'+d.projectType),phase:t('workflow.phase.'+d.phase)})));
1049
+ const x=el('button','wf-suggest-close','×'); x.type='button'; x.title=t('workflow.dismiss'); x.setAttribute('aria-label',t('workflow.dismiss')); x.addEventListener('click',closeWfSuggest);
1050
+ head.append(x); box.append(head);
1051
+ if(wfSuggestNote) box.append(el('div','wf-suggest-note',wfSuggestNote));
1052
+ if(!d.changes.length){ box.append(el('div','wf-suggest-ok',t('workflow.matches'))); return; }
1053
+ const list=el('div','wf-suggest-list');
1054
+ d.changes.forEach(c=>{
1055
+ const row=el('label','wf-suggest-row');
1056
+ const cb=el('input'); cb.type='checkbox'; cb.checked=wfSuggestPicked.has(c.name);
1057
+ cb.addEventListener('change',()=>{ if(cb.checked) wfSuggestPicked.add(c.name); else wfSuggestPicked.delete(c.name); applyBtn.disabled=!wfSuggestPicked.size; });
1058
+ const change=el('span','wf-suggest-change '+(c.to?'is-on':'is-off'),`${c.from?t('workflow.on'):t('workflow.off')} → ${c.to?t('workflow.on'):t('workflow.off')}`);
1059
+ row.append(cb, el('span','wf-suggest-name',c.name), change, el('span','wf-suggest-reason',t('workflow.reason.'+c.reason)));
1060
+ list.append(row);
1061
+ });
1062
+ box.append(list);
1063
+ const acts=el('div','wf-suggest-actions');
1064
+ const applyBtn=el('button','btn primary',t('workflow.apply')); applyBtn.type='button'; applyBtn.disabled=!wfSuggestPicked.size;
1065
+ applyBtn.addEventListener('click',async()=>{
1066
+ applyBtn.disabled=true; flash();
1067
+ try{
1068
+ const r=await fetch(withProject('/api/workflow/apply'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({names:[...wfSuggestPicked]})});
1069
+ const res=await r.json().catch(()=>({}));
1070
+ if(!r.ok) throw new Error(res.error||'error');
1071
+ wfSuggestion=null; wfSuggestNote=t('workflow.applied',{n:(res.changed||[]).length});
1072
+ }catch(err){ wfSuggestNote=t('workflow.analyzeError'); }
1073
+ renderWfSuggest(); scheduleLoad();
1074
+ });
1075
+ const cancel=el('button','btn',t('workflow.dismiss')); cancel.type='button'; cancel.addEventListener('click',closeWfSuggest);
1076
+ acts.append(applyBtn,cancel); box.append(acts);
1077
+ }
1078
+ // Personalize → a custom agent command set by config.json, not yet allowed on this machine (D76).
1079
+ function renderUntrustedRunners(){
1080
+ const box=$('#setRunnersTrust'); if(!box) return;
1081
+ const list=(!REMOTE && P && P.untrustedRunners) || [];
1082
+ box.innerHTML=''; box.hidden=!list.length;
1083
+ if(!list.length) return;
1084
+ box.append(el('div','runner-trust-title',t('runners.title')), el('div','runner-trust-hint',t('runners.hint')));
1085
+ list.forEach(r=>{
1086
+ const row=el('div','runner-trust-row');
1087
+ row.append(el('span','runner-trust-agent',r.agent), el('code','runner-trust-cmd',r.command));
1088
+ const b=el('button','btn',t('runners.allow')); b.type='button';
1089
+ b.addEventListener('click',async()=>{ b.disabled=true; flash(); await fetch(withProject('/api/runners/trust'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:r.agent})}); scheduleLoad(); });
1090
+ row.append(b); box.append(row);
1091
+ });
1092
+ }
1012
1093
  function wfDetailRow(k,v){ const r=el('div','wf-detail-row'); r.append(el('span','wf-detail-k',k), el('span','wf-detail-v',v)); return r; }
1013
1094
  function wfPopFill(pop, s, idx){
1014
1095
  const skill=(P.skills||[]).find(x=>x.name===s.skill);
@@ -1229,6 +1310,8 @@ function renderSettings(){
1229
1310
  setModeSelects(c.mode||'semi');
1230
1311
  setLangSelect(c.language||'en');
1231
1312
  setAgentSelects();
1313
+ const wfAuto=$('#setWorkflowAuto'); if(wfAuto) wfAuto.checked=!!c.workflowAutoEnable;
1314
+ renderUntrustedRunners();
1232
1315
  // design switcher — options from the DESIGNS registry (designs.js)
1233
1316
  const dsel=$('#setDesign');
1234
1317
  if(dsel){
@@ -1423,7 +1506,7 @@ function renderCustomize(){
1423
1506
  async function czSubmit(kind,description,agent){
1424
1507
  const cfg=CZ_KINDS.find((c)=>c.kind===kind);
1425
1508
  const prompt=description?cfg.promptAdd(description):cfg.promptAuto;
1426
- await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
1509
+ await postRunRequest('/api/run',{prompt,agent});
1427
1510
  const root=$('#czRoot'); if(root) root.dataset.open='';
1428
1511
  navigateTab('chat');
1429
1512
  }
@@ -2558,6 +2641,8 @@ $('#blAddCancel').addEventListener('click', closeBacklogAddForm);
2558
2641
  $('#blAddSubmit').addEventListener('click', submitBacklogAdd);
2559
2642
  $('#blAddTitle').addEventListener('keydown', e=>{ if(e.key==='Enter') submitBacklogAdd(); });
2560
2643
  // attention tab: add a note + filter chips
2644
+ $('#wfAnalyzeBtn').addEventListener('click',analyzeWorkflow);
2645
+ $('#setWorkflowAuto').addEventListener('change',(e)=>{ if(P&&P.config) P.config.workflowAutoEnable=e.target.checked; saveSetting({workflowAutoEnable:e.target.checked}); });
2561
2646
  $('#brainAutoAdd').addEventListener('change',(e)=>brainAct(()=>brainCall('POST','/api/brain/settings',{autoAdd:e.target.checked})));
2562
2647
  $('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
2563
2648
  $('#attnInput').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ const v=e.target.value.trim(); if(v){ addAttn(v); e.target.value=''; } } });
@@ -90,6 +90,8 @@ en: {
90
90
  'drawer.agent':'Agent','drawer.skill':'Skill','drawer.file':'File · {rel}',
91
91
  'drawer.loading':'Loading…','drawer.loadError':'Could not load this file.','files.title':'Files','files.sub':'Browse the project\'s files — view Markdown & HTML, edit any text file, create new ones.','files.newFile':'+ File','files.newFolder':'+ Folder','files.pickFile':'Select a file to view it.','files.empty':'No files yet.','files.edit':'Edit','files.preview':'Preview','files.save':'Save','files.saved':'✓ saved','files.saveError':'Could not save this file.','files.loadError':'Could not load this file.','files.binary':'This file can\'t be previewed here (not text).','files.discardConfirm':'Discard unsaved changes?','files.newFilePrompt':'New file name (e.g. todo.md):','files.newFolderPrompt':'New folder name:','files.projectRoot':'project root','files.creatingIn':'Creating in: {path}','files.refresh':'Refresh','files.discard':'Discard','files.create':'Create',
92
92
  'notes.sub':'A freeform scratchpad for this project — Markdown, autosaved as you type. Only you (and whoever else opens this dashboard) can see it.','notes.saving':'Saving…',
93
+ 'runners.title':'A custom command needs your OK','runners.hint':'This project’s config.json launches an agent with a command other than its default. It only runs once you allow it on this machine.','runners.allow':'Allow on this machine',
94
+ 'workflow.analyze':'Analyze the project','workflow.analyzeTitle':'Look at the project and suggest which steps fit it now','workflow.detected':'Detected: {type} · {phase}','workflow.type.app':'application','workflow.type.infra':'infrastructure','workflow.type.data':'data','workflow.phase.design':'design phase, no code yet','workflow.phase.build':'has code','workflow.matches':'Your workflow already matches the project.','workflow.apply':'Apply','workflow.dismiss':'Close','workflow.applied':'{n} step(s) updated.','workflow.analyzeError':'Could not analyze the project.','workflow.on':'on','workflow.off':'off','workflow.reason.always':'always useful','workflow.reason.design-no-code':'no code yet','workflow.reason.has-code':'the project has code','workflow.reason.has-tests':'the project has tests','workflow.reason.infra-no-tests':'infrastructure project with no tests','workflow.reason.data-quality':'data project — data quality tests','workflow.reason.has-integration-tests':'integration tests exist','workflow.reason.no-integration-tests':'no integration tests yet','workflow.reason.has-e2e-setup':'an end-to-end test setup exists','workflow.reason.no-e2e-setup':'no end-to-end test setup','settings.workflowAuto':'Let the agent enable workflow steps when needed','settings.workflowAutoHint':'Off: it asks you first. It never disables a step on its own.',
93
95
  'brain.sub':'What spectoflow has learned about you, shared by all your projects and given to your agent in every session. Add or fix anything.','brain.autoAdd':'Add what the agent learns directly','brain.autoAddOn':'New facts are added right away — you can fix or delete them here.','brain.autoAddOff':'New facts wait in “To confirm” until you accept them.','brain.agents':'Reachable by:','brain.agentWired':'Connected: this agent reads and grows your second brain','brain.agentNotWired':'Not connected yet','brain.setupHint':'Run {cmd} to connect the others.','brain.noAgents':'No coding agent found on this machine.','brain.tooMany':'{n} entries — all of it is given to your agent in every session. Consider removing what is no longer true.','brain.empty':'Nothing yet. As you work, your agent notes durable things about you here — your role, your preferences, how you like to work, what to avoid. You can also add them yourself below.','brain.toConfirm':'To confirm','brain.confirm':'Confirm','brain.confirmAll':'Confirm all','brain.reject':'Reject','brain.cat.profile':'Profile','brain.cat.preferences':'Preferences','brain.cat.workflow':'Working style','brain.cat.avoid':'Avoid','brain.catHint.profile':'Who you are: role, skills, context.','brain.catHint.preferences':'Tools, languages, code style, formats you prefer.','brain.catHint.workflow':'How you like the agent to work with you.','brain.catHint.avoid':'What the agent should never do.','brain.catEmpty':'Nothing here yet.','brain.addPlaceholder':'Add a fact…','brain.duplicate':'Already in your second brain.','brain.byAgent':'learned by the agent','brain.byYou':'added by you','brain.path':'Stored in {path} — never inside a project.','brain.error':'Could not reach your second brain.',
94
96
  'meeting.sub':'One dated note per day for this project — write it yourself, or have the active agent draft it from recent tasks and chat activity.','meeting.history':'History','meeting.today':'today','meeting.generate':'Generate','meeting.generateTitle':'Generate today\'s note from recent activity','meeting.overwriteWarn':'This will overwrite today\'s note.','meeting.generateAnyway':'Generate anyway',
95
97
  'chat.widgetTitle':'Run an agent','chat.widgetSub':'Quick access · full view in the Chat tab',
@@ -210,6 +212,8 @@ fr: {
210
212
  'drawer.agent':'Agent','drawer.skill':'Compétence','drawer.file':'Fichier · {rel}',
211
213
  'drawer.loading':'Chargement…','drawer.loadError':'Impossible de charger ce fichier.','files.title':'Fichiers','files.sub':'Parcourez les fichiers du projet — visualisez Markdown et HTML, modifiez tout fichier texte, créez-en de nouveaux.','files.newFile':'+ Fichier','files.newFolder':'+ Dossier','files.pickFile':'Sélectionnez un fichier pour l’afficher.','files.empty':'Aucun fichier pour l’instant.','files.edit':'Modifier','files.preview':'Aperçu','files.save':'Enregistrer','files.saved':'✓ enregistré','files.saveError':'Impossible d’enregistrer ce fichier.','files.loadError':'Impossible de charger ce fichier.','files.binary':'Ce fichier ne peut pas être prévisualisé ici (non textuel).','files.discardConfirm':'Abandonner les modifications non enregistrées ?','files.newFilePrompt':'Nom du nouveau fichier (ex. todo.md) :','files.newFolderPrompt':'Nom du nouveau dossier :','files.projectRoot':'racine du projet','files.creatingIn':'Création dans : {path}','files.refresh':'Actualiser','files.discard':'Annuler','files.create':'Créer',
212
214
  'notes.sub':'Un bloc-note libre pour ce projet — Markdown, enregistré automatiquement au fil de la frappe. Visible uniquement par vous (et quiconque ouvre ce tableau de bord).','notes.saving':'Enregistrement…',
215
+ 'runners.title':'Une commande personnalisée attend votre accord','runners.hint':'Le config.json de ce projet lance un agent avec une commande différente de celle par défaut. Elle ne s’exécute qu’une fois autorisée sur cette machine.','runners.allow':'Autoriser sur cette machine',
216
+ 'workflow.analyze':'Analyser le projet','workflow.analyzeTitle':'Examiner le projet et proposer les étapes qui lui conviennent maintenant','workflow.detected':'Détecté : {type} · {phase}','workflow.type.app':'application','workflow.type.infra':'infrastructure','workflow.type.data':'données','workflow.phase.design':'phase de conception, pas encore de code','workflow.phase.build':'contient du code','workflow.matches':'Votre workflow correspond déjà au projet.','workflow.apply':'Appliquer','workflow.dismiss':'Fermer','workflow.applied':'{n} étape(s) mise(s) à jour.','workflow.analyzeError':'Impossible d’analyser le projet.','workflow.on':'activée','workflow.off':'désactivée','workflow.reason.always':'toujours utile','workflow.reason.design-no-code':'pas encore de code','workflow.reason.has-code':'le projet contient du code','workflow.reason.has-tests':'le projet a des tests','workflow.reason.infra-no-tests':'projet d’infrastructure sans tests','workflow.reason.data-quality':'projet de données — tests de qualité des données','workflow.reason.has-integration-tests':'des tests d’intégration existent','workflow.reason.no-integration-tests':'pas encore de tests d’intégration','workflow.reason.has-e2e-setup':'une configuration de tests de bout en bout existe','workflow.reason.no-e2e-setup':'pas de configuration de tests de bout en bout','settings.workflowAuto':'Laisser l’agent activer les étapes du workflow quand il faut','settings.workflowAutoHint':'Désactivé : il vous demande d’abord. Il ne désactive jamais une étape de lui-même.',
213
217
  'brain.sub':'Ce que spectoflow a appris sur vous, partagé par tous vos projets et donné à votre agent à chaque session. Ajoutez ou corrigez ce que vous voulez.','brain.autoAdd':'Ajouter directement ce que l’agent apprend','brain.autoAddOn':'Les nouveaux faits sont ajoutés tout de suite — vous pouvez les corriger ou les supprimer ici.','brain.autoAddOff':'Les nouveaux faits attendent dans « À confirmer » jusqu’à votre validation.','brain.agents':'Accessible par :','brain.agentWired':'Connecté : cet agent lit et enrichit votre second cerveau','brain.agentNotWired':'Pas encore connecté','brain.setupHint':'Lancez {cmd} pour connecter les autres.','brain.noAgents':'Aucun agent de code trouvé sur cette machine.','brain.tooMany':'{n} entrées — tout est donné à votre agent à chaque session. Pensez à retirer ce qui n’est plus vrai.','brain.empty':'Rien pour l’instant. Au fil de votre travail, votre agent note ici des choses durables sur vous — votre rôle, vos préférences, votre façon de travailler, ce qu’il faut éviter. Vous pouvez aussi les ajouter vous-même ci-dessous.','brain.toConfirm':'À confirmer','brain.confirm':'Confirmer','brain.confirmAll':'Tout confirmer','brain.reject':'Rejeter','brain.cat.profile':'Profil','brain.cat.preferences':'Préférences','brain.cat.workflow':'Façon de travailler','brain.cat.avoid':'À éviter','brain.catHint.profile':'Qui vous êtes : rôle, compétences, contexte.','brain.catHint.preferences':'Outils, langues, style de code, formats que vous préférez.','brain.catHint.workflow':'Comment vous aimez que l’agent travaille avec vous.','brain.catHint.avoid':'Ce que l’agent ne doit jamais faire.','brain.catEmpty':'Rien ici pour l’instant.','brain.addPlaceholder':'Ajouter un fait…','brain.duplicate':'Déjà dans votre second cerveau.','brain.byAgent':'appris par l’agent','brain.byYou':'ajouté par vous','brain.path':'Stocké dans {path} — jamais dans un projet.','brain.error':'Impossible d’accéder à votre second cerveau.',
214
218
  'meeting.sub':'Une note datée par jour pour ce projet — rédigez-la vous-même, ou laissez l’agent actif la rédiger à partir des tâches récentes et du chat.','meeting.history':'Historique','meeting.today':'aujourd’hui','meeting.generate':'Générer','meeting.generateTitle':'Générer la note du jour à partir de l’activité récente','meeting.overwriteWarn':'Cela va écraser la note d’aujourd’hui.','meeting.generateAnyway':'Générer quand même',
215
219
  'chat.widgetTitle':'Lancer un agent','chat.widgetSub':'Accès rapide · vue complète dans l’onglet Chat',
@@ -330,6 +334,8 @@ es: {
330
334
  'drawer.agent':'Agente','drawer.skill':'Habilidad','drawer.file':'Archivo · {rel}',
331
335
  'drawer.loading':'Cargando…','drawer.loadError':'No se pudo cargar este archivo.','files.title':'Archivos','files.sub':'Explora los archivos del proyecto — visualiza Markdown y HTML, edita cualquier archivo de texto, crea otros nuevos.','files.newFile':'+ Archivo','files.newFolder':'+ Carpeta','files.pickFile':'Selecciona un archivo para verlo.','files.empty':'Aún no hay archivos.','files.edit':'Editar','files.preview':'Vista previa','files.save':'Guardar','files.saved':'✓ guardado','files.saveError':'No se pudo guardar este archivo.','files.loadError':'No se pudo cargar este archivo.','files.binary':'Este archivo no se puede previsualizar aquí (no es texto).','files.discardConfirm':'¿Descartar los cambios sin guardar?','files.newFilePrompt':'Nombre del nuevo archivo (p. ej. todo.md):','files.newFolderPrompt':'Nombre de la nueva carpeta:','files.projectRoot':'raíz del proyecto','files.creatingIn':'Creando en: {path}','files.refresh':'Actualizar','files.discard':'Descartar','files.create':'Crear',
332
336
  'notes.sub':'Un bloc de notas libre para este proyecto — Markdown, guardado automáticamente mientras escribes. Solo tú (y quien más abra este panel) puedes verlo.','notes.saving':'Guardando…',
337
+ 'runners.title':'Un comando personalizado espera tu aprobación','runners.hint':'El config.json de este proyecto lanza un agente con un comando distinto del predeterminado. Solo se ejecuta cuando lo autorizas en esta máquina.','runners.allow':'Autorizar en esta máquina',
338
+ 'workflow.analyze':'Analizar el proyecto','workflow.analyzeTitle':'Examinar el proyecto y proponer los pasos que le convienen ahora','workflow.detected':'Detectado: {type} · {phase}','workflow.type.app':'aplicación','workflow.type.infra':'infraestructura','workflow.type.data':'datos','workflow.phase.design':'fase de diseño, aún sin código','workflow.phase.build':'tiene código','workflow.matches':'Tu workflow ya corresponde al proyecto.','workflow.apply':'Aplicar','workflow.dismiss':'Cerrar','workflow.applied':'{n} paso(s) actualizado(s).','workflow.analyzeError':'No se pudo analizar el proyecto.','workflow.on':'activado','workflow.off':'desactivado','workflow.reason.always':'siempre útil','workflow.reason.design-no-code':'aún no hay código','workflow.reason.has-code':'el proyecto tiene código','workflow.reason.has-tests':'el proyecto tiene tests','workflow.reason.infra-no-tests':'proyecto de infraestructura sin tests','workflow.reason.data-quality':'proyecto de datos — tests de calidad de datos','workflow.reason.has-integration-tests':'existen tests de integración','workflow.reason.no-integration-tests':'aún no hay tests de integración','workflow.reason.has-e2e-setup':'existe una configuración de tests end-to-end','workflow.reason.no-e2e-setup':'no hay configuración de tests end-to-end','settings.workflowAuto':'Dejar que el agente active pasos del workflow cuando haga falta','settings.workflowAutoHint':'Desactivado: te pregunta antes. Nunca desactiva un paso por su cuenta.',
333
339
  'brain.sub':'Lo que spectoflow ha aprendido sobre ti, compartido por todos tus proyectos y dado a tu agente en cada sesión. Añade o corrige lo que quieras.','brain.autoAdd':'Añadir directamente lo que aprende el agente','brain.autoAddOn':'Los nuevos datos se añaden al instante — puedes corregirlos o borrarlos aquí.','brain.autoAddOff':'Los nuevos datos esperan en «Por confirmar» hasta que los aceptes.','brain.agents':'Accesible por:','brain.agentWired':'Conectado: este agente lee y amplía tu segundo cerebro','brain.agentNotWired':'Aún no conectado','brain.setupHint':'Ejecuta {cmd} para conectar los demás.','brain.noAgents':'No se encontró ningún agente de código en esta máquina.','brain.tooMany':'{n} entradas — todo se da a tu agente en cada sesión. Plantéate quitar lo que ya no sea cierto.','brain.empty':'Nada todavía. Mientras trabajas, tu agente anota aquí cosas duraderas sobre ti — tu rol, tus preferencias, cómo te gusta trabajar, qué evitar. También puedes añadirlas tú abajo.','brain.toConfirm':'Por confirmar','brain.confirm':'Confirmar','brain.confirmAll':'Confirmar todo','brain.reject':'Rechazar','brain.cat.profile':'Perfil','brain.cat.preferences':'Preferencias','brain.cat.workflow':'Forma de trabajar','brain.cat.avoid':'Evitar','brain.catHint.profile':'Quién eres: rol, habilidades, contexto.','brain.catHint.preferences':'Herramientas, idiomas, estilo de código, formatos que prefieres.','brain.catHint.workflow':'Cómo te gusta que el agente trabaje contigo.','brain.catHint.avoid':'Lo que el agente nunca debe hacer.','brain.catEmpty':'Nada aquí todavía.','brain.addPlaceholder':'Añadir un dato…','brain.duplicate':'Ya está en tu segundo cerebro.','brain.byAgent':'aprendido por el agente','brain.byYou':'añadido por ti','brain.path':'Guardado en {path} — nunca dentro de un proyecto.','brain.error':'No se pudo acceder a tu segundo cerebro.',
334
340
  'meeting.sub':'Una nota fechada por día para este proyecto — escríbela tú mismo, o deja que el agente activo la redacte a partir de las tareas y el chat recientes.','meeting.history':'Historial','meeting.today':'hoy','meeting.generate':'Generar','meeting.generateTitle':'Generar la nota de hoy a partir de la actividad reciente','meeting.overwriteWarn':'Esto sobrescribirá la nota de hoy.','meeting.generateAnyway':'Generar de todos modos',
335
341
  'chat.widgetTitle':'Ejecutar un agente','chat.widgetSub':'Acceso rápido · vista completa en la pestaña Chat',
@@ -450,6 +456,8 @@ de: {
450
456
  'drawer.agent':'Agent','drawer.skill':'Skill','drawer.file':'Datei · {rel}',
451
457
  'drawer.loading':'Lädt…','drawer.loadError':'Diese Datei konnte nicht geladen werden.','files.title':'Dateien','files.sub':'Durchsuche die Projektdateien — Markdown & HTML ansehen, jede Textdatei bearbeiten, neue erstellen.','files.newFile':'+ Datei','files.newFolder':'+ Ordner','files.pickFile':'Wähle eine Datei aus, um sie anzuzeigen.','files.empty':'Noch keine Dateien.','files.edit':'Bearbeiten','files.preview':'Vorschau','files.save':'Speichern','files.saved':'✓ gespeichert','files.saveError':'Diese Datei konnte nicht gespeichert werden.','files.loadError':'Diese Datei konnte nicht geladen werden.','files.binary':'Diese Datei kann hier nicht angezeigt werden (kein Text).','files.discardConfirm':'Nicht gespeicherte Änderungen verwerfen?','files.newFilePrompt':'Name der neuen Datei (z. B. todo.md):','files.newFolderPrompt':'Name des neuen Ordners:','files.projectRoot':'Projektstamm','files.creatingIn':'Erstellen in: {path}','files.refresh':'Aktualisieren','files.discard':'Verwerfen','files.create':'Erstellen',
452
458
  'notes.sub':'Ein freier Notizblock für dieses Projekt — Markdown, automatisch gespeichert während der Eingabe. Nur du (und wer sonst dieses Dashboard öffnet) siehst ihn.','notes.saving':'Speichert…',
459
+ 'runners.title':'Ein eigener Befehl braucht deine Zustimmung','runners.hint':'Die config.json dieses Projekts startet einen Agenten mit einem anderen als dem Standardbefehl. Er läuft erst, wenn du ihn auf diesem Rechner erlaubst.','runners.allow':'Auf diesem Rechner erlauben',
460
+ 'workflow.analyze':'Projekt analysieren','workflow.analyzeTitle':'Das Projekt ansehen und vorschlagen, welche Schritte jetzt passen','workflow.detected':'Erkannt: {type} · {phase}','workflow.type.app':'Anwendung','workflow.type.infra':'Infrastruktur','workflow.type.data':'Daten','workflow.phase.design':'Entwurfsphase, noch kein Code','workflow.phase.build':'enthält Code','workflow.matches':'Dein Workflow passt bereits zum Projekt.','workflow.apply':'Anwenden','workflow.dismiss':'Schließen','workflow.applied':'{n} Schritt(e) aktualisiert.','workflow.analyzeError':'Das Projekt konnte nicht analysiert werden.','workflow.on':'an','workflow.off':'aus','workflow.reason.always':'immer nützlich','workflow.reason.design-no-code':'noch kein Code','workflow.reason.has-code':'das Projekt enthält Code','workflow.reason.has-tests':'das Projekt hat Tests','workflow.reason.infra-no-tests':'Infrastrukturprojekt ohne Tests','workflow.reason.data-quality':'Datenprojekt — Datenqualitätstests','workflow.reason.has-integration-tests':'Integrationstests vorhanden','workflow.reason.no-integration-tests':'noch keine Integrationstests','workflow.reason.has-e2e-setup':'ein End-to-End-Test-Setup ist vorhanden','workflow.reason.no-e2e-setup':'kein End-to-End-Test-Setup','settings.workflowAuto':'Den Agenten Workflow-Schritte bei Bedarf aktivieren lassen','settings.workflowAutoHint':'Aus: er fragt dich vorher. Er deaktiviert nie selbst einen Schritt.',
453
461
  'brain.sub':'Was spectoflow über dich gelernt hat — geteilt von all deinen Projekten und deinem Agenten in jeder Sitzung mitgegeben. Ergänze oder korrigiere, was du willst.','brain.autoAdd':'Was der Agent lernt, direkt hinzufügen','brain.autoAddOn':'Neue Fakten werden sofort hinzugefügt — du kannst sie hier korrigieren oder löschen.','brain.autoAddOff':'Neue Fakten warten unter „Zu bestätigen“, bis du sie annimmst.','brain.agents':'Erreichbar für:','brain.agentWired':'Verbunden: dieser Agent liest und erweitert dein zweites Gehirn','brain.agentNotWired':'Noch nicht verbunden','brain.setupHint':'Führe {cmd} aus, um die anderen zu verbinden.','brain.noAgents':'Kein Coding-Agent auf diesem Rechner gefunden.','brain.tooMany':'{n} Einträge — alles wird deinem Agenten in jeder Sitzung mitgegeben. Entferne, was nicht mehr stimmt.','brain.empty':'Noch nichts. Während du arbeitest, notiert dein Agent hier Dauerhaftes über dich — deine Rolle, deine Vorlieben, wie du gern arbeitest, was zu vermeiden ist. Du kannst sie auch unten selbst hinzufügen.','brain.toConfirm':'Zu bestätigen','brain.confirm':'Bestätigen','brain.confirmAll':'Alle bestätigen','brain.reject':'Ablehnen','brain.cat.profile':'Profil','brain.cat.preferences':'Vorlieben','brain.cat.workflow':'Arbeitsweise','brain.cat.avoid':'Vermeiden','brain.catHint.profile':'Wer du bist: Rolle, Fähigkeiten, Kontext.','brain.catHint.preferences':'Werkzeuge, Sprachen, Code-Stil, Formate, die du bevorzugst.','brain.catHint.workflow':'Wie der Agent mit dir arbeiten soll.','brain.catHint.avoid':'Was der Agent nie tun soll.','brain.catEmpty':'Hier ist noch nichts.','brain.addPlaceholder':'Fakt hinzufügen…','brain.duplicate':'Schon in deinem zweiten Gehirn.','brain.byAgent':'vom Agenten gelernt','brain.byYou':'von dir hinzugefügt','brain.path':'Gespeichert in {path} — nie in einem Projekt.','brain.error':'Dein zweites Gehirn ist nicht erreichbar.',
454
462
  'meeting.sub':'Eine datierte Notiz pro Tag für dieses Projekt — schreibe sie selbst, oder lass sie vom aktiven Agenten aus den letzten Aufgaben und dem Chat entwerfen.','meeting.history':'Verlauf','meeting.today':'heute','meeting.generate':'Generieren','meeting.generateTitle':'Die heutige Notiz aus der letzten Aktivität generieren','meeting.overwriteWarn':'Dies überschreibt die heutige Notiz.','meeting.generateAnyway':'Trotzdem generieren',
455
463
  'chat.widgetTitle':'Agenten ausführen','chat.widgetSub':'Schnellzugriff · vollständige Ansicht im Chat-Tab',
@@ -570,6 +578,8 @@ pt: {
570
578
  'drawer.agent':'Agente','drawer.skill':'Habilidade','drawer.file':'Ficheiro · {rel}',
571
579
  'drawer.loading':'A carregar…','drawer.loadError':'Não foi possível carregar este ficheiro.','files.title':'Ficheiros','files.sub':'Percorra os ficheiros do projeto — veja Markdown e HTML, edite qualquer ficheiro de texto, crie novos.','files.newFile':'+ Ficheiro','files.newFolder':'+ Pasta','files.pickFile':'Selecione um ficheiro para o ver.','files.empty':'Ainda não há ficheiros.','files.edit':'Editar','files.preview':'Pré-visualizar','files.save':'Guardar','files.saved':'✓ guardado','files.saveError':'Não foi possível guardar este ficheiro.','files.loadError':'Não foi possível carregar este ficheiro.','files.binary':'Este ficheiro não pode ser pré-visualizado aqui (não é texto).','files.discardConfirm':'Descartar alterações não guardadas?','files.newFilePrompt':'Nome do novo ficheiro (ex. todo.md):','files.newFolderPrompt':'Nome da nova pasta:','files.projectRoot':'raiz do projeto','files.creatingIn':'A criar em: {path}','files.refresh':'Atualizar','files.discard':'Descartar','files.create':'Criar',
572
580
  'notes.sub':'Um bloco de notas livre para este projeto — Markdown, guardado automaticamente enquanto escreve. Só você (e quem mais abrir este painel) o vê.','notes.saving':'A guardar…',
581
+ 'runners.title':'Um comando personalizado aguarda a sua aprovação','runners.hint':'O config.json deste projeto inicia um agente com um comando diferente do predefinido. Só é executado depois de o autorizar nesta máquina.','runners.allow':'Autorizar nesta máquina',
582
+ 'workflow.analyze':'Analisar o projeto','workflow.analyzeTitle':'Examinar o projeto e propor os passos que lhe convêm agora','workflow.detected':'Detetado: {type} · {phase}','workflow.type.app':'aplicação','workflow.type.infra':'infraestrutura','workflow.type.data':'dados','workflow.phase.design':'fase de conceção, ainda sem código','workflow.phase.build':'tem código','workflow.matches':'O seu workflow já corresponde ao projeto.','workflow.apply':'Aplicar','workflow.dismiss':'Fechar','workflow.applied':'{n} passo(s) atualizado(s).','workflow.analyzeError':'Não foi possível analisar o projeto.','workflow.on':'ativado','workflow.off':'desativado','workflow.reason.always':'sempre útil','workflow.reason.design-no-code':'ainda sem código','workflow.reason.has-code':'o projeto tem código','workflow.reason.has-tests':'o projeto tem testes','workflow.reason.infra-no-tests':'projeto de infraestrutura sem testes','workflow.reason.data-quality':'projeto de dados — testes de qualidade de dados','workflow.reason.has-integration-tests':'existem testes de integração','workflow.reason.no-integration-tests':'ainda sem testes de integração','workflow.reason.has-e2e-setup':'existe uma configuração de testes ponta a ponta','workflow.reason.no-e2e-setup':'sem configuração de testes ponta a ponta','settings.workflowAuto':'Deixar o agente ativar passos do workflow quando necessário','settings.workflowAutoHint':'Desativado: pergunta-lhe primeiro. Nunca desativa um passo por conta própria.',
573
583
  'brain.sub':'O que o spectoflow aprendeu sobre si, partilhado por todos os seus projetos e dado ao seu agente em cada sessão. Acrescente ou corrija o que quiser.','brain.autoAdd':'Adicionar diretamente o que o agente aprende','brain.autoAddOn':'Os novos factos são adicionados de imediato — pode corrigi-los ou apagá-los aqui.','brain.autoAddOff':'Os novos factos esperam em «A confirmar» até os aceitar.','brain.agents':'Acessível por:','brain.agentWired':'Ligado: este agente lê e enriquece o seu segundo cérebro','brain.agentNotWired':'Ainda não ligado','brain.setupHint':'Execute {cmd} para ligar os outros.','brain.noAgents':'Nenhum agente de código encontrado nesta máquina.','brain.tooMany':'{n} entradas — tudo é dado ao seu agente em cada sessão. Pense em retirar o que já não é verdade.','brain.empty':'Ainda nada. À medida que trabalha, o seu agente anota aqui coisas duradouras sobre si — o seu papel, as suas preferências, como gosta de trabalhar, o que evitar. Também as pode adicionar abaixo.','brain.toConfirm':'A confirmar','brain.confirm':'Confirmar','brain.confirmAll':'Confirmar tudo','brain.reject':'Rejeitar','brain.cat.profile':'Perfil','brain.cat.preferences':'Preferências','brain.cat.workflow':'Forma de trabalhar','brain.cat.avoid':'A evitar','brain.catHint.profile':'Quem é: papel, competências, contexto.','brain.catHint.preferences':'Ferramentas, línguas, estilo de código, formatos que prefere.','brain.catHint.workflow':'Como gosta que o agente trabalhe consigo.','brain.catHint.avoid':'O que o agente nunca deve fazer.','brain.catEmpty':'Ainda nada aqui.','brain.addPlaceholder':'Adicionar um facto…','brain.duplicate':'Já está no seu segundo cérebro.','brain.byAgent':'aprendido pelo agente','brain.byYou':'adicionado por si','brain.path':'Guardado em {path} — nunca dentro de um projeto.','brain.error':'Não foi possível aceder ao seu segundo cérebro.',
574
584
  'meeting.sub':'Uma nota datada por dia para este projeto — escreva-a você mesmo, ou deixe o agente ativo redigi-la a partir das tarefas e do chat recentes.','meeting.history':'Histórico','meeting.today':'hoje','meeting.generate':'Gerar','meeting.generateTitle':'Gerar a nota de hoje a partir da atividade recente','meeting.overwriteWarn':'Isto vai substituir a nota de hoje.','meeting.generateAnyway':'Gerar mesmo assim',
575
585
  'chat.widgetTitle':'Executar um agente','chat.widgetSub':'Acesso rápido · vista completa no separador Chat',
@@ -690,6 +700,8 @@ it: {
690
700
  'drawer.agent':'Agente','drawer.skill':'Skill','drawer.file':'File · {rel}',
691
701
  'drawer.loading':'Caricamento…','drawer.loadError':'Impossibile caricare questo file.','files.title':'File','files.sub':'Sfoglia i file del progetto — visualizza Markdown e HTML, modifica qualsiasi file di testo, creane di nuovi.','files.newFile':'+ File','files.newFolder':'+ Cartella','files.pickFile':'Seleziona un file per visualizzarlo.','files.empty':'Nessun file ancora.','files.edit':'Modifica','files.preview':'Anteprima','files.save':'Salva','files.saved':'✓ salvato','files.saveError':'Impossibile salvare questo file.','files.loadError':'Impossibile caricare questo file.','files.binary':'Questo file non può essere visualizzato qui (non è testo).','files.discardConfirm':'Scartare le modifiche non salvate?','files.newFilePrompt':'Nome del nuovo file (es. todo.md):','files.newFolderPrompt':'Nome della nuova cartella:','files.projectRoot':'radice del progetto','files.creatingIn':'Creazione in: {path}','files.refresh':'Aggiorna','files.discard':'Scarta','files.create':'Crea',
692
702
  'notes.sub':'Un blocco note libero per questo progetto — Markdown, salvato automaticamente mentre scrivi. Solo tu (e chiunque altro apra questa dashboard) puoi vederlo.','notes.saving':'Salvataggio…',
703
+ 'runners.title':'Un comando personalizzato attende la tua approvazione','runners.hint':'Il config.json di questo progetto avvia un agente con un comando diverso da quello predefinito. Viene eseguito solo dopo che lo autorizzi su questa macchina.','runners.allow':'Autorizza su questa macchina',
704
+ 'workflow.analyze':'Analizza il progetto','workflow.analyzeTitle':'Esaminare il progetto e proporre i passi adatti ora','workflow.detected':'Rilevato: {type} · {phase}','workflow.type.app':'applicazione','workflow.type.infra':'infrastruttura','workflow.type.data':'dati','workflow.phase.design':'fase di progettazione, ancora nessun codice','workflow.phase.build':'contiene codice','workflow.matches':'Il tuo workflow corrisponde già al progetto.','workflow.apply':'Applica','workflow.dismiss':'Chiudi','workflow.applied':'{n} passo/i aggiornato/i.','workflow.analyzeError':'Impossibile analizzare il progetto.','workflow.on':'attivo','workflow.off':'disattivo','workflow.reason.always':'sempre utile','workflow.reason.design-no-code':'ancora nessun codice','workflow.reason.has-code':'il progetto contiene codice','workflow.reason.has-tests':'il progetto ha dei test','workflow.reason.infra-no-tests':'progetto di infrastruttura senza test','workflow.reason.data-quality':'progetto di dati — test di qualità dei dati','workflow.reason.has-integration-tests':'esistono test di integrazione','workflow.reason.no-integration-tests':'ancora nessun test di integrazione','workflow.reason.has-e2e-setup':'esiste una configurazione di test end-to-end','workflow.reason.no-e2e-setup':'nessuna configurazione di test end-to-end','settings.workflowAuto':'Lascia che l’agente attivi i passi del workflow quando serve','settings.workflowAutoHint':'Disattivato: ti chiede prima. Non disattiva mai un passo da solo.',
693
705
  'brain.sub':'Ciò che spectoflow ha imparato su di te, condiviso da tutti i tuoi progetti e dato al tuo agente in ogni sessione. Aggiungi o correggi ciò che vuoi.','brain.autoAdd':'Aggiungi direttamente ciò che l’agente impara','brain.autoAddOn':'I nuovi fatti vengono aggiunti subito — puoi correggerli o eliminarli qui.','brain.autoAddOff':'I nuovi fatti attendono in «Da confermare» finché non li accetti.','brain.agents':'Raggiungibile da:','brain.agentWired':'Collegato: questo agente legge e arricchisce il tuo secondo cervello','brain.agentNotWired':'Non ancora collegato','brain.setupHint':'Esegui {cmd} per collegare gli altri.','brain.noAgents':'Nessun agente di programmazione trovato su questa macchina.','brain.tooMany':'{n} voci — tutto viene dato al tuo agente in ogni sessione. Valuta di rimuovere ciò che non è più vero.','brain.empty':'Ancora niente. Mentre lavori, il tuo agente annota qui cose durature su di te — il tuo ruolo, le tue preferenze, come ti piace lavorare, cosa evitare. Puoi anche aggiungerle tu qui sotto.','brain.toConfirm':'Da confermare','brain.confirm':'Conferma','brain.confirmAll':'Conferma tutto','brain.reject':'Rifiuta','brain.cat.profile':'Profilo','brain.cat.preferences':'Preferenze','brain.cat.workflow':'Modo di lavorare','brain.cat.avoid':'Da evitare','brain.catHint.profile':'Chi sei: ruolo, competenze, contesto.','brain.catHint.preferences':'Strumenti, lingue, stile di codice, formati che preferisci.','brain.catHint.workflow':'Come ti piace che l’agente lavori con te.','brain.catHint.avoid':'Ciò che l’agente non deve mai fare.','brain.catEmpty':'Ancora niente qui.','brain.addPlaceholder':'Aggiungi un fatto…','brain.duplicate':'Già nel tuo secondo cervello.','brain.byAgent':'imparato dall’agente','brain.byYou':'aggiunto da te','brain.path':'Salvato in {path} — mai dentro un progetto.','brain.error':'Impossibile raggiungere il tuo secondo cervello.',
694
706
  'meeting.sub':'Una nota datata al giorno per questo progetto — scrivila tu stesso, oppure lascia che l’agente attivo la scriva a partire dalle attività e dalla chat recenti.','meeting.history':'Cronologia','meeting.today':'oggi','meeting.generate':'Genera','meeting.generateTitle':'Genera la nota di oggi dall’attività recente','meeting.overwriteWarn':'Questo sovrascriverà la nota di oggi.','meeting.generateAnyway':'Genera comunque',
695
707
  'chat.widgetTitle':'Avvia un agente','chat.widgetSub':'Accesso rapido · vista completa nella scheda Chat',
@@ -217,6 +217,10 @@
217
217
  <div class="wf-wrap">
218
218
  <h2 class="panel-title" data-i18n="workflow.title">Active workflow</h2>
219
219
  <p class="panel-sub" data-i18n-html="workflow.sub">The pipeline your requests flow through — from idea to shipped, spec-driven. <strong>Click a step</strong> to see what it does and turn it on or off (this edits <code>.spectoflow/workflow.md</code>). Enabled steps run in order; the orchestrator gates each one by your <em>mode</em> and <em>policy</em>. Optional steps stay off until you switch them on.</p>
220
+ <div class="wf-analyze">
221
+ <button class="btn" id="wfAnalyzeBtn" type="button" data-i18n="workflow.analyze" data-i18n-title="workflow.analyzeTitle" title="Look at the project and suggest which steps fit it now">Analyze the project</button>
222
+ <div class="wf-suggest" id="wfSuggest" hidden></div>
223
+ </div>
220
224
  <div class="wf-diagram" id="wfDiagram"></div>
221
225
  </div>
222
226
  </section>
@@ -389,6 +393,7 @@
389
393
  <select id="setAgent" class="settings-select"></select>
390
394
  <span class="settings-field-hint is-empty" id="setAgentHint" data-i18n="topbar.agent.none" hidden>No agent found</span>
391
395
  </label>
396
+ <div class="runner-trust" id="setRunnersTrust" hidden></div>
392
397
  <label class="settings-field">
393
398
  <span class="settings-field-label" data-i18n="field.mode">Autonomy mode</span>
394
399
  <select id="setMode" class="settings-select">
@@ -397,6 +402,13 @@
397
402
  <option value="manual" data-i18n="mode.manual.desc">manual — approve every step</option>
398
403
  </select>
399
404
  </label>
405
+ <label class="settings-check">
406
+ <input type="checkbox" id="setWorkflowAuto">
407
+ <span class="settings-check-text">
408
+ <span class="settings-check-label" data-i18n="settings.workflowAuto">Let the agent enable workflow steps when needed</span>
409
+ <span class="settings-check-hint" data-i18n="settings.workflowAutoHint">Off: it asks you first. It never disables a step on its own.</span>
410
+ </span>
411
+ </label>
400
412
  </div>
401
413
  <div class="settings-card">
402
414
  <h3 class="settings-card-title" data-i18n="settings.group.appearance">Appearance &amp; language</h3>
@@ -659,6 +659,41 @@ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:
659
659
  .attn-edit { width:100%; min-height:64px; font-family:inherit; font-size:14px; padding:8px; border:1px solid var(--cool); border-radius:8px; background:var(--surface-2); color:var(--ink); box-sizing:border-box; }
660
660
  .attn-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:11px; }
661
661
 
662
+ /* ---- Custom agent commands awaiting the user's OK (D76) ---- */
663
+ .runner-trust { border:1px solid color-mix(in srgb,var(--s-blocked) 45%,var(--line)); border-radius:var(--radius); padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
664
+ .runner-trust[hidden] { display:none; }
665
+ .runner-trust-title { font-size:13px; font-weight:600; color:var(--s-blocked); }
666
+ .runner-trust-hint { font-size:12px; color:var(--muted); }
667
+ .runner-trust-row { display:flex; flex-wrap:wrap; align-items:center; gap:6px 10px; }
668
+ .runner-trust-agent { font-weight:600; font-size:13px; }
669
+ .runner-trust-cmd { font-family:var(--mono); font-size:11.5px; background:var(--surface-2); border:1px solid var(--line); border-radius:5px; padding:2px 6px; overflow-wrap:anywhere; flex:1 1 180px; }
670
+ .msg.chat-error { color:var(--s-blocked); border:1px solid color-mix(in srgb,var(--s-blocked) 40%,var(--line)); border-radius:8px; padding:8px 10px; font-size:12.5px; white-space:pre-wrap; }
671
+
672
+ /* ---- Workflow → Analyze the project ---- */
673
+ .wf-analyze { display:flex; flex-direction:column; align-items:flex-start; gap:10px; margin:2px 0 16px; }
674
+ .wf-suggest { align-self:stretch; border:1px solid var(--line); border-left:3px solid var(--signal); border-radius:var(--radius); background:var(--surface); padding:12px 14px; display:flex; flex-direction:column; gap:10px; max-width:820px; }
675
+ .wf-suggest[hidden] { display:none; }
676
+ .wf-suggest-head { display:flex; align-items:center; justify-content:space-between; gap:10px; }
677
+ .wf-suggest-detected { font-size:13px; font-weight:600; }
678
+ .wf-suggest-close { border:none; background:none; color:var(--muted); font-size:18px; line-height:1; cursor:pointer; padding:0 4px; }
679
+ .wf-suggest-close:hover { color:var(--ink); }
680
+ .wf-suggest-ok, .wf-suggest-note { font-size:13px; color:var(--muted); }
681
+ .wf-suggest-list { display:flex; flex-direction:column; gap:6px; }
682
+ .wf-suggest-row { display:grid; grid-template-columns:auto minmax(110px,max-content) auto 1fr; align-items:center; gap:6px 12px; font-size:13px; cursor:pointer; padding:6px 8px; border-radius:8px; background:var(--surface-2); }
683
+ .wf-suggest-row input { accent-color:var(--signal); margin:0; }
684
+ .wf-suggest-name { font-weight:600; }
685
+ .wf-suggest-change { font-family:var(--mono); font-size:11px; border:1px solid var(--line); border-radius:999px; padding:1px 8px; white-space:nowrap; }
686
+ .wf-suggest-change.is-on { color:var(--s-done); border-color:color-mix(in srgb,var(--s-done) 45%,var(--line)); }
687
+ .wf-suggest-change.is-off { color:var(--muted); }
688
+ .wf-suggest-reason { color:var(--muted); font-size:12.5px; }
689
+ .wf-suggest-actions { display:flex; gap:8px; }
690
+ @media (max-width:620px){ .wf-suggest-row { grid-template-columns:auto 1fr; } .wf-suggest-change, .wf-suggest-reason { grid-column:2; } }
691
+ .settings-check { display:flex; align-items:flex-start; gap:9px; cursor:pointer; }
692
+ .settings-check input { accent-color:var(--signal); width:15px; height:15px; margin:2px 0 0; flex:none; }
693
+ .settings-check-text { display:flex; flex-direction:column; gap:3px; }
694
+ .settings-check-label { font-size:13px; font-weight:600; }
695
+ .settings-check-hint { font-size:12px; color:var(--muted); }
696
+
662
697
  /* ---- Second brain ---- */
663
698
  .brain-wrap { max-width:1080px; margin:0 auto; padding:20px 22px 32px; }
664
699
  .brain-bar { display:flex; flex-wrap:wrap; align-items:center; gap:6px 14px; margin:4px 0 10px; }
@@ -20,6 +20,8 @@ const ROUTES = [
20
20
  ['PATCH', /^\/api\/task\/[^/]+$/, 'task.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
21
21
  ['POST', /^\/api\/task\/[^/]+\/comment$/, 'task.comment', (_u, b, p) => ({ id: seg(p, 3), text: b.text, action: b.action })],
22
22
  ['POST', '/api/workflow/toggle', 'workflow.toggle', (_u, b) => b],
23
+ ['GET', '/api/workflow/suggest', 'workflow.suggest', () => ({})],
24
+ ['POST', '/api/workflow/apply', 'workflow.apply', (_u, b) => b],
23
25
  ['POST', '/api/run', 'run.start', (_u, b) => b],
24
26
  ['POST', '/api/chat/summarize', 'chat.summarize', (_u, b) => b],
25
27
  ['POST', '/api/meeting/generate', 'meeting.generate', (_u, b) => b],
@@ -27,6 +29,8 @@ const ROUTES = [
27
29
  ['POST', '/api/orchestrate', 'orchestrate.start', (_u, b) => b],
28
30
  ['POST', '/api/orchestrate/approve', 'orchestrate.approve', (_u, b) => b],
29
31
  ['POST', '/api/settings', 'settings.save', (_u, b) => b],
32
+ // Local only — deliberately absent from server/src/relay.js's OP_PERMISSIONS (D76).
33
+ ['POST', '/api/runners/trust', 'runners.trust', (_u, b) => b],
30
34
  ['POST', '/api/attention', 'attention.add', (_u, b) => b],
31
35
  ['POST', /^\/api\/attention\/[^/]+\/promote$/, 'attention.promote', (_u, _b, p) => ({ id: seg(p, 3) })],
32
36
  ['PATCH', /^\/api\/attention\/[^/]+$/, 'attention.update', (_u, b, p) => ({ id: seg(p, 3), patch: b })],
@@ -12,6 +12,7 @@ const store = require('../store');
12
12
  const adapters = require('../adapters');
13
13
  const detect = require('../detect');
14
14
  const brain = require('../brain');
15
+ const runnerTrust = require('../runner-trust');
15
16
 
16
17
  // The command to run `which`: config.json's own runners map first (an explicit user choice always
17
18
  // wins), falling back to the registry's default for a known, headless-capable, genuinely-installed
@@ -80,6 +81,8 @@ function startRun(root, { prompt, agent, logPrompt = true, display, learn = true
80
81
  const which = agent || cfg.agent || 'claude';
81
82
  const cmdStr = resolveRunnerCommand(root, cfg, which);
82
83
  if (!cmdStr) return { error: `No runner configured for "${which}".` };
84
+ const blocked = runnerTrust.blockedMessage(root, which, cmdStr);
85
+ if (blocked) return { error: blocked, untrustedRunner: { agent: which, command: cmdStr } };
83
86
  const parts = cmdStr.split(/\s+/).filter(Boolean);
84
87
  const runId = 'r' + Date.now().toString(36);
85
88
  const p = String(prompt).trim();
@@ -9,6 +9,7 @@
9
9
  const { spawn } = require('child_process');
10
10
  const store = require('../store');
11
11
  const { resolveRunnerCommand } = require('./runner');
12
+ const runnerTrust = require('../runner-trust');
12
13
 
13
14
  const DEFAULT_LIMIT = 40;
14
15
 
@@ -26,6 +27,8 @@ function runSummarize(root, { agent } = {}, emit) {
26
27
  const which = agent || cfg.agent || 'claude';
27
28
  const cmdStr = resolveRunnerCommand(root, cfg, which);
28
29
  if (!cmdStr) return { error: `No runner configured for "${which}".` };
30
+ const blocked = runnerTrust.blockedMessage(root, which, cmdStr);
31
+ if (blocked) return { error: blocked, untrustedRunner: { agent: which, command: cmdStr } };
29
32
 
30
33
  const rt = store.readRuntime(root);
31
34
  const messages = (rt.messages || []).filter((m) => m.kind !== 'summary');
package/lib/init.js CHANGED
@@ -14,6 +14,7 @@ const manifest = require('./manifest');
14
14
  const mcp = require('./mcp');
15
15
  const store = require('./store');
16
16
  const globalConfig = require('./global-config');
17
+ const workflowDetect = require('./workflow-detect');
17
18
 
18
19
  function copyDir(src, dst) {
19
20
  fs.mkdirSync(dst, { recursive: true });
@@ -70,6 +71,7 @@ function runInit({ target, templatesDir, version, agentsArg, defaults }) {
70
71
  }
71
72
 
72
73
  const spectoflowDir = path.join(target, '.spectoflow');
74
+ const workflowExisted = fs.existsSync(path.join(spectoflowDir, 'workflow.md'));
73
75
  copyDir(templatesDir, spectoflowDir);
74
76
 
75
77
  const frameworkFiles = ownership.listFrameworkFiles(templatesDir);
@@ -87,6 +89,15 @@ function runInit({ target, templatesDir, version, agentsArg, defaults }) {
87
89
  cfg.agent = agentsArg ? agents[0] : ((detected.includes(d.agent) || !detected.length) ? d.agent : detected[0]);
88
90
  if (!agents.includes(cfg.agent)) agents.unshift(cfg.agent);
89
91
  cfg.runners = { ...cfg.runners, ...adapters.defaultRunners(agents) };
92
+ // Fit the freshly copied workflow to the project (D75). An existing workflow.md is the user's: never touched.
93
+ let workflowFit = null;
94
+ if (!workflowExisted) {
95
+ const fit = workflowDetect.detect(target);
96
+ const changed = workflowDetect.applySteps(target, Object.fromEntries(Object.entries(fit.steps).map(([k, v]) => [k, v.enabled])));
97
+ cfg.projectType = fit.projectType;
98
+ cfg.workflowReview = 'pending';
99
+ workflowFit = { ...fit, changed };
100
+ }
90
101
  fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
91
102
 
92
103
  const plansDirName = store.resolvePlansDir(target, cfg);
@@ -118,6 +129,18 @@ function runInit({ target, templatesDir, version, agentsArg, defaults }) {
118
129
  if (!giText.includes(line)) fs.appendFileSync(gi, ((fs.existsSync(gi) && fs.readFileSync(gi, 'utf8').length) ? '\n' : '') + line + '\n');
119
130
  }
120
131
 
132
+ if (workflowFit) {
133
+ // One clause per (on/off, reason): "off: Develop, Unit tests, Review (no code yet)".
134
+ const groups = new Map();
135
+ for (const n of workflowFit.changed) {
136
+ const s = workflowFit.steps[n], key = `${s.enabled ? 'on' : 'off'}|${s.reason}`;
137
+ groups.set(key, [...(groups.get(key) || []), n]);
138
+ }
139
+ const clauses = [...groups].map(([key, names]) => { const [state, reason] = key.split('|'); return `${state}: ${names.join(', ')} (${workflowDetect.REASONS[reason]})`; });
140
+ const phase = workflowFit.phase === 'design' ? 'design phase' : `${workflowFit.projectType} project with code`;
141
+ notes.push(`Workflow fitted to the project (${phase})${clauses.length ? ' — ' + clauses.join('; ') : ' — the default steps fit'}. Your agent will review it with you; change it anytime in the dashboard's Workflow tab.`);
142
+ }
143
+
121
144
  const unwired = require('./brain-setup').status().filter((a) => !a.wired);
122
145
  if (unwired.length) notes.push(`Your second brain isn't connected to ${unwired.map((a) => a.label).join(', ')} yet — run: spectoflow brain setup (once per machine).`);
123
146
 
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ /*
3
+ * A project's config.json can set the command that launches an agent (`runners`). That file is committed
4
+ * and can be written by others — a cloned repository, a teammate's change, a member of the online
5
+ * dashboard, or an agent run told to edit it — so a command that isn't the registry's default for that
6
+ * agent only runs once the user has allowed it on THIS machine. Trust lives outside every project
7
+ * (~/.spectoflow/trusted-runners.json) and is bound to the exact command: change it, and it must be
8
+ * allowed again. Default commands never ask. (D76)
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const globalConfig = require('./global-config');
13
+ const adapters = require('./adapters');
14
+
15
+ function storePath() { return path.join(globalConfig.homeDir(), 'trusted-runners.json'); }
16
+ function readStore() { try { return JSON.parse(fs.readFileSync(storePath(), 'utf8')) || {}; } catch { return {}; } }
17
+ function projectKey(root) { try { return fs.realpathSync(root); } catch { return path.resolve(root); } }
18
+
19
+ const defaultRunner = (which) => { const a = adapters.knownAgents().find((x) => x.id === which); return a ? a.runner : null; };
20
+ const isDefault = (which, cmd) => !!cmd && cmd === defaultRunner(which);
21
+
22
+ function isTrusted(root, which, cmd) {
23
+ if (!cmd || isDefault(which, cmd)) return true;
24
+ const allowed = readStore()[projectKey(root)];
25
+ return !!(allowed && allowed[which] === cmd);
26
+ }
27
+
28
+ function trust(root, which, cmd) {
29
+ const store = readStore(), key = projectKey(root);
30
+ store[key] = { ...(store[key] || {}), [which]: cmd };
31
+ fs.mkdirSync(path.dirname(storePath()), { recursive: true });
32
+ fs.writeFileSync(storePath(), JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
33
+ }
34
+
35
+ // The project's runners that would be refused right now → [{ agent, command }].
36
+ function untrusted(root, cfg) {
37
+ return Object.entries((cfg && cfg.runners) || {})
38
+ .filter(([which, cmd]) => typeof cmd === 'string' && !isTrusted(root, which, cmd))
39
+ .map(([agent, command]) => ({ agent, command }));
40
+ }
41
+
42
+ // null when `cmd` may run; otherwise the message explaining why not and how to allow it.
43
+ function blockedMessage(root, which, cmd) {
44
+ if (isTrusted(root, which, cmd)) return null;
45
+ return `For your safety, this project's custom command for "${which}" needs your OK once on this machine before it runs: ${cmd} — allow it in Personalize → Agent & automation, or run: spectoflow runners allow ${which}`;
46
+ }
47
+
48
+ module.exports = { isTrusted, isDefault, trust, untrusted, blockedMessage, storePath };
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+ /*
3
+ * Fits the workflow to the project (docs/workflow-autoconfig-design.md, DECISIONS D75). A deterministic,
4
+ * zero-dependency look at the files: is there code yet (phase: design or build), what kind of project it is
5
+ * (app / infra / data), and which test setups already exist. From that, which of the kit's workflow steps
6
+ * make sense now — each decision with a reason code. `init` applies it to a freshly copied workflow.md;
7
+ * `spectoflow workflow suggest` and the dashboard's Workflow tab offer it for an existing project. Steps
8
+ * the user added themselves are never touched. The agent refines this with the user (it can tell a
9
+ * prototype from a product; a file scan can't).
10
+ */
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.spectoflow', 'vendor', 'dist', 'build', 'target', '.venv', 'venv', '__pycache__', 'coverage', '.next', '.nuxt', 'out']);
15
+ const CODE_EXT = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.kt', '.cs', '.rb', '.php', '.swift', '.c', '.cpp', '.h', '.vue', '.svelte', '.dart', '.scala', '.ex', '.exs']);
16
+ const MANIFESTS = new Set(['pyproject.toml', 'setup.py', 'requirements.txt', 'go.mod', 'Cargo.toml', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json', 'pubspec.yaml', 'mix.exs']);
17
+ const INFRA_FILES = new Set(['Pulumi.yaml', 'Chart.yaml', 'kustomization.yaml', 'ansible.cfg']);
18
+ const TEST_DIRS = new Set(['test', 'tests', '__tests__']);
19
+ const E2E_DEPS = ['@playwright/test', 'playwright', 'cypress', 'puppeteer'];
20
+ const LIMITS = { depth: 4, entries: 5000 };
21
+
22
+ const isTestFile = (name) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(name) || /_test\.(go|py)$/.test(name) || /^test_.*\.py$/.test(name) || /Tests?\.(java|kt|cs)$/.test(name) || /_spec\.rb$/.test(name);
23
+
24
+ function readPackageJson(fp, s) {
25
+ let pkg;
26
+ try { pkg = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return; }
27
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
28
+ const scripts = pkg.scripts || {};
29
+ const realScripts = Object.entries(scripts).filter(([k, v]) => !(k === 'test' && /no test specified/.test(String(v))));
30
+ if (Object.keys(deps).length || realScripts.length) s.manifests.push(fp);
31
+ if (scripts.test && !/no test specified/.test(String(scripts.test))) s.unitTests = true;
32
+ if (E2E_DEPS.some((d) => deps[d])) s.e2e = true;
33
+ }
34
+
35
+ // → { sourceFiles, manifests, unitTests, e2e, integration, infra, data, truncated }
36
+ function scan(root, limits = LIMITS) {
37
+ const s = { sourceFiles: 0, manifests: [], unitTests: false, e2e: false, integration: false, infra: false, data: false, truncated: false };
38
+ let seen = 0;
39
+ const walk = (dir, depth, dirs) => {
40
+ if (s.truncated) return;
41
+ let list;
42
+ try { list = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
43
+ for (const e of list) {
44
+ if (++seen > limits.entries) { s.truncated = true; return; }
45
+ const fp = path.join(dir, e.name);
46
+ if (e.isDirectory()) {
47
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.') || depth >= limits.depth) continue;
48
+ walk(fp, depth + 1, [...dirs, e.name]);
49
+ continue;
50
+ }
51
+ if (!e.isFile()) continue;
52
+ const name = e.name, ext = path.extname(name).toLowerCase();
53
+ if (name === 'package.json') readPackageJson(fp, s);
54
+ else if (MANIFESTS.has(name) || ext === '.csproj') s.manifests.push(fp);
55
+ if (/^(playwright|cypress)\.config\.[cm]?[jt]s$/.test(name) || name === 'cypress.json') s.e2e = true;
56
+ if (ext === '.tf' || INFRA_FILES.has(name)) s.infra = true;
57
+ if (name === 'dbt_project.yml' || ext === '.ipynb' || (ext === '.py' && dirs.includes('dags'))) s.data = true;
58
+ if (!CODE_EXT.has(ext)) continue;
59
+ const underTests = dirs.some((d) => TEST_DIRS.has(d));
60
+ if (dirs.includes('e2e') || dirs.includes('cypress')) { s.e2e = true; continue; }
61
+ if (underTests && dirs.includes('integration')) { s.integration = true; continue; }
62
+ if (underTests || isTestFile(name)) { s.unitTests = true; continue; }
63
+ s.sourceFiles++;
64
+ }
65
+ };
66
+ walk(root, 0, []);
67
+ return s;
68
+ }
69
+
70
+ const STEPS = ['Brainstorm', 'Analysis', 'Spec', 'Plan', 'Develop', 'Unit tests', 'Integration tests', 'End-to-end tests', 'Review'];
71
+
72
+ // → { projectType, phase, signals, steps: { <name>: { enabled, reason } } }
73
+ function detect(root, limits) {
74
+ const signals = scan(root, limits);
75
+ const hasCode = signals.sourceFiles > 0 || signals.manifests.length > 0 || signals.unitTests || signals.infra || signals.data;
76
+ const projectType = signals.infra && signals.sourceFiles < 10 ? 'infra' : signals.data ? 'data' : 'app';
77
+ const phase = hasCode ? 'build' : 'design';
78
+ const on = (reason) => ({ enabled: true, reason }), off = (reason) => ({ enabled: false, reason });
79
+ const steps = { Brainstorm: on('always'), Analysis: on('always'), Spec: on('always'), Plan: on('always') };
80
+ if (phase === 'design') {
81
+ for (const n of ['Develop', 'Unit tests', 'Integration tests', 'End-to-end tests', 'Review']) steps[n] = off('design-no-code');
82
+ } else {
83
+ steps.Develop = on('has-code');
84
+ steps.Review = on('has-code');
85
+ steps['Unit tests'] = projectType === 'infra' ? (signals.unitTests ? on('has-tests') : off('infra-no-tests'))
86
+ : projectType === 'data' ? on('data-quality') : on('has-code');
87
+ steps['Integration tests'] = signals.integration ? on('has-integration-tests') : off('no-integration-tests');
88
+ steps['End-to-end tests'] = projectType === 'app' && signals.e2e ? on('has-e2e-setup') : off('no-e2e-setup');
89
+ }
90
+ return { projectType, phase, signals, steps };
91
+ }
92
+
93
+ // The step's name as store.readWorkflow() reports it: annotation stripped first, then "(optional)".
94
+ function stepName(rest) {
95
+ const ann = rest.match(/\{([^}]*)\}\s*$/);
96
+ if (ann) rest = rest.slice(0, ann.index).trim();
97
+ return rest.replace(/\s*\(optional\)\s*$/i, '').trim();
98
+ }
99
+ const LINE_RE = /^(\s*- \[)( |x|X)(\]\s+)(.*?)(\r?)$/;
100
+
101
+ function workflowPath(root) { return path.join(root, '.spectoflow', 'workflow.md'); }
102
+
103
+ // Current state of the steps detection knows about → [{ name, enabled }] in file order.
104
+ function currentSteps(root) {
105
+ let text;
106
+ try { text = fs.readFileSync(workflowPath(root), 'utf8'); } catch { return []; }
107
+ return text.split('\n').map((l) => l.match(LINE_RE)).filter(Boolean).map((m) => ({ name: stepName(m[4]), enabled: m[2].trim() !== '' }));
108
+ }
109
+
110
+ // What re-analysis would change → { projectType, phase, currentType, changes: [{ name, from, to, reason }] }
111
+ function suggest(root, { projectType: currentType, limits } = {}) {
112
+ const d = detect(root, limits);
113
+ const changes = currentSteps(root)
114
+ .filter((s) => d.steps[s.name] && d.steps[s.name].enabled !== s.enabled)
115
+ .map((s) => ({ name: s.name, from: s.enabled, to: d.steps[s.name].enabled, reason: d.steps[s.name].reason }));
116
+ return { projectType: d.projectType, phase: d.phase, currentType: currentType || null, changes, truncated: d.signals.truncated };
117
+ }
118
+
119
+ // Set the checkbox of each named step to `target[name]` (true/false). Only those lines change, byte for
120
+ // byte otherwise. → names actually changed.
121
+ function applySteps(root, target) {
122
+ const fp = workflowPath(root);
123
+ const lines = fs.readFileSync(fp, 'utf8').split('\n');
124
+ const changed = [];
125
+ for (let i = 0; i < lines.length; i++) {
126
+ const m = lines[i].match(LINE_RE);
127
+ if (!m) continue;
128
+ const name = stepName(m[4]);
129
+ if (!Object.prototype.hasOwnProperty.call(target, name)) continue;
130
+ const want = target[name] ? 'x' : ' ';
131
+ if ((m[2].trim() ? 'x' : ' ') === want) continue;
132
+ lines[i] = m[1] + want + m[3] + m[4] + m[5];
133
+ if (!changed.includes(name)) changed.push(name);
134
+ }
135
+ if (changed.length) fs.writeFileSync(fp, lines.join('\n'));
136
+ return changed;
137
+ }
138
+
139
+ const REASONS = {
140
+ always: 'always useful',
141
+ 'design-no-code': 'no code yet',
142
+ 'has-code': 'the project has code',
143
+ 'has-tests': 'the project has tests',
144
+ 'infra-no-tests': 'infrastructure project with no tests',
145
+ 'data-quality': 'data project — data quality tests',
146
+ 'has-integration-tests': 'integration tests exist',
147
+ 'no-integration-tests': 'no integration tests yet',
148
+ 'has-e2e-setup': 'an end-to-end test setup exists',
149
+ 'no-e2e-setup': 'no end-to-end test setup',
150
+ };
151
+
152
+ module.exports = { scan, detect, suggest, applySteps, currentSteps, STEPS, REASONS, LIMITS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -85,7 +85,9 @@ whole file. This lets the dashboard and you co-edit without clobbering. Reflect
85
85
  4. **Gate** — by `mode` (`.spectoflow/config.json`): **autopilot** proceeds · **semi** (default)
86
86
  confirms if ambiguous/borderline/risky **and always for a Major** · **manual** confirms each step.
87
87
  5. **Load** — read the enabled steps from `.spectoflow/workflow.md` (single source of truth), plus the
88
- `.spectoflow/skills/` needed for those steps. Load only what this task needs.
88
+ `.spectoflow/skills/` needed for those steps. Load only what this task needs. **If the request needs a
89
+ step that is disabled** (asked to implement while *Develop* is off, writing code while *Unit tests* is
90
+ off…), see *Keep the workflow in step with the project* below — never silently skip it.
89
91
  6. **Run** — execute. A **policy gate** (`.spectoflow/policy.md`) can interrupt at any point, any mode.
90
92
 
91
93
  ## New / empty project → Intake
@@ -127,6 +129,25 @@ clarifying question there.
127
129
 
128
130
  - The **active workflow** is `.spectoflow/workflow.md` — a checklist of enabled steps, editable (also
129
131
  from the dashboard). It is the single source; do not restate workflows elsewhere.
132
+
133
+ ### Keep the workflow in step with the project
134
+
135
+ `init` fitted `workflow.md` to what the files showed (is there code yet, tests, an E2E setup, infra or data).
136
+ A file scan can't tell a prototype from a product, or a project still in design from one being built — you
137
+ can, from the docs, specs and plans. Enable or disable a step by changing only its checkbox line.
138
+
139
+ - **First review** — when `.spectoflow/config.json` → `workflowReview` is `"pending"`: early in the session,
140
+ compare the enabled steps with what you understand of the project (phase, type, what the user is doing
141
+ now). Propose the changes that fit, one line of reason each; apply what the user accepts; then set
142
+ `workflowReview` to `"done"`. If nothing needs changing, just say it looks right and set it to `"done"`.
143
+ - **As the project moves on** — when a request needs a step that is disabled:
144
+ - `workflowAutoEnable` is `false` (default): tell the user which step and why, and ask before doing that
145
+ part of the work;
146
+ - `workflowAutoEnable` is `true`: enable that step yourself and tell the user you did.
147
+ - You may **enable** a step on your own only under `workflowAutoEnable: true`. **Disabling** a step always
148
+ goes through the user, whatever the setting.
149
+ - `spectoflow workflow suggest` (and the dashboard's Workflow → *Analyze the project*) re-runs the file
150
+ analysis; point the user to it when the project changed a lot.
130
151
  - **Capabilities** (`.spectoflow/capabilities.md`) are a palette; the project type selects the active ones.
131
152
  - **Agents** (`.spectoflow/agents/`) are stable team personas (Developer, QA Engineer, …). **Skills**
132
153
  (`.spectoflow/skills/`) are the evolving procedures. A workflow step → a capability → its agent →
@@ -3,6 +3,7 @@
3
3
  "language": "en",
4
4
  "agent": "claude",
5
5
  "projectType": "app",
6
+ "workflowAutoEnable": false,
6
7
  "plansDir": null,
7
8
  "specsDir": null,
8
9
  "dashboard": { "autostart": true },