supipowers 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import type { ParsedSkill, PromptSection } from "./analyzer.js";
2
- import { parseManagedRule } from "./rule-renderer.js";
2
+ import { MANAGED_COMMAND_HEADER, parseManagedCommand, parseManagedExtension, parseManagedRule } from "./rule-renderer.js";
3
3
  import {
4
4
  hashOptimizationSource,
5
5
  type ManualOptimizationAction,
@@ -16,9 +16,32 @@ export interface StartupOptimizerManifestRule {
16
16
  slug: string;
17
17
  sourceBytes: number;
18
18
  condition?: string;
19
+ triggers?: string;
20
+ scope?: string;
19
21
  description?: string;
20
22
  }
21
23
 
24
+ export interface StartupOptimizerManifestCommand {
25
+ path: string;
26
+ sourceId: string;
27
+ sourceName: string;
28
+ sourceHash: string;
29
+ slug: string;
30
+ commandName: string;
31
+ sourceBytes: number;
32
+ description?: string;
33
+ }
34
+
35
+ export interface StartupOptimizerManifestExtension {
36
+ path: string;
37
+ sourceId: string;
38
+ sourceName: string;
39
+ sourceHash: string;
40
+ slug: string;
41
+ extensionName: string;
42
+ sourceBytes: number;
43
+ }
44
+
22
45
  export interface StartupOptimizerManifest {
23
46
  version: 1;
24
47
  targetBytes: number;
@@ -27,6 +50,8 @@ export interface StartupOptimizerManifest {
27
50
  estimatedAfterBytes: number;
28
51
  estimatedSavedBytes: number;
29
52
  rules: StartupOptimizerManifestRule[];
53
+ commands: StartupOptimizerManifestCommand[];
54
+ extensions: StartupOptimizerManifestExtension[];
30
55
  tokenignore: {
31
56
  path: string;
32
57
  entries: string[];
@@ -46,6 +71,16 @@ export type StartupCheckReason =
46
71
  | "rule-drift"
47
72
  | "rule-body-drift"
48
73
  | "tokenignore-drift"
74
+ | "missing-command"
75
+ | "unmanaged-command"
76
+ | "malformed-command"
77
+ | "command-drift"
78
+ | "command-body-drift"
79
+ | "missing-extension"
80
+ | "unmanaged-extension"
81
+ | "malformed-extension"
82
+ | "extension-drift"
83
+ | "extension-body-drift"
49
84
  | "still-loaded-source"
50
85
  | "unresolved-manual-action"
51
86
  | "prompt-over-target"
@@ -64,6 +99,8 @@ export interface StartupCheckInput {
64
99
  manifestPath: string;
65
100
  manifestText: string | null | undefined;
66
101
  ruleFiles: Record<string, string | null | undefined>;
102
+ commandFiles?: Record<string, string | null | undefined>;
103
+ extensionFiles?: Record<string, string | null | undefined>;
67
104
  tokenignorePath: string;
68
105
  tokenignoreText: string | null | undefined;
69
106
  currentPrompt: string | null | undefined;
@@ -153,7 +190,9 @@ export function runStartupCheck(input: StartupCheckInput): StartupCheckReport {
153
190
 
154
191
  const metadata = parsed.metadata;
155
192
  const frontmatterDrift = rule.mode === "ttsr"
156
- ? parsed.frontmatter.condition !== rule.condition
193
+ ? parsed.frontmatter.condition !== rule.condition ||
194
+ (typeof rule.triggers === "string" && parsed.frontmatter.triggers !== rule.triggers) ||
195
+ parsed.frontmatter.scope !== (rule.scope ?? "text")
157
196
  : (typeof rule.description === "string" && parsed.frontmatter.description !== rule.description);
158
197
 
159
198
  if (
@@ -187,6 +226,133 @@ export function runStartupCheck(input: StartupCheckInput): StartupCheckReport {
187
226
  }
188
227
  }
189
228
 
229
+
230
+ for (const command of manifest.commands) {
231
+ const text = input.commandFiles?.[command.path];
232
+ if (text == null) {
233
+ issues.push(issue("missing-command", {
234
+ path: command.path,
235
+ sourceId: command.sourceId,
236
+ message: `Managed command file is missing for ${command.sourceId}.`,
237
+ remediation: "Rerun /supi:optimize-context --apply to regenerate managed commands.",
238
+ }));
239
+ continue;
240
+ }
241
+
242
+ const parsed = parseManagedCommand(text);
243
+ if (parsed.status === "unmanaged") {
244
+ issues.push(issue("unmanaged-command", {
245
+ path: command.path,
246
+ sourceId: command.sourceId,
247
+ message: `Command file ${command.path} is not managed by supipowers.`,
248
+ remediation: "Move the user-authored command aside or choose a different command name before applying again.",
249
+ }));
250
+ continue;
251
+ }
252
+
253
+ if (parsed.status === "malformed") {
254
+ issues.push(issue("malformed-command", {
255
+ path: command.path,
256
+ sourceId: command.sourceId,
257
+ message: `Managed command ${command.path} is malformed: ${parsed.error}.`,
258
+ remediation: "Rerun /supi:optimize-context --apply to rewrite the managed command.",
259
+ }));
260
+ continue;
261
+ }
262
+
263
+ // Legacy managed commands stored supipowers metadata before command
264
+ // frontmatter. OMP sends that prefix as prompt text, so force a refresh.
265
+ const commandDrift =
266
+ text.startsWith(MANAGED_COMMAND_HEADER) ||
267
+ parsed.metadata.sourceId !== command.sourceId ||
268
+ parsed.metadata.sourceHash !== command.sourceHash ||
269
+ parsed.metadata.slug !== command.slug ||
270
+ parsed.metadata.commandName !== command.commandName ||
271
+ parsed.metadata.sourceBytes !== command.sourceBytes ||
272
+ (typeof command.description === "string" && parsed.frontmatter.description !== command.description);
273
+ if (commandDrift) {
274
+ issues.push(issue("command-drift", {
275
+ path: command.path,
276
+ sourceId: command.sourceId,
277
+ message: `Managed command ${command.path} no longer matches the startup optimizer manifest.`,
278
+ remediation: "Rerun /supi:optimize-context --apply to refresh managed command artifacts.",
279
+ }));
280
+ continue;
281
+ }
282
+
283
+ const actualBodyHash = hashOptimizationSource(parsed.body);
284
+ const actualBodyBytes = byteLength(parsed.body);
285
+ if (actualBodyHash !== command.sourceHash || actualBodyBytes !== command.sourceBytes) {
286
+ issues.push(issue("command-body-drift", {
287
+ path: command.path,
288
+ sourceId: command.sourceId,
289
+ message: `Managed command ${command.path} body has been modified (hash/size no longer matches the manifest).`,
290
+ remediation: "Rerun /supi:optimize-context --apply to rewrite the managed command from the current prompt source.",
291
+ }));
292
+ }
293
+ }
294
+
295
+ for (const extension of manifest.extensions) {
296
+ const text = input.extensionFiles?.[extension.path];
297
+ if (text == null) {
298
+ issues.push(issue("missing-extension", {
299
+ path: extension.path,
300
+ sourceId: extension.sourceId,
301
+ message: `Managed extension file is missing for ${extension.sourceId}.`,
302
+ remediation: "Rerun /supi:optimize-context --apply to regenerate managed extensions.",
303
+ }));
304
+ continue;
305
+ }
306
+
307
+ const parsed = parseManagedExtension(text);
308
+ if (parsed.status === "unmanaged") {
309
+ issues.push(issue("unmanaged-extension", {
310
+ path: extension.path,
311
+ sourceId: extension.sourceId,
312
+ message: `Extension file ${extension.path} is not managed by supipowers.`,
313
+ remediation: "Move the user-authored extension aside or choose a different extension name before applying again.",
314
+ }));
315
+ continue;
316
+ }
317
+
318
+ if (parsed.status === "malformed") {
319
+ issues.push(issue("malformed-extension", {
320
+ path: extension.path,
321
+ sourceId: extension.sourceId,
322
+ message: `Managed extension ${extension.path} is malformed: ${parsed.error}.`,
323
+ remediation: "Rerun /supi:optimize-context --apply to rewrite the managed extension.",
324
+ }));
325
+ continue;
326
+ }
327
+
328
+ const extensionDrift =
329
+ parsed.metadata.sourceId !== extension.sourceId ||
330
+ parsed.metadata.sourceHash !== extension.sourceHash ||
331
+ parsed.metadata.slug !== extension.slug ||
332
+ parsed.metadata.extensionName !== extension.extensionName ||
333
+ parsed.metadata.sourceBytes !== extension.sourceBytes;
334
+ if (extensionDrift) {
335
+ issues.push(issue("extension-drift", {
336
+ path: extension.path,
337
+ sourceId: extension.sourceId,
338
+ message: `Managed extension ${extension.path} no longer matches the startup optimizer manifest.`,
339
+ remediation: "Rerun /supi:optimize-context --apply to refresh managed extension artifacts.",
340
+ }));
341
+ continue;
342
+ }
343
+
344
+ const actualBodyHash = hashOptimizationSource(parsed.body);
345
+ const actualBodyBytes = byteLength(parsed.body);
346
+ if (actualBodyHash !== extension.sourceHash || actualBodyBytes !== extension.sourceBytes) {
347
+ issues.push(issue("extension-body-drift", {
348
+ path: extension.path,
349
+ sourceId: extension.sourceId,
350
+ message: `Managed extension ${extension.path} body has been modified (hash/size no longer matches the manifest).`,
351
+ remediation: "Rerun /supi:optimize-context --apply to rewrite the managed extension from the current optimizer template.",
352
+ }));
353
+ }
354
+ }
355
+
190
356
  const tokenignore = parseManagedTokenignore(input.tokenignoreText);
191
357
  if (
192
358
  tokenignore.status !== "managed" ||
@@ -213,6 +379,10 @@ export function runStartupCheck(input: StartupCheckInput): StartupCheckReport {
213
379
  if (rule.sourceId.startsWith("skill:")) skillSourceIdsToVerify.add(rule.sourceId);
214
380
  else if (rule.sourceId.startsWith("section:")) sectionSourceIdsToVerify.add(rule.sourceId);
215
381
  }
382
+ for (const command of manifest.commands) {
383
+ if (command.sourceId.startsWith("skill:")) skillSourceIdsToVerify.add(command.sourceId);
384
+ else if (command.sourceId.startsWith("section:")) sectionSourceIdsToVerify.add(command.sourceId);
385
+ }
216
386
  for (const action of manifest.manualActions) {
217
387
  if (action.kind !== "manual-disable") continue;
218
388
  if (action.sourceId.startsWith("skill:")) skillSourceIdsToVerify.add(action.sourceId);
@@ -310,6 +480,29 @@ export function parseStartupOptimizerManifest(
310
480
  rules.push(candidate as unknown as StartupOptimizerManifestRule);
311
481
  }
312
482
 
483
+ const commands: StartupOptimizerManifestCommand[] = [];
484
+ const rawCommands = Array.isArray((parsed as any).commands) ? (parsed as any).commands : [];
485
+ for (const candidate of rawCommands) {
486
+ if (!isRecord(candidate)) return "Startup optimizer manifest has invalid command entry.";
487
+ for (const key of ["path", "sourceId", "sourceName", "sourceHash", "slug", "commandName"] as const) {
488
+ if (typeof candidate[key] !== "string") return `Startup optimizer manifest command is missing ${key}.`;
489
+ }
490
+ if (!isFiniteNumber(candidate.sourceBytes)) return "Startup optimizer manifest command is missing sourceBytes.";
491
+ commands.push(candidate as unknown as StartupOptimizerManifestCommand);
492
+ }
493
+
494
+ const extensions: StartupOptimizerManifestExtension[] = [];
495
+ const rawExtensions = Array.isArray((parsed as any).extensions) ? (parsed as any).extensions : [];
496
+ for (const candidate of rawExtensions) {
497
+ if (!isRecord(candidate)) return "Startup optimizer manifest has invalid extension entry.";
498
+ for (const key of ["path", "sourceId", "sourceName", "sourceHash", "slug", "extensionName"] as const) {
499
+ if (typeof candidate[key] !== "string") return `Startup optimizer manifest extension is missing ${key}.`;
500
+ }
501
+ if (!isFiniteNumber(candidate.sourceBytes)) return "Startup optimizer manifest extension is missing sourceBytes.";
502
+ extensions.push(candidate as unknown as StartupOptimizerManifestExtension);
503
+ }
504
+
505
+
313
506
  if (typeof parsed.tokenignore.path !== "string") return "Startup optimizer manifest tokenignore is missing path.";
314
507
  if (!Array.isArray(parsed.tokenignore.entries) || !parsed.tokenignore.entries.every((entry) => typeof entry === "string")) {
315
508
  return "Startup optimizer manifest tokenignore has invalid entries.";
@@ -324,6 +517,8 @@ export function parseStartupOptimizerManifest(
324
517
  estimatedAfterBytes: parsed.estimatedAfterBytes,
325
518
  estimatedSavedBytes: parsed.estimatedSavedBytes,
326
519
  rules,
520
+ commands,
521
+ extensions,
327
522
  tokenignore: {
328
523
  path: parsed.tokenignore.path,
329
524
  entries: parsed.tokenignore.entries,
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ParsedSkill, PromptSection } from "./analyzer.js";
3
+ import { RUNBOOK_EXTENSION_NAME, RUNBOOK_EXTENSION_PATH, RUNBOOK_EXTENSION_SOURCE } from "./runbook-extension-template.js";
3
4
  import type { TechStack } from "./optimizer.js";
4
5
 
5
6
  export const STARTUP_OPTIMIZER_MANIFEST_VERSION = 1;
@@ -33,6 +34,35 @@ export interface WriteRuleAction {
33
34
  sourceContent: string;
34
35
  condition?: string;
35
36
  description?: string;
37
+ triggers?: string;
38
+ scope?: string;
39
+ }
40
+
41
+ export interface WriteCommandAction {
42
+ kind: "write-command";
43
+ sourceId: string;
44
+ sourceName: string;
45
+ sourceHash: string;
46
+ slug: string;
47
+ commandName: string;
48
+ targetPath: string;
49
+ sourceBytes: number;
50
+ estimatedSavedBytes: number;
51
+ sourceContent: string;
52
+ description?: string;
53
+ }
54
+
55
+ export interface WriteExtensionAction {
56
+ kind: "write-extension";
57
+ sourceId: string;
58
+ sourceName: string;
59
+ sourceHash: string;
60
+ slug: string;
61
+ extensionName: string;
62
+ targetPath: string;
63
+ sourceBytes: number;
64
+ estimatedSavedBytes: number;
65
+ sourceContent: string;
36
66
  }
37
67
 
38
68
  export type ManualDisableReason = "source-still-loaded" | "tech-stack-irrelevant";
@@ -58,7 +88,7 @@ export interface ManualAgentsSplitAction {
58
88
  }
59
89
 
60
90
  export type ManualOptimizationAction = ManualDisableAction | ManualAgentsSplitAction;
61
- export type OptimizationAction = WriteRuleAction | ManualOptimizationAction;
91
+ export type OptimizationAction = WriteRuleAction | WriteCommandAction | WriteExtensionAction | ManualOptimizationAction;
62
92
 
63
93
  export interface OptimizationWarning {
64
94
  code: string;
@@ -91,27 +121,44 @@ export interface BuildOptimizationPlanInput {
91
121
  techStack: TechStack;
92
122
  }
93
123
 
124
+ interface TtsrRuleOptions {
125
+ condition: string;
126
+ triggers?: string;
127
+ scope?: string;
128
+ }
129
+
130
+
94
131
  interface BehaviorSkillSpec {
95
132
  mode: "ttsr";
96
133
  condition: string;
134
+ triggers: string;
135
+ scope: string;
97
136
  }
98
137
 
99
138
  const BEHAVIOR_SKILLS: Record<string, BehaviorSkillSpec> = {
100
139
  debugging: {
101
140
  mode: "ttsr",
102
141
  condition: String.raw`\b(?:debug(?:ging)?|root\s+cause|investigate|repro(?:duce|duction)?|failing\s+test)\b`,
142
+ triggers: "debugging, root cause, investigate, reproduce, failing test",
143
+ scope: "text",
103
144
  },
104
145
  tdd: {
105
146
  mode: "ttsr",
106
147
  condition: String.raw`\b(?:tdd|test\s+first|failing\s+test\s+first|red[-\s]+green[-\s]+refactor)\b`,
148
+ triggers: "TDD, test first, failing test first, red-green-refactor",
149
+ scope: "text",
107
150
  },
108
151
  verification: {
109
152
  mode: "ttsr",
110
153
  condition: String.raw`\b(?:verify|verification|evidence|prove|proof|run\s+(?:the\s+)?(?:focused\s+)?tests?)\b`,
154
+ triggers: "verify, verification, evidence, proof, run focused tests",
155
+ scope: "text",
111
156
  },
112
157
  "receiving-code-review": {
113
158
  mode: "ttsr",
114
159
  condition: String.raw`\b(?:pr\s+feedback|code\s+review\s+comments?|reviewer\s+feedback|review\s+comments?)\b`,
160
+ triggers: "PR feedback, code review comments, reviewer feedback",
161
+ scope: "text",
115
162
  },
116
163
  };
117
164
 
@@ -130,10 +177,40 @@ const TECH_STACK_SKILLS: Record<string, { anyOf: Array<keyof TechStack>; values:
130
177
  },
131
178
  };
132
179
 
180
+ const WORKFLOW_COMMAND_SKILLS = new Set([
181
+ "rewrite-page-copy",
182
+ "e2e-test-orchestrator",
183
+ "design-refiner",
184
+ "workflow-extractor",
185
+ "engaging-writing",
186
+ "brand-namer",
187
+ "brand-forge",
188
+ "product-linear-issues",
189
+ "skill-auditor",
190
+ "caveman",
191
+ "caveman-compress",
192
+ "security-threat-model",
193
+ "brand-migrate",
194
+ "audit",
195
+ "setup-tyndale",
196
+ "setup-tyndale-local",
197
+ "writing-commands",
198
+ "playwright",
199
+ "ui-design",
200
+ "quick-setup",
201
+ "planning",
202
+ "fix-pr",
203
+ "create-readme",
204
+ "translate-book",
205
+ ]);
206
+
207
+
133
208
  const ACTION_KIND_ORDER: Record<OptimizationAction["kind"], number> = {
134
209
  "write-rule": 0,
135
- "manual-disable": 1,
136
- "manual-agents-split": 2,
210
+ "write-command": 1,
211
+ "write-extension": 2,
212
+ "manual-disable": 3,
213
+ "manual-agents-split": 4,
137
214
  };
138
215
 
139
216
  export function hashOptimizationSource(content: string): string {
@@ -162,12 +239,24 @@ export function buildOptimizationPlan(input: BuildOptimizationPlanInput): Optimi
162
239
  const actions: OptimizationAction[] = [];
163
240
  const warnings: OptimizationWarning[] = [];
164
241
 
242
+ actions.push(buildRunbookExtensionAction());
243
+
165
244
  for (const source of sources) {
166
245
  if (source.sourceType === "skill") {
167
246
  const canonicalName = source.sourceName.toLowerCase();
168
247
  const behavior = BEHAVIOR_SKILLS[canonicalName];
169
248
  if (behavior) {
170
- actions.push(buildWriteRuleAction(source, behavior.mode, behavior.condition));
249
+ actions.push(buildWriteRuleAction(source, behavior.mode, {
250
+ condition: behavior.condition,
251
+ triggers: behavior.triggers,
252
+ scope: behavior.scope,
253
+ }));
254
+ actions.push(buildManualDisableAction(source, "source-still-loaded"));
255
+ continue;
256
+ }
257
+
258
+ if (WORKFLOW_COMMAND_SKILLS.has(canonicalName)) {
259
+ actions.push(buildWriteCommandAction(source));
171
260
  actions.push(buildManualDisableAction(source, "source-still-loaded"));
172
261
  continue;
173
262
  }
@@ -202,12 +291,12 @@ export function buildOptimizationPlan(input: BuildOptimizationPlanInput): Optimi
202
291
  const beforeBytes = byteLength(input.prompt);
203
292
 
204
293
  // Sources whose content the user is expected to actually remove from the
205
- // startup prompt: write-rule companions and tech-stack manual-disable
206
- // actions. Deduplicated by sourceId because a write-rule action is paired
207
- // with a `source-still-loaded` manual-disable for the same source.
294
+ // startup prompt: write-rule companions, write-command companions, and
295
+ // manual-disable actions. Deduplicated by sourceId because generated artifacts
296
+ // are paired with `source-still-loaded` manual-disable records.
208
297
  const removedSourceIds = new Set<string>();
209
298
  for (const action of actions) {
210
- if (action.kind === "write-rule" || action.kind === "manual-disable") {
299
+ if (action.kind === "write-rule" || action.kind === "write-command" || action.kind === "manual-disable") {
211
300
  removedSourceIds.add(action.sourceId);
212
301
  }
213
302
  }
@@ -289,7 +378,7 @@ function canonicalSourceContent(content: string): string {
289
378
  function buildWriteRuleAction(
290
379
  source: OptimizationSource,
291
380
  mode: RuleMode,
292
- condition?: string,
381
+ ttsr?: TtsrRuleOptions,
293
382
  ): WriteRuleAction {
294
383
  return {
295
384
  kind: "write-rule",
@@ -302,7 +391,41 @@ function buildWriteRuleAction(
302
391
  sourceBytes: source.bytes,
303
392
  estimatedSavedBytes: source.bytes,
304
393
  sourceContent: source.content,
305
- ...(condition ? { condition } : {}),
394
+ ...(ttsr ? { condition: ttsr.condition, triggers: ttsr.triggers, scope: ttsr.scope } : {}),
395
+ };
396
+ }
397
+
398
+ function buildWriteCommandAction(source: OptimizationSource): WriteCommandAction {
399
+ const commandName = slugifyOptimizationSource(source.sourceName);
400
+ return {
401
+ kind: "write-command",
402
+ sourceId: source.sourceId,
403
+ sourceName: source.sourceName,
404
+ sourceHash: source.sourceHash,
405
+ slug: source.slug,
406
+ commandName,
407
+ targetPath: `.omp/commands/${commandName}.md`,
408
+ sourceBytes: source.bytes,
409
+ estimatedSavedBytes: source.bytes,
410
+ sourceContent: source.content,
411
+ description: `Run the ${source.sourceName} workflow on demand.`,
412
+ };
413
+ }
414
+
415
+ function buildRunbookExtensionAction(): WriteExtensionAction {
416
+ const sourceContent = canonicalSourceContent(RUNBOOK_EXTENSION_SOURCE);
417
+ const sourceBytes = byteLength(sourceContent);
418
+ return {
419
+ kind: "write-extension",
420
+ sourceId: "extension:runbook",
421
+ sourceName: RUNBOOK_EXTENSION_NAME,
422
+ sourceHash: hashOptimizationSource(sourceContent),
423
+ slug: "extension-runbook",
424
+ extensionName: RUNBOOK_EXTENSION_NAME,
425
+ targetPath: RUNBOOK_EXTENSION_PATH,
426
+ sourceBytes,
427
+ estimatedSavedBytes: 0,
428
+ sourceContent,
306
429
  };
307
430
  }
308
431
 
@@ -39,6 +39,7 @@ import {
39
39
  loadHarnessSession,
40
40
  loadHarnessValidateReport,
41
41
  readSlopQueue,
42
+ saveHarnessDesignSpecJson,
42
43
  saveHarnessSession,
43
44
  } from "./storage.js";
44
45
  import { computeScore } from "./anti_slop/score.js";
@@ -55,6 +56,8 @@ import { buildBackendAdapter } from "./anti_slop/backend-factory.js";
55
56
  import { getWorkingTreeStatus } from "../git/status.js";
56
57
  import { DEFAULT_HARNESS_CONFIG } from "./hooks/register.js";
57
58
  import { handlePrComment } from "./pr-comment/handler.js";
59
+ import { runGitVerificationQa } from "./git-verify-qa.js";
60
+ import { getHarnessSessionDir } from "./project-paths.js";
58
61
  import type { HarnessDesignSpec, HarnessGateMode, HarnessSession, HarnessStage } from "../types.js";
59
62
 
60
63
  modelRegistry.register({
@@ -223,12 +226,13 @@ async function runPipelineWithProgress(
223
226
  gates: HarnessGateMode,
224
227
  stageInputs: BuildRunnerInput,
225
228
  startStage?: HarnessStage,
229
+ forceStages?: ReadonlySet<HarnessStage>,
226
230
  ): Promise<PipelineRunOutcome> {
227
231
  const harnessProgress = createHarnessProgress(ctx);
228
232
  const modelConfig = loadModelConfig(platform.paths, ctx.cwd);
229
233
  const outcome = await pipelineDriver({
230
234
  platform, paths: platform.paths, cwd: ctx.cwd, sessionId,
231
- modelConfig, gates, stageInputs, startStage,
235
+ modelConfig, gates, stageInputs, startStage, forceStages,
232
236
  onProgress: harnessProgress.onProgress,
233
237
  });
234
238
  // Single consolidated notification.
@@ -558,12 +562,12 @@ async function runDesignQa(
558
562
 
559
563
  if (choice === "Accept all suggestions") {
560
564
  applyDesignAnalysis(base, analysis);
561
- await askCiAndTooling(ctx, base);
565
+ await askCiAndTooling(platform, ctx, base);
562
566
  return base;
563
567
  }
564
568
 
565
569
  if (choice === "Skip — use bare defaults") {
566
- await askCiAndTooling(ctx, base);
570
+ await askCiAndTooling(platform, ctx, base);
567
571
  return base;
568
572
  }
569
573
  }
@@ -627,7 +631,7 @@ async function runDesignQa(
627
631
  }
628
632
  }
629
633
 
630
- await askCiAndTooling(ctx, base);
634
+ await askCiAndTooling(platform, ctx, base);
631
635
  return base;
632
636
  }
633
637
 
@@ -707,7 +711,7 @@ function localCommandOptions(base: HarnessDesignSpec): string[] {
707
711
  ]));
708
712
  }
709
713
 
710
- async function askCiAndTooling(ctx: HarnessCommandContext, base: HarnessDesignSpec): Promise<void> {
714
+ async function askCiAndTooling(platform: Platform, ctx: HarnessCommandContext, base: HarnessDesignSpec): Promise<void> {
711
715
  if (!ctx.ui.select) return;
712
716
 
713
717
  const triggerChoice = await ctx.ui.select(
@@ -741,6 +745,84 @@ async function askCiAndTooling(ctx: HarnessCommandContext, base: HarnessDesignSp
741
745
  } else if (toolChoice) {
742
746
  base.ci.localCommand = toolChoice.replace(/\s+\(.+\)$/, "");
743
747
  }
748
+
749
+ // After the user picks their CI trigger and local command, offer the optional Git
750
+ // verification flow. This populates `base.ci.git` so the implement stage renders the
751
+ // PR-source guardrail and validate confirms the wiring. Skippable; declining leaves
752
+ // `git` unset and the rest of the pipeline behaves identically to before this feature.
753
+ await runGitVerificationStep(platform, ctx, base);
754
+ }
755
+
756
+ /**
757
+ * Adapter around `runGitVerificationQa` that fits the harness command UI. The QA helper
758
+ * expects an `ExecFn`-shaped function plus a `select / input / notify` UI trio; we wrap
759
+ * `platform.exec` and `ctx.ui` so the helper stays independent of the OMP plumbing.
760
+ *
761
+ * Persists any returned `HarnessCiGitConfig` onto the in-memory `base` spec; the design
762
+ * stage runner persists the spec to disk so no extra storage call is needed here. We
763
+ * also widen `base.ci.trigger.branches` to include both `mainBranch` and `devBranch`
764
+ * so the rendered workflow runs CI on both PR targets — matching the rule "CI runs on
765
+ * both the dev branch PR and the main branch".
766
+ */
767
+ async function runGitVerificationStep(
768
+ platform: Platform,
769
+ ctx: HarnessCommandContext,
770
+ base: HarnessDesignSpec,
771
+ ): Promise<void> {
772
+ if (!ctx.ui.select || !ctx.ui.input) return; // No interactive UI — skip silently.
773
+
774
+ const sessionDir = getHarnessSessionDir(platform.paths, ctx.cwd, base.sessionId);
775
+
776
+ const result = await runGitVerificationQa({
777
+ exec: (cmd, args, opts) => platform.exec(cmd, args, opts),
778
+ cwd: ctx.cwd,
779
+ sessionDir,
780
+ ui: {
781
+ select: (title, options) => ctx.ui.select!(title, options as unknown as string[]),
782
+ input: (label) => ctx.ui.input!(label),
783
+ notify: (message) => notifyInfo(ctx, "Git verification", message),
784
+ },
785
+ });
786
+
787
+ if (!result) return;
788
+ base.ci.git = result;
789
+
790
+ // Ensure the CI trigger includes both branches so the workflow runs on PRs targeting
791
+ // either. Preserve any user-customized branches the prior step already picked.
792
+ if (base.ci.trigger.mode === "branches") {
793
+ const next = new Set(base.ci.trigger.branches);
794
+ next.add(result.mainBranch);
795
+ if (result.devBranch) next.add(result.devBranch);
796
+ base.ci.trigger = { mode: "branches", branches: Array.from(next) };
797
+ }
798
+ }
799
+
800
+ /**
801
+ * Harden-mode entry point for Git verification. Mutates the persisted design spec in
802
+ * place so the downstream implement stage re-renders the workflow with the new `git`
803
+ * block. Returns true when a new `ci.git` block was captured and persisted so the
804
+ * caller can force-re-run the affected stages; false when the user declined or no UI
805
+ * is available.
806
+ */
807
+ async function runGitVerificationOnHarden(
808
+ platform: Platform,
809
+ ctx: HarnessCommandContext,
810
+ sessionId: string,
811
+ spec: HarnessDesignSpec,
812
+ ): Promise<boolean> {
813
+ await runGitVerificationStep(platform, ctx, spec);
814
+ if (!spec.ci.git) return false; // user declined
815
+ const persisted = saveHarnessDesignSpecJson(platform.paths, ctx.cwd, sessionId, spec);
816
+ if (!persisted.ok) {
817
+ notifyInfo(
818
+ ctx,
819
+ "Git verification persisted partially",
820
+ `In-memory spec updated, but persistence failed: ${persisted.error.message}. ` +
821
+ `Re-run /supi:harness to retry.`,
822
+ );
823
+ return false;
824
+ }
825
+ return true;
744
826
  }
745
827
 
746
828
 
@@ -902,7 +984,17 @@ async function handleBareEntry(platform: Platform, ctx: HarnessCommandContext):
902
984
  if (layerCount >= 2) {
903
985
  await promptDocsTierIfNeeded(platform, ctx, sessionId, layerCount);
904
986
  }
905
- await runPipelineWithProgress(platform, ctx, sessionId, "auto", {});
987
+ // Offer the optional Git verification flow on harden when the existing spec has no
988
+ // `ci.git` block. Keeps the harden path lightweight by skipping silently when the
989
+ // user previously declined or completed the verification. When the user captures a
990
+ // new `ci.git` block, force implement + validate to re-run so the workflow file is
991
+ // re-rendered with the `verify-pr-source` job and the validate cross-check fires.
992
+ let forceStages: ReadonlySet<HarnessStage> | undefined;
993
+ if (designSpec.ok && !designSpec.value.ci.git) {
994
+ const captured = await runGitVerificationOnHarden(platform, ctx, sessionId, designSpec.value);
995
+ if (captured) forceStages = new Set<HarnessStage>(["implement", "validate"]);
996
+ }
997
+ await runPipelineWithProgress(platform, ctx, sessionId, "auto", {}, undefined, forceStages);
906
998
  } else {
907
999
  // Rebuild: full regeneration with user gates at each stage.
908
1000
  await runRebuildWithGates(platform, ctx, sessionId);