valent-pipeline 0.19.56 → 0.19.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valent-pipeline",
3
- "version": "0.19.56",
3
+ "version": "0.19.58",
4
4
  "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
5
  "type": "module",
6
6
  "bin": {
@@ -107,6 +107,7 @@ the config — surface it explicitly:
107
107
  | `git.parallelism.max_stories` | `2` | Concurrent story cap |
108
108
  | `git.parallelism.worktree_dir` | `.valent-worktrees` | Worktree container (ignored via `.git/info/exclude`) |
109
109
  | `git.parallelism.setup_commands` | `[]` | Commands run in each fresh worktree (env files, per-story ports, non-node installs) — MUST be idempotent (setup re-runs on resume) |
110
+ | `git.parallelism.teardown_commands` | `[]` | Commands run IN the worktree just before removal (on ship or rollover) — symmetric cleanup (e.g. `docker compose down` on a per-story stack) so a stale stack can't leak into the next story. Best-effort: a failure is logged but never blocks the ship/rollover |
110
111
 
111
112
  ### Step 4a: Evidence Gate (machine-evidence layer)
112
113
 
@@ -29,7 +29,7 @@ If no argument is provided, resolve the next work item from the backlog (see Ste
29
29
 
30
30
  ### Step 1: Load Pipeline Config
31
31
 
32
- Read `.valent-pipeline/pipeline-config.yaml` and resolve the variables you need below (project type, paths, quality caps, `tech_stack`, `knowledge`). Capture `project.type` — it feeds `--project-type` in Step 1b, and the resolved candidate object then carries `projectType`, `profiles`, and the execution-mode flags for Step 4 (when the user gave an explicit story argument instead, run the Step 1b resolver command anyway and select that story's candidate object from its buckets). Also capture the entire `models` section (the `{ opus:[...], sonnet:[...], haiku:[...] }` tier→roles map) — pass it as the `models` arg so per-agent model tiers stay config-driven (editable via `/valent-configure`). If the config has no `models` section, omit the arg and the workflow uses its baked-in default assignment. Likewise capture the `reasoning` section (the `{ ultrathink:[...], 'think-harder':[...], 'think-hard':[...], think:[...] }` level→roles map) and pass it as the `reasoning` arg; it is blank by default (injects nothing). Omit the arg if the section is absent or all levels are empty. Also capture the `pre_critic_gate` section (deterministic lint/type/static checks) and pass it as the `preCriticGate` arg; omit it if absent or its `commands` list is empty (the gate is then a no-op). Likewise capture the `pack_gate` section (run-level pack-time freshness checks — they run ONCE at sprint intake, before the story spends tokens) and pass it as the `packGate` arg; omit it if absent or its `commands` list is empty. Capture the `atdd` section and pass it as the `atdd` arg — it drives the RED gate (pre-implementation acceptance-suite run that must fail) and the GREEN gate (post-CRITIC re-run that must pass with the acceptance sources unchanged); omit it if absent or its `command` is blank (both gates are then a loud no-op). Capture the `git` section and pass it as the `git` arg `{ enabled: <git.enabled ?? true>, targetBranch: <git.target_branch or ''>, storyBranchPrefix: <git.story_branch_prefix or 'story/'>, worktreeIsolation: <git.worktree_isolation ?? false>, parallelism: <git.parallelism or omit> }` — it drives the story git flow (branch off the target, full commit trail, merge --no-ff on SHIP); `worktreeIsolation` builds each story in its own worktree even when serial (parallel mode forces it on); `parallelism` ({ enabled, max_stories, worktree_dir, setup_commands }) additionally enables concurrent stories in worktrees (off by default). Omit the whole arg only to accept the defaults (flow ON, parallelism OFF, graceful no-op outside a git repo). Capture the `visual` section together with `tech_stack.browser_automation_mcp` and pass them as the `visual` arg `{ onMissingMcp: <visual.on_missing_mcp or 'block'>, browserMcp: <tech_stack.browser_automation_mcp or 'playwright-mcp'> }` — this drives the Visual (PMCP) phase for `ui`-profile stories.
32
+ Read `.valent-pipeline/pipeline-config.yaml` and resolve the variables you need below (project type, paths, quality caps, `tech_stack`, `knowledge`). Capture `project.type` — it feeds `--project-type` in Step 1b, and the resolved candidate object then carries `projectType`, `profiles`, and the execution-mode flags for Step 4 (when the user gave an explicit story argument instead, run the Step 1b resolver command anyway and select that story's candidate object from its buckets). Also capture the entire `models` section (the `{ opus:[...], sonnet:[...], haiku:[...] }` tier→roles map) — pass it as the `models` arg so per-agent model tiers stay config-driven (editable via `/valent-configure`). If the config has no `models` section, omit the arg and the workflow uses its baked-in default assignment. Likewise capture the `reasoning` section (the `{ ultrathink:[...], 'think-harder':[...], 'think-hard':[...], think:[...] }` level→roles map) and pass it as the `reasoning` arg; it is blank by default (injects nothing). Omit the arg if the section is absent or all levels are empty. Also capture the `pre_critic_gate` section (deterministic lint/type/static checks) and pass it as the `preCriticGate` arg; omit it if absent or its `commands` list is empty (the gate is then a no-op). Likewise capture the `pack_gate` section (run-level pack-time freshness checks — they run ONCE at sprint intake, before the story spends tokens) and pass it as the `packGate` arg; omit it if absent or its `commands` list is empty. Capture the `atdd` section and pass it as the `atdd` arg — it drives the RED gate (pre-implementation acceptance-suite run that must fail) and the GREEN gate (post-CRITIC re-run that must pass with the acceptance sources unchanged); omit it if absent or its `command` is blank (both gates are then a loud no-op). Capture the `git` section and pass it as the `git` arg `{ enabled: <git.enabled ?? true>, targetBranch: <git.target_branch or ''>, storyBranchPrefix: <git.story_branch_prefix or 'story/'>, worktreeIsolation: <git.worktree_isolation ?? false>, parallelism: <git.parallelism or omit> }` — it drives the story git flow (branch off the target, full commit trail, merge --no-ff on SHIP); `worktreeIsolation` builds each story in its own worktree even when serial (parallel mode forces it on); `parallelism` ({ enabled, max_stories, worktree_dir, setup_commands, teardown_commands }) additionally enables concurrent stories in worktrees (off by default); `setup_commands`/`teardown_commands` run in each worktree on create/remove (the git CLI reads them from config on disk). Omit the whole arg only to accept the defaults (flow ON, parallelism OFF, graceful no-op outside a git repo). Capture the `visual` section together with `tech_stack.browser_automation_mcp` and pass them as the `visual` arg `{ onMissingMcp: <visual.on_missing_mcp or 'block'>, browserMcp: <tech_stack.browser_automation_mcp or 'playwright-mcp'> }` — this drives the Visual (PMCP) phase for `ui`-profile stories.
33
33
 
34
34
  ### Step 1b: Resolve Next Work Item (when no argument provided)
35
35
 
@@ -36,6 +36,7 @@ function resolveOpts(options) {
36
36
  worktree: options.worktree === true,
37
37
  worktreeDir: options.worktreeDir || par.worktree_dir || DEFAULT_WORKTREE_DIR,
38
38
  setupCommands: Array.isArray(par.setup_commands) ? par.setup_commands : [],
39
+ teardownCommands: Array.isArray(par.teardown_commands) ? par.teardown_commands : [],
39
40
  };
40
41
  }
41
42
 
@@ -78,7 +79,7 @@ export async function gitCommitPhaseCmd(options) {
78
79
 
79
80
  export async function gitShipStoryCmd(options) {
80
81
  try {
81
- const { root, targetBranch, prefix, worktree } = resolveOpts(options);
82
+ const { root, targetBranch, prefix, worktree, teardownCommands } = resolveOpts(options);
82
83
  // Ship-time evidence binding (review pass-3 #7): the clamp config rides pipeline-config.yaml
83
84
  // (evidence_gate.ship_clamp) — no CLI bypass flag exists by design.
84
85
  const { evidenceShipConfig } = await import('./evidence.js');
@@ -87,6 +88,7 @@ export async function gitShipStoryCmd(options) {
87
88
  root, story: options.story, verdict: options.verdict || 'ship', targetBranch, prefix, worktree,
88
89
  into: options.into || null,
89
90
  shipClamp: shipCfg.mode === 'off' ? null : { mode: shipCfg.mode, label: shipCfg.label },
91
+ teardownCommands,
90
92
  });
91
93
  if (result.shipClampWarning) {
92
94
  console.error(`git-flow WARNING: ship-time evidence binding failed (${result.shipClampWarning}) — proceeding (evidence_gate.ship_clamp: warn)`);
@@ -135,8 +137,8 @@ export async function gitStoryStatusCmd(options) {
135
137
 
136
138
  export async function gitLeaveStoryCmd(options) {
137
139
  try {
138
- const { root, targetBranch, prefix, worktree } = resolveOpts(options);
139
- finish(leaveStory({ root, story: options.story, reason: options.reason || 'rejected', targetBranch, prefix, worktree }));
140
+ const { root, targetBranch, prefix, worktree, teardownCommands } = resolveOpts(options);
141
+ finish(leaveStory({ root, story: options.story, reason: options.reason || 'rejected', targetBranch, prefix, worktree, teardownCommands }));
140
142
  } catch (e) {
141
143
  fail(e);
142
144
  }
@@ -554,12 +554,16 @@ git:
554
554
  # worktree under worktree_dir; the main checkout stays on the target, merges are serialized, and
555
555
  # each ship gets a post-merge acceptance verify (ATDD armed). OFF by default — sequential sprint.
556
556
  # setup_commands run in each fresh worktree (node_modules is linked + pipeline.db copied for you;
557
- # add project-specific needs here, e.g. env files or per-story ports).
557
+ # add project-specific needs here, e.g. env files or per-story ports). teardown_commands run in
558
+ # the worktree just before it is removed (on ship or rollover) — the symmetric cleanup (e.g.
559
+ # "docker compose down" on a per-story stack) so a stale stack can't leak into the next story.
560
+ # Best-effort: a teardown failure is logged but never blocks the ship/rollover.
558
561
  parallelism:
559
562
  enabled: ${config.git.parallelism?.enabled ?? false}
560
563
  max_stories: ${config.git.parallelism?.max_stories ?? 2}
561
564
  worktree_dir: "${config.git.parallelism?.worktree_dir ?? '.valent-worktrees'}"
562
565
  setup_commands: [${(config.git.parallelism?.setup_commands ?? []).map((c) => JSON.stringify(c)).join(', ')}]
566
+ teardown_commands: [${(config.git.parallelism?.teardown_commands ?? []).map((c) => JSON.stringify(c)).join(', ')}]
563
567
 
564
568
  communication:
565
569
  handoff_format: "${config.communication.handoff_format}"
@@ -545,6 +545,12 @@ export const defaults = {
545
545
  // Commands run in each fresh worktree after node_modules linking + pipeline.db copy —
546
546
  // project-specific setup (env files, per-story ports, non-node package installs).
547
547
  setup_commands: [],
548
+ // Commands run IN the worktree just before it is removed (on ship or rollover) — the
549
+ // symmetric complement to setup_commands. Use to tear down per-worktree side effects a setup
550
+ // command created (e.g. `docker compose down` on a per-story stack) so a stale stack can't
551
+ // leak into the next story and pin a shared host port. Best-effort: a failure is logged but
552
+ // never blocks the ship/rollover.
553
+ teardown_commands: [],
548
554
  },
549
555
  },
550
556
  communication: {
Binary file
@@ -276,12 +276,41 @@ function worktreeForBranch(root, branch) {
276
276
  return null;
277
277
  }
278
278
 
279
+ /**
280
+ * Best-effort teardown (config git.parallelism.teardown_commands), run IN the worktree just before
281
+ * it is removed — the symmetric complement to setupWorktree's setup_commands. Use it to tear down
282
+ * per-worktree side effects a setup command created (e.g. `docker compose down` on a per-story
283
+ * stack) so a stale stack can't leak into the next story and pin a shared host port. UNLIKE setup,
284
+ * a teardown failure NEVER throws: the worktree is being discarded regardless, and a failed cleanup
285
+ * must not strand a shipped/rolled-over story — it is reported loudly on stderr and removal still
286
+ * proceeds. Runs in the worktree's cwd so a command can read its compose project name / .env.test.
287
+ */
288
+ function runWorktreeTeardown(wtPath, teardownCommands = []) {
289
+ if (!Array.isArray(teardownCommands) || !existsSync(wtPath)) return { ran: [], failed: [] };
290
+ const ran = [], failed = [];
291
+ for (const cmd of teardownCommands) {
292
+ if (typeof cmd !== 'string' || !cmd.trim()) continue;
293
+ try {
294
+ execSync(cmd, { cwd: wtPath, stdio: ['ignore', 'pipe', 'pipe'], timeout: 300000 });
295
+ ran.push(cmd);
296
+ } catch (e) {
297
+ const stderr = e.stderr ? e.stderr.toString().slice(-2000) : '';
298
+ const why = e.killed ? ' (timed out after 300s)' : '';
299
+ failed.push(cmd);
300
+ console.error(`git-flow WARNING: worktree teardown command failed${why} (best-effort, removal proceeds): ${cmd}${stderr ? `\n${stderr}` : ''}`);
301
+ }
302
+ }
303
+ return { ran, failed };
304
+ }
305
+
279
306
  /**
280
307
  * Remove a worktree AFTER its sweep commit. Plain remove refuses while untracked/ignored files
281
308
  * remain — post-sweep the only leftovers are ignored setup artifacts (the node_modules link, the
282
- * pipeline.db copy), so the --force fallback discards nothing of value.
309
+ * pipeline.db copy), so the --force fallback discards nothing of value. Teardown commands run FIRST
310
+ * (in the worktree, while its files still exist), best-effort.
283
311
  */
284
- function removeWorktreeDir(root, wtPath) {
312
+ function removeWorktreeDir(root, wtPath, teardownCommands = []) {
313
+ runWorktreeTeardown(wtPath, teardownCommands);
285
314
  try {
286
315
  git(root, ['worktree', 'remove', wtPath]);
287
316
  return true;
@@ -691,7 +720,7 @@ export function commitPhase({ root, story, phase, summary = null, gate = null })
691
720
  * shipped; the branch retains every commit. Idempotent: an already-merged branch reports
692
721
  * { merged:true, already:true }.
693
722
  */
694
- export function shipStory({ root, story, verdict = 'ship', targetBranch = '', prefix = 'story/', worktree = false, into = null, shipClamp = null }) {
723
+ export function shipStory({ root, story, verdict = 'ship', targetBranch = '', prefix = 'story/', worktree = false, into = null, shipClamp = null, teardownCommands = [] }) {
695
724
  if (!isRepoWithCommits(root)) return NOT_REPO;
696
725
  const branch = storyBranchName(prefix, story);
697
726
  if (!branchExists(root, branch)) throw new Error(`story branch "${branch}" does not exist — was start-story run?`);
@@ -735,7 +764,7 @@ export function shipStory({ root, story, verdict = 'ship', targetBranch = '', pr
735
764
  sweep = commitAll(root, buildCommitMessage({ storyId: story, phase: 'ship', trailers: { Verdict: verdict } }));
736
765
  }
737
766
 
738
- const removeWorktree = () => (wt ? removeWorktreeDir(root, wt) : false);
767
+ const removeWorktree = () => (wt ? removeWorktreeDir(root, wt, teardownCommands) : false);
739
768
 
740
769
  if (isMergedInto(root, branch, target)) {
741
770
  if (currentBranch(root) !== target) git(root, ['checkout', target]);
@@ -898,7 +927,7 @@ export function syncStory({ root, story, targetBranch = '', prefix = 'story/', c
898
927
  * must survive for the inline fix or next-sprint retry), checkout the target, branch retained.
899
928
  * Safe to call from any branch state; idempotent.
900
929
  */
901
- export function leaveStory({ root, story, reason = 'rejected', targetBranch = '', prefix = 'story/', worktree = false }) {
930
+ export function leaveStory({ root, story, reason = 'rejected', targetBranch = '', prefix = 'story/', worktree = false, teardownCommands = [] }) {
902
931
  if (!isRepoWithCommits(root)) return NOT_REPO;
903
932
  const branch = storyBranchName(prefix, story);
904
933
 
@@ -910,7 +939,7 @@ export function leaveStory({ root, story, reason = 'rejected', targetBranch = ''
910
939
  let removed = false;
911
940
  if (wt) {
912
941
  sweep = commitAll(wt, buildCommitMessage({ storyId: story, phase: 'rollover', summary: `roll over (${reason})` }));
913
- removed = removeWorktreeDir(root, wt);
942
+ removed = removeWorktreeDir(root, wt, teardownCommands);
914
943
  }
915
944
  return { left: true, branch, target: targetBranch || currentBranch(root), sweepSha: sweep.sha || null, reason, worktreeRemoved: removed };
916
945
  }