supipowers 2.0.2 → 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.
Files changed (84) hide show
  1. package/README.md +5 -6
  2. package/package.json +4 -2
  3. package/skills/harness/SKILL.md +1 -0
  4. package/src/bootstrap.ts +8 -133
  5. package/src/commands/optimize-context.ts +153 -16
  6. package/src/commands/runbook.ts +511 -0
  7. package/src/config/defaults.ts +5 -5
  8. package/src/config/loader.ts +1 -0
  9. package/src/config/schema.ts +2 -6
  10. package/src/context/rule-renderer.ts +274 -2
  11. package/src/context/runbook-extension-template.ts +193 -0
  12. package/src/context/startup-check.ts +197 -2
  13. package/src/context/startup-optimizer.ts +133 -10
  14. package/src/context-mode/knowledge/store.ts +381 -43
  15. package/src/context-mode/tools.ts +41 -3
  16. package/src/deps/registry.ts +1 -12
  17. package/src/fix-pr/assessment.ts +1 -0
  18. package/src/fix-pr/prompt-builder.ts +1 -0
  19. package/src/git/commit.ts +76 -18
  20. package/src/harness/command.ts +201 -12
  21. package/src/harness/default-agents/docs.md +39 -0
  22. package/src/harness/docs/config.ts +29 -0
  23. package/src/harness/docs/glob-match.ts +27 -0
  24. package/src/harness/docs/index-renderer.ts +82 -0
  25. package/src/harness/docs/provenance.ts +125 -0
  26. package/src/harness/docs/regen-decision.ts +167 -0
  27. package/src/harness/docs/representative-files.ts +175 -0
  28. package/src/harness/docs/source-hash.ts +106 -0
  29. package/src/harness/docs/validator.ts +233 -0
  30. package/src/harness/git-verification.ts +515 -0
  31. package/src/harness/git-verify-qa.ts +406 -0
  32. package/src/harness/hooks/layer-context-inject.ts +35 -1
  33. package/src/harness/hooks/register.ts +24 -3
  34. package/src/harness/pipeline.ts +37 -13
  35. package/src/harness/pr-comment/baseline.ts +105 -0
  36. package/src/harness/pr-comment/ci-env.ts +120 -0
  37. package/src/harness/pr-comment/gh-poster.ts +227 -0
  38. package/src/harness/pr-comment/handler.ts +198 -0
  39. package/src/harness/pr-comment/render.ts +297 -0
  40. package/src/harness/pr-comment/status.ts +95 -0
  41. package/src/harness/pr-comment/types.ts +73 -0
  42. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  43. package/src/harness/project-paths.ts +95 -0
  44. package/src/harness/stages/design.ts +1 -0
  45. package/src/harness/stages/discover.ts +1 -13
  46. package/src/harness/stages/docs.ts +708 -0
  47. package/src/harness/stages/implement-apply.ts +934 -0
  48. package/src/harness/stages/implement.ts +64 -51
  49. package/src/harness/stages/plan.ts +25 -16
  50. package/src/harness/stages/validate.ts +478 -0
  51. package/src/harness/storage.ts +142 -0
  52. package/src/harness/tools.ts +130 -0
  53. package/src/mempalace/bridge.ts +207 -41
  54. package/src/mempalace/config.ts +10 -4
  55. package/src/mempalace/format.ts +122 -6
  56. package/src/mempalace/hooks.ts +204 -56
  57. package/src/mempalace/installer-helper.ts +18 -4
  58. package/src/mempalace/python/mempalace_bridge.py +128 -3
  59. package/src/mempalace/runtime.ts +53 -16
  60. package/src/mempalace/schema.ts +151 -30
  61. package/src/mempalace/session-summary.ts +5 -0
  62. package/src/mempalace/tool.ts +17 -4
  63. package/src/mempalace/upstream-limits.ts +69 -0
  64. package/src/planning/approval-flow.ts +25 -2
  65. package/src/planning/planning-ask-tool.ts +34 -4
  66. package/src/planning/system-prompt.ts +1 -1
  67. package/src/tool-catalog/active-tool-controller.ts +0 -22
  68. package/src/tool-catalog/active-tool-planner.ts +0 -26
  69. package/src/tool-catalog/tool-groups.ts +1 -9
  70. package/src/types.ts +127 -8
  71. package/src/ui-design/session.ts +114 -8
  72. package/src/utils/executable.ts +10 -1
  73. package/src/workspace/state-paths.ts +1 -1
  74. package/src/commands/mcp.ts +0 -814
  75. package/src/mcp/activation.ts +0 -77
  76. package/src/mcp/config.ts +0 -223
  77. package/src/mcp/docs.ts +0 -154
  78. package/src/mcp/gateway.ts +0 -103
  79. package/src/mcp/lifecycle.ts +0 -79
  80. package/src/mcp/manager-tool.ts +0 -104
  81. package/src/mcp/mcpc.ts +0 -113
  82. package/src/mcp/registry.ts +0 -98
  83. package/src/mcp/triggers.ts +0 -62
  84. package/src/mcp/types.ts +0 -95
