valent-pipeline 0.19.57 → 0.19.59

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.57",
3
+ "version": "0.19.59",
4
4
  "description": "v3 multi-agent AI pipeline for software development lifecycle",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,6 +87,8 @@ For each NFR-sensitive path: `[NFR-PERF]` response time + load patterns; `[NFR-S
87
87
 
88
88
  **Sanctioning a checkpoint `N/A (sanction)` — the visual gate covers browser-testable *static renders* only.** The PMCP visual gate drives a **stateless** browser-automation MCP: it can navigate, snapshot, and assert rendered DOM, but it CANNOT drive a persistent WebSocket event (`career.created`, `career.resumed`), sever a connection mid-session, or catch an in-flight transient. A checkpoint whose pass criteria *require* such a flow will false-FAIL the visual gate even when the feature works. For exactly those checkpoints, set the checklist's **`N/A (sanction)`** field to `yes — <rationale>`, where the rationale (a) states why the browser MCP cannot drive it and (b) **names the acceptance/integration test that DOES validate it** — that test MUST exist; an `na` with no validating test is a coverage hole, not a sanction. PMCP then records the checkpoint N/A (not FAIL) and JUDGE treats it as non-blocking. Keep every browser-testable static render (default/empty/error/success states, element presence, layout) as a normal PASS/FAIL checkpoint — only WS-event-driven / non-render flows are eligible. When in doubt, do NOT sanction: a real render that fails must stay a FAIL.
89
89
 
90
+ **The validating test a WS-driven sanction names MUST be a true end-to-end reachability case — never a service-layer test that bypasses the live event path.** When the UXA spec carries a Step 6b reachability contract for a WS-driven screen, author a REQUIRED `e2e` acceptance case that **drives the real triggering event over the actual connection** (register/login → issue the WS command → assert the screen leaves loading and renders), then sanction the matching visual checkpoint to it. A test that calls the service/handler layer directly (e.g. asserts `POST /api/careers` or the service method) can pass green while the **live WS command path is structurally broken** — a missing initial-state push, or a bootstrap command that violates a pre-event invariant (writing an FK row keyed on an id that does not exist until the event lands). That gap stays invisible until the visual gate a full attempt later. The reachability case is what forces the live path; the service-layer test is necessary but NOT sufficient to back a sanction.
91
+
90
92
  ## Step 9b: Test Quality Bar — make every case implementable AND falsifiable
91
93
 
92
94
  The dev agents implement exactly what you specify, and CRITIC rejects tests that are weak, unfalsifiable, or bound to the wrong artifact. Spec the bar IN so it is built right the first time (each rule below traces to a real rework cycle):
@@ -37,6 +37,21 @@ For every page, document all five states. No exceptions.
37
37
 
38
38
  Each state: condition (when), behavior (what user sees/can do).
39
39
 
40
+ ## Step 6b: WS-Driven Screen Reachability Contract (Conditional — real-time / WebSocket / SSE UIs)
41
+
42
+ A screen whose data arrives over a **persistent connection** (WebSocket/SSE) — not an HTTP page load — is NOT reachable just because its component renders. It becomes reachable only once its **triggering event fires AND pushes the initial state** the screen needs to leave its loading state. Miss that push and the screen hangs in Loading forever: the component is correct, the screen is unreachable. This is the single most common late-caught UI defect on real-time apps — the acceptance/visual gate catches it at green, a full attempt late. Spec it HERE so the dev wires the push first-try.
43
+
44
+ For EACH WS-driven screen, document its **reachability contract**:
45
+
46
+ | Field | What to specify |
47
+ |-------|-----------------|
48
+ | **Triggering event** | The server→client event that makes this screen reachable (e.g. `career.created`, `career.resumed`) |
49
+ | **Initial-state-push payload** | The exact state fields the event MUST push so the screen's loading state resolves to active — name them (e.g. `school_name`, `season`, `current_phase`); "sends state" is not enough |
50
+ | **Pre-event session state** | What exists BEFORE the event (e.g. session keyed on a player/user id, no record yet) — flags bootstrap commands that must NOT assume post-event invariants (FK rows, ids that do not exist until the event lands) |
51
+ | **Unreachable-if** | The concrete failure: which screen hangs in which state if the push (or the bootstrap command) is missing/broken |
52
+
53
+ Each contract becomes a REQUIRED end-to-end reachability acceptance case in QA-A (drive the real event → assert the screen renders), and the matching PMCP visual checkpoint is sanctioned `N/A` (the stateless browser MCP cannot drive a persistent WS event — see QA-A Step 9). A screen reachable via a plain HTTP load only is out of scope — this is for event-driven reachability.
54
+
40
55
  ## Step 7: Accessibility Checklist (WCAG AA)
41
56
 
42
57
  - [ ] ARIA labels for interactive components
@@ -71,6 +86,7 @@ A developer with zero project context must build every page from this spec alone
71
86
  | Components have explicit dimensions/spacing/layout | | |
72
87
  | Interactive elements have behavior for all actions | | |
73
88
  | All five states documented per page | | |
89
+ | WS-driven screens carry a reachability contract (triggering event + named initial-state-push payload) — Step 6b | | |
74
90
  | Area labels consistent and follow convention | | |
75
91
  | No ambiguous language ("appropriate", "as needed") | | |
76
92
  | Multi-language considerations documented | | |
@@ -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: {
@@ -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
  }