package/src/git/commit.ts CHANGED
@@ -139,28 +139,27 @@ interface CommitStagingContext {
139
139
  stagedFiles: string[];
140
140
  }
141
141
 
142
- async function ensureStagedChanges(
143
- platform: Platform,
142
+ const STAGE_ALL_CHANGES_OPTION = "Stage all changes — include staged, unstaged, and untracked files";
143
+ const USE_STAGED_CHANGES_OPTION = "Use staged changes only — commit the index as-is";
144
+
145
+ async function stageAllChanges(
146
+ exec: ExecFn,
144
147
  ctx: any,
145
148
  cwd: string,
146
- status: Awaited<ReturnType<typeof getWorkingTreeStatus>>,
149
+ fileCount: number,
147
150
  progress: ReturnType<typeof createProgress>,
148
- ): Promise<CommitStagingContext | null> {
149
- const exec = platform.exec.bind(platform);
150
-
151
- if (status.stagedFiles.length === 0) {
152
- progress.activate(1, `${status.files.length} file(s)`);
153
- const addResult = await exec("git", ["add", "-A"], { cwd });
154
- if (addResult.code !== 0) {
155
- notifyError(ctx, "git add failed", addResult.stderr || "Non-zero exit");
156
- return null;
157
- }
158
- progress.complete(1, `${status.files.length} file(s)`);
159
- } else {
160
- progress.activate(1, `${status.stagedFiles.length} staged`);
161
- progress.complete(1, `${status.stagedFiles.length} staged`);
151
+ ): Promise<boolean> {
152
+ progress.activate(1, `${fileCount} file(s)`);
153
+ const addResult = await exec("git", ["add", "-A"], { cwd });
154
+ if (addResult.code !== 0) {
155
+ notifyError(ctx, "git add failed", addResult.stderr || "Non-zero exit");
156
+ return false;
162
157
  }
158
+ progress.complete(1, `${fileCount} file(s)`);
159
+ return true;
160
+ }
163
161
 
162
+ async function readStagedFiles(exec: ExecFn, ctx: any, cwd: string): Promise<string[] | null> {
164
163
  const stagedFilesResult = await exec("git", ["diff", "--cached", "--name-only"], { cwd });
165
164
  if (stagedFilesResult.code !== 0) {
166
165
  notifyError(ctx, "git diff failed", stagedFilesResult.stderr || "Could not read staged files");
@@ -173,7 +172,66 @@ async function ensureStagedChanges(
173
172
  return null;
174
173
  }
175
174
 
176
- return { stagedFiles };
175
+ return stagedFiles;
176
+ }
177
+
178
+ function formatFilePreview(files: string[], label: string): string {
179
+ const preview = files.slice(0, 8).join("\n");
180
+ const extra = files.length > 8 ? `\n… and ${files.length - 8} more ${label}` : "";
181
+ return `${preview}${extra}`;
182
+ }
183
+
184
+ async function ensureStagedChanges(
185
+ platform: Platform,
186
+ ctx: any,
187
+ cwd: string,
188
+ status: Awaited<ReturnType<typeof getWorkingTreeStatus>>,
189
+ progress: ReturnType<typeof createProgress>,
190
+ ): Promise<CommitStagingContext | null> {
191
+ const exec = platform.exec.bind(platform);
192
+
193
+ if (status.stagedFiles.length > 0 && status.unstagedFiles.length > 0) {
194
+ const selection = await ctx.ui.select(
195
+ "Staged and unstaged changes detected",
196
+ [STAGE_ALL_CHANGES_OPTION, USE_STAGED_CHANGES_OPTION],
197
+ {
198
+ helpText: [
199
+ "Choose the source of truth for /supi:commit.",
200
+ `Staged (${status.stagedFiles.length}):\n${formatFilePreview(status.stagedFiles, "staged")}`,
201
+ `Unstaged/untracked (${status.unstagedFiles.length}):\n${formatFilePreview(status.unstagedFiles, "unstaged")}`,
202
+ ].join("\n\n"),
203
+ },
204
+ );
205
+
206
+ if (!selection) {
207
+ progress.dispose();
208
+ return null;
209
+ }
210
+
211
+ if (selection === STAGE_ALL_CHANGES_OPTION) {
212
+ if (!await stageAllChanges(exec, ctx, cwd, status.files.length, progress)) {
213
+ return null;
214
+ }
215
+ } else {
216
+ progress.activate(1, `${status.stagedFiles.length} staged`);
217
+ progress.complete(1, `${status.stagedFiles.length} staged`);
218
+ }
219
+
220
+ const stagedFiles = await readStagedFiles(exec, ctx, cwd);
221
+ return stagedFiles ? { stagedFiles } : null;
222
+ }
223
+
224
+ if (status.stagedFiles.length === 0) {
225
+ if (!await stageAllChanges(exec, ctx, cwd, status.files.length, progress)) {
226
+ return null;
227
+ }
228
+ } else {
229
+ progress.activate(1, `${status.stagedFiles.length} staged`);
230
+ progress.complete(1, `${status.stagedFiles.length} staged`);
231
+ }
232
+
233
+ const stagedFiles = await readStagedFiles(exec, ctx, cwd);
234
+ return stagedFiles ? { stagedFiles } : null;
177
235
  }
178
236
 
179
237
  // ── Main entry point ───────────────────────────────────────
@@ -39,9 +39,9 @@ import {
39
39
  loadHarnessSession,
40
40
  loadHarnessValidateReport,
41
41
  readSlopQueue,
42
+ saveHarnessDesignSpecJson,
42
43
  saveHarnessSession,
43
44
  } from "./storage.js";
44
- import { getHarnessSessionDir } from "./project-paths.js";
45
45
  import { computeScore } from "./anti_slop/score.js";
46
46
  import {
47
47
  type BuildRunnerInput,
@@ -55,6 +55,9 @@ import { newHarnessSessionId } from "./stage-runner.js";
55
55
  import { buildBackendAdapter } from "./anti_slop/backend-factory.js";
56
56
  import { getWorkingTreeStatus } from "../git/status.js";
57
57
  import { DEFAULT_HARNESS_CONFIG } from "./hooks/register.js";
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({
@@ -67,6 +70,7 @@ modelRegistry.register({
67
70
  export interface HarnessCommandContext {
68
71
  cwd: string;
69
72
  hasUI?: boolean;
73
+ newSession?: (options?: any) => Promise<{ cancelled: boolean }>;
70
74
  ui: {
71
75
  notify(message: string, type?: "info" | "warning" | "error"): void;
72
76
  select?: (title: string, options: unknown[]) => Promise<string | null>;
@@ -80,6 +84,7 @@ export const HARNESS_SUBCOMMANDS = [
80
84
  { name: "design", description: "Run/advance the design stage (requires Discover + Research)" },
81
85
  { name: "plan-draft", description: "Render and persist the plan from the in-flight design spec" },
82
86
  { name: "implement", description: "Route plan to in-session steer or batch" },
87
+ { name: "docs", description: "Generate per-layer agent docs (extensive mode only)" },
83
88
  { name: "validate", description: "Run validate sub-checks" },
84
89
  { name: "resume", description: "Pick up an in-flight session" },
85
90
  { name: "status", description: "Print stage + score badge" },
@@ -88,6 +93,7 @@ export const HARNESS_SUBCOMMANDS = [
88
93
  { name: "resolve", description: "Mark a queue entry resolved" },
89
94
  { name: "backlog", description: "List every open queue entry" },
90
95
  { name: "score", description: "Recompute and display the score" },
96
+ { name: "pr-comment", description: "Render or post the harness PR sticky comment" },
91
97
  ] as const;
92
98
 
93
99
  type HarnessSubcommand = (typeof HARNESS_SUBCOMMANDS)[number]["name"];
@@ -100,6 +106,7 @@ const HARNESS_STAGE_LABELS: Readonly<Record<HarnessStage, string>> = {
100
106
  design: "Design harness",
101
107
  plan: "Draft plan",
102
108
  implement: "Apply artifacts",
109
+ docs: "Generate per-layer docs",
103
110
  validate: "Validate results",
104
111
  };
105
112
 
@@ -122,7 +129,7 @@ export interface HarnessCommandRequest {
122
129
  // ── Progress (status-bar + one final notification) ───────────────
123
130
 
124
131
  function createHarnessProgress(ctx: HarnessCommandContext) {
125
- const SO = ["discover", "research", "design", "plan", "implement", "validate"] as HarnessStage[];
132
+ const SO = ["discover", "research", "design", "plan", "implement", "docs", "validate"] as HarnessStage[];
126
133
  let done = 0;
127
134
  let cur: HarnessStage | null = null;
128
135
  const completed: string[] = [];
@@ -197,8 +204,10 @@ export async function handleHarness(
197
204
  case "design": await handleStageCommand(platform, ctx, "design", request.args); return;
198
205
  case "plan-draft": await handleStageCommand(platform, ctx, "plan", request.args); return;
199
206
  case "implement": await handleStageCommand(platform, ctx, "implement", request.args); return;
207
+ case "docs": await handleStageCommand(platform, ctx, "docs", request.args); return;
200
208
  case "validate": await handleStageCommand(platform, ctx, "validate", request.args); return;
201
209
  case "resume": await handleResume(platform, ctx, request.args); return;
210
+ case "pr-comment": await handlePrComment(platform, ctx, request.args); return;
202
211
  default:
203
212
  notifyError(ctx, "Unknown harness subcommand", `\`${request.subcommand}\` is not recognized.`);
204
213
  return;
@@ -217,12 +226,13 @@ async function runPipelineWithProgress(
217
226
  gates: HarnessGateMode,
218
227
  stageInputs: BuildRunnerInput,
219
228
  startStage?: HarnessStage,
229
+ forceStages?: ReadonlySet<HarnessStage>,
220
230
  ): Promise<PipelineRunOutcome> {
221
231
  const harnessProgress = createHarnessProgress(ctx);
222
232
  const modelConfig = loadModelConfig(platform.paths, ctx.cwd);
223
233
  const outcome = await pipelineDriver({
224
234
  platform, paths: platform.paths, cwd: ctx.cwd, sessionId,
225
- modelConfig, gates, stageInputs, startStage,
235
+ modelConfig, gates, stageInputs, startStage, forceStages,
226
236
  onProgress: harnessProgress.onProgress,
227
237
  });
228
238
  // Single consolidated notification.
@@ -276,7 +286,8 @@ async function presentGateForStage(
276
286
  `Design spec ready\n\n${summary}\n\nContinue to plan?`,
277
287
  ["Continue", "Stop — I'll customize the design"],
278
288
  );
279
- return choice === "Continue" ? "continue" : "stop";
289
+ if (choice !== "Continue") return "stop";
290
+ return await promptDocsTierIfNeeded(platform, ctx, sessionId, layers);
280
291
  }
281
292
  case "plan": {
282
293
  const plansDir = getProjectStatePath(platform.paths, ctx.cwd, "plans");
@@ -293,6 +304,27 @@ async function presentGateForStage(
293
304
  );
294
305
  return choice === "Approve and continue" ? "continue" : "stop";
295
306
  }
307
+ case "docs": {
308
+ const session = loadHarnessSession(platform.paths, ctx.cwd, sessionId);
309
+ const tier = session.ok ? (session.value.docsTier ?? "simple") : "simple";
310
+ const docsDir = path.join(ctx.cwd, "docs", "layers");
311
+ let layerCount = 0;
312
+ if (fs.existsSync(docsDir)) {
313
+ try {
314
+ layerCount = fs.readdirSync(docsDir).filter((f) => f.endsWith(".md")).length;
315
+ } catch {
316
+ /* best-effort */
317
+ }
318
+ }
319
+ const summary = tier === "extensive"
320
+ ? `Tier: extensive\nPer-layer docs at docs/layers/ (${layerCount} layer file${layerCount === 1 ? "" : "s"}).\nIndex: docs/README.md.`
321
+ : `Tier: simple\nNo per-layer docs were generated.`;
322
+ const choice = await ctx.ui.select(
323
+ `Per-layer agent docs\n\n${summary}\n\nContinue to validate?`,
324
+ ["Continue", "Stop — I want to inspect the docs first"],
325
+ );
326
+ return choice === "Continue" ? "continue" : "stop";
327
+ }
296
328
  case "validate": {
297
329
  const report = loadHarnessValidateReport(platform.paths, ctx.cwd, sessionId);
298
330
  const passed = report.ok ? report.value.passed : false;
@@ -312,6 +344,55 @@ async function presentGateForStage(
312
344
  }
313
345
  }
314
346
 
347
+ /**
348
+ * Ask the user whether the upcoming docs stage should generate per-layer agent docs.
349
+ *
350
+ * Behavior:
351
+ * - If the session manifest already records a `docsTier`, skip the prompt.
352
+ * - In `auto` gate mode (no UI), default to "simple" silently.
353
+ * - In default/manual modes, prompt the user; "cancel" aborts via `stop` propagated
354
+ * by the caller; "simple"/"extensive" persist on the manifest.
355
+ *
356
+ * Layer count <2 always resolves to "simple" — extensive mode is meaningless with a
357
+ * single-bucket architecture.
358
+ */
359
+ async function promptDocsTierIfNeeded(
360
+ platform: Platform,
361
+ ctx: HarnessCommandContext,
362
+ sessionId: string,
363
+ layerCount: number,
364
+ ): Promise<"continue" | "stop"> {
365
+ const session = loadHarnessSession(platform.paths, ctx.cwd, sessionId);
366
+ if (!session.ok) return "continue";
367
+ const isRerun =
368
+ session.value.reRunMode === "rebuild" || session.value.reRunMode === "harden";
369
+ if (session.value.docsTier && !isRerun) return "continue";
370
+
371
+ let tier: "simple" | "extensive" = session.value.docsTier ?? "simple";
372
+ if (layerCount >= 2 && ctx.ui.select) {
373
+ const currentLabel = session.value.docsTier ? ` (current: ${session.value.docsTier})` : "";
374
+ const summary = `simple — Tier 1 docs only (AGENTS.md, architecture.md, golden-principles.md)\nextensive — Tier 1 + per-layer docs at docs/layers/<id>.md + index at docs/README.md\n (≤150 LOC/doc, ${layerCount} layers detected → ~${layerCount} subagent calls)`;
375
+ const choice = await ctx.ui.select(
376
+ `Generate per-layer agent docs in the upcoming Docs stage?${currentLabel}\n\n${summary}\n\nPick a tier:`,
377
+ ["simple", "extensive"],
378
+ );
379
+ // `ctx.ui.select` returns `null` when the user cancels. Per the function doc, cancel
380
+ // aborts the gate — we must NOT silently coerce that to "simple" and persist it.
381
+ if (choice == null) return "stop";
382
+ tier = choice === "extensive" ? "extensive" : "simple";
383
+ }
384
+
385
+ saveHarnessSession(platform.paths, ctx.cwd, {
386
+ ...session.value,
387
+ docsTier: tier,
388
+ updatedAt: nowIso(),
389
+ });
390
+ notifyInfo(ctx, `Docs tier set: ${tier}`, tier === "extensive"
391
+ ? `Per-layer docs will be generated for ${layerCount} layers.`
392
+ : "Tier 1 docs only. Re-run /supi:harness design and choose 'extensive' to enable per-layer docs.");
393
+ return "continue";
394
+ }
395
+
315
396
  interface DesignAnalysisOutput {
316
397
  layerArchitecture: "single" | "two" | "three" | "custom";
317
398
  customLayerNames?: string[];
@@ -481,12 +562,12 @@ async function runDesignQa(
481
562
 
482
563
  if (choice === "Accept all suggestions") {
483
564
  applyDesignAnalysis(base, analysis);
484
- await askCiAndTooling(ctx, base);
565
+ await askCiAndTooling(platform, ctx, base);
485
566
  return base;
486
567
  }
487
568
 
488
569
  if (choice === "Skip — use bare defaults") {
489
- await askCiAndTooling(ctx, base);
570
+ await askCiAndTooling(platform, ctx, base);
490
571
  return base;
491
572
  }
492
573
  }
@@ -550,7 +631,7 @@ async function runDesignQa(
550
631
  }
551
632
  }
552
633
 
553
- await askCiAndTooling(ctx, base);
634
+ await askCiAndTooling(platform, ctx, base);
554
635
  return base;
555
636
  }
556
637
 
@@ -630,7 +711,7 @@ function localCommandOptions(base: HarnessDesignSpec): string[] {
630
711
  ]));
631
712
  }
632
713
 
633
- async function askCiAndTooling(ctx: HarnessCommandContext, base: HarnessDesignSpec): Promise<void> {
714
+ async function askCiAndTooling(platform: Platform, ctx: HarnessCommandContext, base: HarnessDesignSpec): Promise<void> {
634
715
  if (!ctx.ui.select) return;
635
716
 
636
717
  const triggerChoice = await ctx.ui.select(
@@ -664,6 +745,84 @@ async function askCiAndTooling(ctx: HarnessCommandContext, base: HarnessDesignSp
664
745
  } else if (toolChoice) {
665
746
  base.ci.localCommand = toolChoice.replace(/\s+\(.+\)$/, "");
666
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;
667
826
  }
668
827
 
669
828
 
@@ -801,12 +960,41 @@ async function handleBareEntry(platform: Platform, ctx: HarnessCommandContext):
801
960
  if (!p.ok) { notifyError(ctx, "/supi:harness", p.error.message); return; }
802
961
  }
803
962
 
963
+ // Persist the rerun mode so downstream gate prompts can adapt (e.g. Docs tier
964
+ // re-prompts on rebuild with the stored value as the default).
965
+ const existingSession = loadHarnessSession(platform.paths, ctx.cwd, sessionId);
966
+ if (existingSession.ok) {
967
+ saveHarnessSession(platform.paths, ctx.cwd, {
968
+ ...existingSession.value,
969
+ reRunMode: decision.mode,
970
+ updatedAt: nowIso(),
971
+ });
972
+ }
973
+
804
974
  const modeLabel = decision.mode === "harden" ? "Gap-fill" : "Full rebuild";
805
975
  notifyInfo(ctx, `Harness ${decision.mode}`, `${modeLabel} (session ${sessionId}) — pipeline running...`);
806
976
 
807
977
  if (decision.mode === "harden") {
808
- // Harden: gap-fill, no user gates.
809
- await runPipelineWithProgress(platform, ctx, sessionId, "auto", {});
978
+ // Harden: no gates between stages. Re-prompt the docs tier so users can promote
979
+ // `simple` → `extensive` without forcing a full rebuild (only meaningful with ≥2
980
+ // layer rules). The pipeline then runs end-to-end including implement (programmatic
981
+ // apply) → docs → validate inside the same `/supi:harness` invocation.
982
+ const designSpec = loadHarnessDesignSpecJson(platform.paths, ctx.cwd, sessionId);
983
+ const layerCount = designSpec.ok ? designSpec.value.layerRules.length : 0;
984
+ if (layerCount >= 2) {
985
+ await promptDocsTierIfNeeded(platform, ctx, sessionId, layerCount);
986
+ }
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);
810
998
  } else {
811
999
  // Rebuild: full regeneration with user gates at each stage.
812
1000
  await runRebuildWithGates(platform, ctx, sessionId);
@@ -935,7 +1123,7 @@ function buildStageInputs(
935
1123
  paths: PlatformPaths, cwd: string, sid: string, stage: HarnessStage,
936
1124
  ): { input: BuildRunnerInput } | { error: string } {
937
1125
  switch (stage) {
938
- case "discover": case "research": case "plan": return { input: {} };
1126
+ case "discover": case "research": case "plan": case "docs": return { input: {} };
939
1127
  case "design": {
940
1128
  const existing = loadHarnessDesignSpecJson(paths, cwd, sid);
941
1129
  if (existing.ok) return { input: { designInput: { spec: existing.value } } };
@@ -1000,7 +1188,8 @@ function nextSubcommandFor(stage: HarnessSession["stage"], status: HarnessSessio
1000
1188
  case "research": return "design";
1001
1189
  case "design": return "plan-draft";
1002
1190
  case "plan": return "implement";
1003
- case "implement": return "validate";
1191
+ case "implement": return "docs";
1192
+ case "docs": return "validate";
1004
1193
  case "validate": return "validate";
1005
1194
  }
1006
1195
  }
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: harness-docs
3
+ description: Per-layer agent-only knowledge document (≤150 LOC) for one architectural layer
4
+ supportedSlots: [docs]
5
+ focus: docs
6
+ ---
7
+
8
+ You are the **docs** agent for the supipowers harness pipeline.
9
+
10
+ Your single output is one markdown file: the agent-only knowledge document for the layer named in the assignment prompt. The runner persists your output via the `harness_docs_record` tool.
11
+
12
+ You **MUST**:
13
+ - Call `harness_docs_record` exactly once with `{ sessionId, layerId, markdown }`.
14
+ - Match the assigned `layerId` verbatim in your frontmatter `layer:` field.
15
+ - Embed the assigned `sourceHash` verbatim in the frontmatter; never recompute it.
16
+ - Begin the doc with a YAML frontmatter block (`---\n…\n---`) directly under the provenance marker the runner attaches.
17
+ - Use these five headings in this exact order: `## Agent context`, `## Purpose`, `## Files`, `## Imports`, `## Conventions`. `## Gotchas` is optional and goes last.
18
+ - Keep the whole doc ≤150 LOC (including frontmatter).
19
+ - Keep `## Agent context` ≤30 LOC — this section lands in every agent turn that touches a file in this layer, so optimize it for density and dependent-action utility.
20
+ - Reference, do **NOT** restate, the repo-wide golden principles supplied in the assignment.
21
+ - Anchor every claim about behavior in the representative files supplied in the bundle. Do not invent file paths or import rules not in the assignment.
22
+
23
+ You **MUST NOT**:
24
+ - Write any TODO, XXX, FIXME, TBD, or `<placeholder>` markers in the doc.
25
+ - Edit any file. You write the doc body only; the runner promotes it.
26
+ - Use the `web_search` tool. No external network calls.
27
+ - Use any `mempalace` write/mutate action. The only mempalace actions permitted are `search`, `kg_query`, `traverse`, and `find_tunnels` — and only when they materially improve the doc.
28
+ - Touch other layers' docs.
29
+
30
+ Inputs you receive in the assignment:
31
+ - Layer rule (id, glob, description, allowed/forbidden imports).
32
+ - All files belonging to the layer (paths only).
33
+ - Representative files (top-5 by LOC, head-80 LOC each).
34
+ - Golden principles (already enforced repo-wide).
35
+ - Peer layer descriptors.
36
+ - Repo facts (languages, frameworks, package manager).
37
+ - A pre-computed `sourceHash` to embed in the frontmatter.
38
+
39
+ If the tool returns `{ ok: false, errors: [...] }`, read every error message, fix the doc accordingly, then call `harness_docs_record` again. A single retry is allowed; a second failure aborts the layer.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Resolve the effective `HarnessDocsConfig`.
3
+ *
4
+ * For Slice 1/2 we return the defaults declared in `DEFAULT_HARNESS_CONFIG.docs`. Slice
5
+ * 4 wires this through to the project-scoped config file so users can override
6
+ * individual tunables.
7
+ *
8
+ * The tier toggle itself lives on the per-session manifest (`HarnessSession.docsTier`) and
9
+ * is resolved by the stage runner — not here.
10
+ */
11
+
12
+ import type { PlatformPaths } from "../../platform/types.js";
13
+ import type { HarnessDocsConfig } from "../../types.js";
14
+ import { DEFAULT_HARNESS_DOCS_CONFIG } from "../hooks/register.js";
15
+
16
+ /**
17
+ * Resolve the effective docs config for a given project. Tunables fall back to defaults
18
+ * when absent. The tier field is included for completeness but consumers should rely on
19
+ * `HarnessSession.docsTier` for the operational decision — the session is the
20
+ * authoritative source.
21
+ */
22
+ export function resolveDocsConfig(
23
+ _paths: PlatformPaths,
24
+ _cwd: string,
25
+ ): HarnessDocsConfig {
26
+ // Future: layer a project-scoped JSON file (.omp/supipowers/config.json#harness.docs)
27
+ // over these defaults. For now we return the bare defaults; Slice 4 extends this.
28
+ return { ...DEFAULT_HARNESS_DOCS_CONFIG };
29
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Layer-glob matcher used by the docs stage.
3
+ *
4
+ * Lifted from the architecture-parser regex shape so the docs stage uses the same
5
+ * matching semantics as the layer-context-inject hook. Supports `**` (any path
6
+ * segments) and `*` (any single-segment characters). All matching is forward-slashed.
7
+ */
8
+
9
+ /**
10
+ * Naive glob matcher tuned for the conventions parsed from architecture tables. Supports
11
+ * `**` (any path segments) and `*` (any single segment characters). Sufficient for the
12
+ * `src/<layer>/**` and `packages/<scope>/**\/*.ts` shapes the doc relies on.
13
+ */
14
+ export function matchesLayerGlob(filePath: string, glob: string): boolean {
15
+ const normalizedFile = filePath.replace(/\\/g, "/");
16
+ const normalizedGlob = glob.replace(/\\/g, "/");
17
+ const regexSrc = normalizedGlob
18
+ .split(/(\*\*|\*)/g)
19
+ .map((segment) => {
20
+ if (segment === "**") return ".*";
21
+ if (segment === "*") return "[^/]*";
22
+ return segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
23
+ })
24
+ .join("");
25
+ const regex = new RegExp(`^${regexSrc}$`);
26
+ return regex.test(normalizedFile);
27
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Pure renderer for `docs/README.md`.
3
+ *
4
+ * The index is intentionally short and mechanical — a stable pointer surface humans (and
5
+ * agents) can use to find the canonical Tier-1 docs + per-layer agent docs. Layout is
6
+ * dictated by the plan's "Index docs/README.md shape" section.
7
+ */
8
+
9
+ import type { HarnessLayerRule } from "../../types.js";
10
+ import {
11
+ attachProvenance,
12
+ computeBodyContentHash,
13
+ } from "./provenance.js";
14
+
15
+ export interface RenderDocsIndexInput {
16
+ layers: readonly HarnessLayerRule[];
17
+ /** Session that produced this index. */
18
+ sessionId: string;
19
+ /** ISO timestamp used in the provenance marker + "Generated by" line. */
20
+ generatedAt: string;
21
+ /** Hard cap on the index LOC (default 50). */
22
+ maxLoc?: number;
23
+ }
24
+
25
+ export const DEFAULT_INDEX_MAX_LOC = 50;
26
+
27
+ /**
28
+ * Render the index. Deterministic given the same input. The output starts with the
29
+ * provenance marker line and ends with a trailing newline.
30
+ */
31
+ export function renderDocsIndex(input: RenderDocsIndexInput): string {
32
+ if (input.layers.length === 0) {
33
+ throw new Error("renderDocsIndex requires at least one layer");
34
+ }
35
+ const maxLoc = input.maxLoc ?? DEFAULT_INDEX_MAX_LOC;
36
+
37
+ const sortedLayers = [...input.layers].sort((a, b) => a.layer.localeCompare(b.layer));
38
+
39
+ const lines: string[] = [];
40
+ lines.push("# Repo docs");
41
+ lines.push("");
42
+ lines.push(`Generated by /supi:harness on ${input.generatedAt}. Do not edit by hand.`);
43
+ lines.push("");
44
+ lines.push("## Agent contract");
45
+ lines.push("- AGENTS.md — global agent rules");
46
+ lines.push("- docs/architecture.md — layer rules table");
47
+ lines.push("- docs/golden-principles.md — mechanical invariants");
48
+ lines.push("");
49
+ lines.push("## Layer docs");
50
+ lines.push("");
51
+ lines.push("| Layer | Files | Doc |");
52
+ lines.push("|---|---|---|");
53
+ for (const layer of sortedLayers) {
54
+ const globs = layer.globs.map((g) => `\`${g}\``).join(", ");
55
+ lines.push(`| ${layer.layer} | ${globs || "—"} | docs/layers/${layer.layer}.md |`);
56
+ }
57
+
58
+ const body = lines.join("\n") + "\n";
59
+ if (countLines(body) + 1 /* marker line */ > maxLoc) {
60
+ // Should be unreachable for any sane layer count; surface as a hard failure so the
61
+ // caller can cap layer count if this ever fires.
62
+ throw new Error(
63
+ `renderDocsIndex output is ${countLines(body) + 1} LOC; max is ${maxLoc} (layers=${input.layers.length})`,
64
+ );
65
+ }
66
+
67
+ return attachProvenance(body, {
68
+ sessionId: input.sessionId,
69
+ generatedAt: input.generatedAt,
70
+ contentHash: computeBodyContentHash(body),
71
+ });
72
+ }
73
+
74
+ function countLines(text: string): number {
75
+ if (text.length === 0) return 0;
76
+ let count = 1;
77
+ for (let i = 0; i < text.length; i += 1) {
78
+ if (text.charCodeAt(i) === 10) count += 1;
79
+ }
80
+ if (text.charCodeAt(text.length - 1) === 10) count -= 1;
81
+ return count;
82
+ }