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
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Interactive Git topology + branch-protection sub-step for `/supi:harness`.
3
+ *
4
+ * Pure-ish entry point: takes an `ExecFn`, a minimal UI interface, and a session dir, and
5
+ * returns a populated `HarnessCiGitConfig` (or null when the user opts out). All side
6
+ * effects — branch creation, ruleset POST, manual-instructions doc — are routed through
7
+ * the dependencies so the harness command layer can drive it without changing.
8
+ *
9
+ * Why split this out of `src/harness/command.ts`? Two reasons:
10
+ * 1. Testability. The command file is 1100+ LOC and pulls in the full platform/agent
11
+ * stack; we want a tight Q&A test surface that operates on a fake UI + scripted
12
+ * `exec` results.
13
+ * 2. Separation of concerns. The command layer owns "when do we ask?", this module owns
14
+ * "what do we ask, and what do we do with the answers?".
15
+ *
16
+ * Decision tree (matches the user's spec):
17
+ * 1. Detect topology (default branch + dev candidates).
18
+ * 2. If default is `main`/`master`:
19
+ * a. "Do you have a development branch?"
20
+ * - Yes → "Which one?" (existing candidates or custom).
21
+ * - No → "Do you want one?"
22
+ * - Yes → "Name?" → "Create new from main, or promote existing?"
23
+ * - No → record devBranch=null, no enforcement.
24
+ * 3. If default is *not* main/master, treat it as already-dev and ask the user to
25
+ * confirm + pick a separate "main" branch from the listed remotes.
26
+ * 4. Optionally apply protections via gh; render manual-instructions doc on any
27
+ * skipped/failed protection step.
28
+ */
29
+
30
+ import * as fs from "node:fs";
31
+ import * as path from "node:path";
32
+
33
+ import type {
34
+ HarnessCiGitConfig,
35
+ HarnessCiGitFinding,
36
+ } from "../types.js";
37
+ import {
38
+ applyMainProtectionRuleset,
39
+ createBranchFromRef,
40
+ detectGitTopology,
41
+ isSafeBranchName,
42
+ renderManualInstructions,
43
+ type ExecFn,
44
+ type GhExecOutcome,
45
+ } from "./git-verification.js";
46
+
47
+ export interface GitVerifyQaUi {
48
+ select: (title: string, options: string[]) => Promise<string | null>;
49
+ input: (label: string) => Promise<string | null>;
50
+ notify: (message: string, level?: "info" | "warning" | "error") => void;
51
+ }
52
+
53
+ export interface GitVerifyQaInput {
54
+ exec: ExecFn;
55
+ cwd: string;
56
+ ui: GitVerifyQaUi;
57
+ /** Absolute path to the harness session directory where manual instructions land. */
58
+ sessionDir: string;
59
+ /** Clock injection for deterministic tests. */
60
+ now?: () => string;
61
+ }
62
+
63
+ const TOP_LEVEL_RUN = "Run verification";
64
+ const TOP_LEVEL_SKIP = "Skip";
65
+
66
+ /**
67
+ * Capture-side validation for branch names that flow into the persisted design spec.
68
+ * Any value that reaches `HarnessCiGitConfig.{mainBranch,devBranch}` is rendered into
69
+ * the GitHub Actions workflow (single-quoted YAML expression and double-quoted shell
70
+ * line). We accept only the strict subset defined by `isSafeBranchName` so the render
71
+ * path stays escape-free and an injected branch name cannot break the workflow.
72
+ *
73
+ * Returns the trimmed name on success; pushes a finding and returns `null` otherwise.
74
+ */
75
+ function captureBranchName(
76
+ raw: string | null | undefined,
77
+ role: "main" | "dev",
78
+ findings: HarnessCiGitFinding[],
79
+ ): string | null {
80
+ const trimmed = raw?.trim() ?? "";
81
+ if (trimmed.length === 0) return null;
82
+ if (!isSafeBranchName(trimmed)) {
83
+ findings.push({
84
+ severity: "warning",
85
+ message: `Rejected unsafe ${role} branch name: ${JSON.stringify(trimmed)}`,
86
+ remediation:
87
+ "Branch names must match [A-Za-z0-9._/-]+ (no whitespace, quotes, or shell metacharacters). " +
88
+ "Re-run /supi:harness with a sanitized name.",
89
+ });
90
+ return null;
91
+ }
92
+ return trimmed;
93
+ }
94
+
95
+ /**
96
+ * Drive the interactive Git verification flow.
97
+ *
98
+ * Returns:
99
+ * - `null` when the user opts out of running verification entirely.
100
+ * - A populated `HarnessCiGitConfig` otherwise. Even when sub-steps fail (gh missing,
101
+ * branch creation rejected by the remote), we return a config so the design spec
102
+ * captures the user's intent — the failures land in `verification.findings`.
103
+ */
104
+ export async function runGitVerificationQa(
105
+ input: GitVerifyQaInput,
106
+ ): Promise<HarnessCiGitConfig | null> {
107
+ const now = input.now ?? (() => new Date().toISOString());
108
+
109
+ const topLevel = await input.ui.select(
110
+ "Run Git branching verification now? (checks default branch, optional dev branch, and PR-source restrictions)",
111
+ [TOP_LEVEL_RUN, TOP_LEVEL_SKIP],
112
+ );
113
+ if (topLevel !== TOP_LEVEL_RUN) {
114
+ return null;
115
+ }
116
+
117
+ const topology = await detectGitTopology(input.exec, input.cwd);
118
+ input.ui.notify(
119
+ `Detected default branch: ${topology.mainBranch}` +
120
+ (topology.devBranchCandidates.length > 0
121
+ ? ` — dev candidates: ${topology.devBranchCandidates.join(", ")}`
122
+ : ""),
123
+ "info",
124
+ );
125
+
126
+ const findings: HarnessCiGitFinding[] = [];
127
+ const appliedProtections: string[] = [];
128
+
129
+ let mainBranch = topology.mainBranch;
130
+ let devBranch: string | null = null;
131
+ let enforceMainFromDevOnly = false;
132
+
133
+ if (topology.defaultIsMainOrMaster) {
134
+ const decision = await resolveDevBranchWhenDefaultIsMain(input, topology, findings, appliedProtections);
135
+ devBranch = decision.devBranch;
136
+ enforceMainFromDevOnly = decision.enforceMainFromDevOnly;
137
+ } else {
138
+ const decision = await resolveDevBranchWhenDefaultIsAlreadyDev(input, topology, findings);
139
+ if (decision.mainBranch) mainBranch = decision.mainBranch;
140
+ devBranch = decision.devBranch;
141
+ enforceMainFromDevOnly = decision.enforceMainFromDevOnly;
142
+ }
143
+
144
+ // Attempt the server-side ruleset opportunistically when enforcement is on.
145
+ if (enforceMainFromDevOnly && devBranch) {
146
+ const outcome = await applyMainProtectionRuleset(input.exec, input.cwd, {
147
+ mainBranch,
148
+ devBranch,
149
+ });
150
+ foldRulesetOutcome(outcome, findings, appliedProtections);
151
+ }
152
+
153
+ // CI-side guardrail always applies when enforcement is on — recorded so the validate
154
+ // stage can confirm the rendered workflow contains the verify-pr-source job.
155
+ if (enforceMainFromDevOnly && devBranch) {
156
+ appliedProtections.push("ci-guardrail");
157
+ }
158
+
159
+ // Manual-instructions doc lives at <session>/git-verification.md whenever any
160
+ // protection step skipped or failed — gives the user a copy-pasteable fallback.
161
+ const ghAvailable = appliedProtections.includes("ruleset");
162
+ const needsManualDoc =
163
+ enforceMainFromDevOnly && devBranch !== null && !ghAvailable;
164
+
165
+ let manualInstructionsPath: string | null = null;
166
+ if (needsManualDoc) {
167
+ const md = renderManualInstructions({
168
+ mainBranch,
169
+ devBranch,
170
+ enforceMainFromDevOnly,
171
+ ghAvailable,
172
+ });
173
+ const rel = "git-verification.md";
174
+ fs.mkdirSync(input.sessionDir, { recursive: true });
175
+ fs.writeFileSync(path.join(input.sessionDir, rel), md, "utf8");
176
+ manualInstructionsPath = rel;
177
+ input.ui.notify(
178
+ `Wrote manual Git verification steps to ${rel}.`,
179
+ "info",
180
+ );
181
+ }
182
+
183
+ return {
184
+ mainBranch,
185
+ devBranch,
186
+ enforceMainFromDevOnly,
187
+ verification: {
188
+ checkedAt: now(),
189
+ appliedProtections,
190
+ findings,
191
+ manualInstructionsPath,
192
+ },
193
+ };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Decision sub-flows
198
+ // ---------------------------------------------------------------------------
199
+
200
+ interface DevDecision {
201
+ devBranch: string | null;
202
+ enforceMainFromDevOnly: boolean;
203
+ /** Set by the "default is already dev" path; left undefined when the caller keeps the detected main. */
204
+ mainBranch?: string;
205
+ }
206
+
207
+ async function resolveDevBranchWhenDefaultIsMain(
208
+ input: GitVerifyQaInput,
209
+ topology: Awaited<ReturnType<typeof detectGitTopology>>,
210
+ findings: HarnessCiGitFinding[],
211
+ appliedProtections: string[],
212
+ ): Promise<DevDecision> {
213
+ const hasOptions = topology.devBranchCandidates.length > 0;
214
+ const haveDevPrompt = hasOptions
215
+ ? "Yes — use " + topology.devBranchCandidates[0]
216
+ : "Yes, I have one";
217
+ const decision = await input.ui.select(
218
+ "Do you have a development branch?",
219
+ [haveDevPrompt, "No, I don't have one"],
220
+ );
221
+
222
+ if (decision && decision.startsWith("Yes")) {
223
+ if (hasOptions) {
224
+ const picked = topology.devBranchCandidates[0];
225
+ const safe = captureBranchName(picked, "dev", findings);
226
+ if (safe) return { devBranch: safe, enforceMainFromDevOnly: true };
227
+ return { devBranch: null, enforceMainFromDevOnly: false };
228
+ }
229
+ const safe = captureBranchName(
230
+ await input.ui.input("What is your development branch?"),
231
+ "dev",
232
+ findings,
233
+ );
234
+ if (safe) return { devBranch: safe, enforceMainFromDevOnly: true };
235
+ findings.push({
236
+ severity: "warning",
237
+ message: "User claimed to have a dev branch but provided no usable name.",
238
+ remediation: "Re-run /supi:harness and provide a valid branch name.",
239
+ });
240
+ return { devBranch: null, enforceMainFromDevOnly: false };
241
+ }
242
+
243
+ // No existing dev branch — ask if they want one.
244
+ const wantsOne = await input.ui.select(
245
+ "Do you want a dedicated development branch?",
246
+ ["Yes — create one", "No, run CI on main only"],
247
+ );
248
+ if (!wantsOne || !wantsOne.startsWith("Yes")) {
249
+ findings.push({
250
+ severity: "info",
251
+ message: "User opted out of a dedicated dev branch.",
252
+ remediation: "Re-run /supi:harness if you change your mind.",
253
+ });
254
+ return { devBranch: null, enforceMainFromDevOnly: false };
255
+ }
256
+
257
+ const rawName = (await input.ui.input("What name? (default: dev)"))?.trim() || "dev";
258
+ const name = captureBranchName(rawName, "dev", findings);
259
+ if (!name) {
260
+ return { devBranch: null, enforceMainFromDevOnly: false };
261
+ }
262
+ const action = await input.ui.select(
263
+ `Create \`${name}\` from \`${topology.mainBranch}\`, or promote an existing branch?`,
264
+ [
265
+ "Create new branch from main",
266
+ ...(topology.allBranches.length > 0 ? ["Promote an existing branch"] : []),
267
+ ],
268
+ );
269
+
270
+ if (action === "Promote an existing branch") {
271
+ const existingPick = await input.ui.select(
272
+ "Pick the branch to promote:",
273
+ topology.allBranches.filter((b) => b !== topology.mainBranch),
274
+ );
275
+ const safePick = captureBranchName(existingPick, "dev", findings);
276
+ if (safePick) {
277
+ return { devBranch: safePick, enforceMainFromDevOnly: true };
278
+ }
279
+ findings.push({
280
+ severity: "warning",
281
+ message: "No branch picked for promotion.",
282
+ remediation: "Re-run /supi:harness to finish wiring the dev branch.",
283
+ });
284
+ return { devBranch: null, enforceMainFromDevOnly: false };
285
+ }
286
+
287
+ // Create new branch from main.
288
+ const outcome = await createBranchFromRef(
289
+ input.exec,
290
+ input.cwd,
291
+ name,
292
+ `origin/${topology.mainBranch}`,
293
+ );
294
+ if (outcome.kind === "created" || outcome.kind === "already-exists") {
295
+ appliedProtections.push("branch-created");
296
+ if (outcome.kind === "already-exists") {
297
+ findings.push({
298
+ severity: "info",
299
+ message: `Branch \`${name}\` already exists — reusing.`,
300
+ });
301
+ }
302
+ return { devBranch: name, enforceMainFromDevOnly: true };
303
+ }
304
+ findings.push({
305
+ severity: "error",
306
+ message: `Failed to create branch \`${name}\`: ${outcome.reason}`,
307
+ remediation: `Create the branch manually with: git switch -c ${name} origin/${topology.mainBranch} && git push -u origin ${name}`,
308
+ });
309
+ // Still record the user's intent so the design spec captures it; protections will be
310
+ // rejected by validate.
311
+ return { devBranch: name, enforceMainFromDevOnly: true };
312
+ }
313
+
314
+ async function resolveDevBranchWhenDefaultIsAlreadyDev(
315
+ input: GitVerifyQaInput,
316
+ topology: Awaited<ReturnType<typeof detectGitTopology>>,
317
+ findings: HarnessCiGitFinding[],
318
+ ): Promise<DevDecision> {
319
+ const otherBranches = topology.allBranches.filter((b) => b !== topology.mainBranch);
320
+ const pick = await input.ui.select(
321
+ `\`${topology.mainBranch}\` looks like a development branch. Pick the *dev* branch the harness should target:`,
322
+ [topology.mainBranch, ...otherBranches],
323
+ );
324
+ const devBranch = captureBranchName(pick ?? topology.mainBranch, "dev", findings);
325
+ if (!devBranch) {
326
+ return { devBranch: null, enforceMainFromDevOnly: false };
327
+ }
328
+
329
+ // Ask which branch is the protected main.
330
+ const mainCandidates = topology.allBranches.filter(
331
+ (b) => b === "main" || b === "master",
332
+ );
333
+ let mainBranch: string | null = mainCandidates[0] ?? "main";
334
+ if (mainCandidates.length === 0) {
335
+ const provided = await input.ui.input("What is your main/master branch?");
336
+ if (provided && provided.trim().length > 0) {
337
+ mainBranch = captureBranchName(provided, "main", findings);
338
+ }
339
+ }
340
+ if (!mainBranch) {
341
+ return { devBranch, enforceMainFromDevOnly: false };
342
+ }
343
+
344
+ if (mainBranch === devBranch) {
345
+ findings.push({
346
+ severity: "warning",
347
+ message: "Main branch and dev branch are the same; PR-source restriction will be disabled.",
348
+ remediation: "Re-run /supi:harness and pick distinct branches.",
349
+ });
350
+ return { devBranch, mainBranch, enforceMainFromDevOnly: false };
351
+ }
352
+
353
+ return { devBranch, mainBranch, enforceMainFromDevOnly: true };
354
+ }
355
+
356
+ function foldRulesetOutcome(
357
+ outcome: GhExecOutcome,
358
+ findings: HarnessCiGitFinding[],
359
+ appliedProtections: string[],
360
+ ): void {
361
+ switch (outcome.kind) {
362
+ case "applied":
363
+ appliedProtections.push("ruleset");
364
+ return;
365
+ case "skipped":
366
+ switch (outcome.reason) {
367
+ case "no-cli":
368
+ findings.push({
369
+ severity: "warning",
370
+ message: "`gh` CLI is not installed; could not apply server-side ruleset.",
371
+ remediation: "Install gh (https://cli.github.com/) and re-run /supi:harness, or follow the manual steps.",
372
+ });
373
+ return;
374
+ case "no-auth":
375
+ findings.push({
376
+ severity: "warning",
377
+ message: "`gh` is not authenticated; could not apply server-side ruleset.",
378
+ remediation: "Run `gh auth login --scopes admin:repo` and re-run /supi:harness.",
379
+ });
380
+ return;
381
+ case "no-permission":
382
+ findings.push({
383
+ severity: "warning",
384
+ message: "`gh` lacks `admin:repo` scope; could not apply server-side ruleset.",
385
+ remediation: "Run `gh auth refresh -s admin:repo` and re-run /supi:harness, or follow the manual steps.",
386
+ });
387
+ return;
388
+ case "no-repo":
389
+ findings.push({
390
+ severity: "info",
391
+ message: "Could not detect GitHub repo from `gh repo view`; skipping ruleset.",
392
+ remediation: "Confirm the repository is connected to GitHub (gh repo view should print owner/repo).",
393
+ });
394
+ return;
395
+ case "no-dev-branch":
396
+ return; // Should be unreachable here — we gate on devBranch above.
397
+ }
398
+ case "failed":
399
+ findings.push({
400
+ severity: "error",
401
+ message: `Ruleset API call failed: ${outcome.reason}`,
402
+ remediation: "Inspect the failure and apply the ruleset manually via Settings → Rules → Rulesets.",
403
+ });
404
+ return;
405
+ }
406
+ }
@@ -31,6 +31,8 @@ import {
31
31
  getHarnessArchitectureDocPath,
32
32
  getHarnessMarkerPath,
33
33
  } from "../project-paths.js";
34
+ import { extractAgentContextSection } from "../docs/validator.js";
35
+ import { parseProvenance } from "../docs/provenance.js";
34
36
 
35
37
  export interface LayerContextHookOptions {
36
38
  /**
@@ -86,6 +88,13 @@ export interface LayerContextInjectionResult {
86
88
  /**
87
89
  * Compute the addendum for a single hook invocation. Pure-ish: reads the file system but
88
90
  * never mutates state. Tests call this directly with a known cwd + candidate file.
91
+ *
92
+ * Resolution order:
93
+ * 1. If `docs/layers/<layerId>.md` exists, extract its `## Agent context` section and
94
+ * return it (capped at `addendum_max_chars`). This is the preferred path once the
95
+ * docs stage has run.
96
+ * 2. Otherwise, fall back to the architecture-doc-derived addendum so projects that
97
+ * have not generated per-layer docs still receive a useful reminder.
89
98
  */
90
99
  export function computeLayerAddendum(input: {
91
100
  cwd: string;
@@ -93,6 +102,8 @@ export function computeLayerAddendum(input: {
93
102
  config: HarnessHookConfig["layer_context_inject"];
94
103
  /** Override the resolved architecture-doc path; tests use this to point at a fixture. */
95
104
  archPath?: string;
105
+ /** Override the resolved per-layer doc path; tests use this to point at a fixture. */
106
+ layerDocPath?: (layerId: string) => string;
96
107
  }): LayerContextInjectionResult {
97
108
  if (!input.config.enabled) return { addendum: "", reason: "disabled" };
98
109
  if (!input.candidateFile) return { addendum: "", reason: "no candidate file" };
@@ -101,8 +112,31 @@ export function computeLayerAddendum(input: {
101
112
  if (rules.length === 0) return { addendum: "", reason: "no rules parsed" };
102
113
  const rule = resolveLayerForFile(input.candidateFile, rules);
103
114
  if (!rule) return { addendum: "", reason: "no rule matches candidate file" };
115
+
116
+ // Preferred path: per-layer agent doc.
117
+ const docPath = input.layerDocPath
118
+ ? input.layerDocPath(rule.layer)
119
+ : `${input.cwd}/docs/layers/${rule.layer}.md`;
120
+ if (fs.existsSync(docPath)) {
121
+ try {
122
+ const contents = fs.readFileSync(docPath, "utf8");
123
+ const parsed = parseProvenance(contents);
124
+ const body = parsed ? parsed.body : contents;
125
+ const section = extractAgentContextSection(body);
126
+ if (section.length > 0) {
127
+ const cap = input.config.addendum_max_chars;
128
+ const capped = section.length <= cap
129
+ ? section
130
+ : `${section.slice(0, Math.max(0, cap - 1))}…`;
131
+ return { addendum: capped, reason: "matched (per-layer doc)" };
132
+ }
133
+ } catch {
134
+ // fall through to architecture-doc fallback on any read error
135
+ }
136
+ }
137
+
104
138
  const addendum = buildLayerAddendum(input.candidateFile, rule, input.config.addendum_max_chars);
105
- return { addendum, reason: "matched" };
139
+ return { addendum, reason: "matched (architecture.md fallback)" };
106
140
  }
107
141
 
108
142
  /**
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { Platform } from "../../platform/types.js";
15
- import type { HarnessConfig, HarnessHookConfig } from "../../types.js";
15
+ import type { HarnessConfig, HarnessDocsConfig, HarnessHookConfig } from "../../types.js";
16
16
  import { buildBackendAdapter } from "../anti_slop/backend-factory.js";
17
17
  import {
18
18
  registerLayerContextInjectHook,
@@ -31,9 +31,21 @@ export const DEFAULT_HARNESS_HOOK_CONFIG: HarnessHookConfig = {
31
31
  score_floor: { strict: 75, lenient: 90, release_blocking: false },
32
32
  };
33
33
 
34
+ export const DEFAULT_HARNESS_DOCS_CONFIG: HarnessDocsConfig = {
35
+ tier: "simple",
36
+ max_per_doc_loc: 150,
37
+ agent_context_loc: 30,
38
+ max_index_loc: 50,
39
+ max_units: 12,
40
+ max_concurrent_subagents: null,
41
+ drift_warning: { enabled: true },
42
+ regen_preview_threshold: 1,
43
+ };
44
+
34
45
  export const DEFAULT_HARNESS_CONFIG: HarnessConfig = {
35
46
  anti_slop: DEFAULT_HARNESS_HOOK_CONFIG,
36
47
  implement_in_session_threshold: 10,
48
+ docs: DEFAULT_HARNESS_DOCS_CONFIG,
37
49
  };
38
50
 
39
51
  export interface HarnessHookRegistration {
@@ -54,19 +66,28 @@ export interface RegisterHooksOptions {
54
66
  * unless a real resolver is wired).
55
67
  */
56
68
  resolveCandidateFile?: (event: unknown, ctx: unknown) => string | null;
69
+ /** CWD whose repo-local marker controls registration. Defaults to process.cwd(). */
70
+ cwd?: string;
57
71
  }
58
72
 
59
73
  // Re-export so existing call sites keep working without an import path change.
60
74
  export { buildBackendAdapter };
61
75
 
62
76
  /**
63
- * Register every harness hook. Idempotent at the dispose boundary: calling
64
- * `dispose()` twice is safe.
77
+ * Register every harness hook. Hooks subscribe unconditionally at bootstrap time; each
78
+ * hook checks the repo-local marker per event, so creating the marker after install
79
+ * activates already-registered handlers without an OMP restart, and removing the marker
80
+ * disables them. `dispose()` is idempotent.
81
+ *
82
+ * The `cwd` option is retained for tests that exercise the legacy marker check; it is
83
+ * unused by the new registration path because per-event handlers resolve cwd from the
84
+ * event payload.
65
85
  */
66
86
  export function registerHarnessHooks(
67
87
  platform: Platform,
68
88
  options: RegisterHooksOptions = {},
69
89
  ): HarnessHookRegistration {
90
+ void options.cwd; // reserved for future per-repo gating
70
91
  const backend = options.backend ?? "fallow";
71
92
  const hooks = options.hooks ?? DEFAULT_HARNESS_HOOK_CONFIG;
72
93
  const adapter = buildBackendAdapter(backend);
@@ -31,6 +31,7 @@ import {
31
31
  } from "./stages/design.js";
32
32
  import { HarnessPlanStage, type PlanStageInput } from "./stages/plan.js";
33
33
  import { HarnessImplementStage, type ImplementStageInput } from "./stages/implement.js";
34
+ import { HarnessDocsStage, type DocsStageInput } from "./stages/docs.js";
34
35
  import { HarnessValidateStage, type ValidateStageInput } from "./stages/validate.js";
35
36
  import { loadHarnessDesignSpecJson, loadHarnessDiscover } from "./storage.js";
36
37
  import { buildBackendAdapter } from "./anti_slop/backend-factory.js";
@@ -52,6 +53,7 @@ const STAGE_ORDER: readonly HarnessStage[] = [
52
53
  "design",
53
54
  "plan",
54
55
  "implement",
56
+ "docs",
55
57
  "validate",
56
58
  ];
57
59
 
@@ -60,6 +62,7 @@ const GATE_STAGES_DEFAULT: ReadonlySet<HarnessStage> = new Set([
60
62
  "discover",
61
63
  "design",
62
64
  "plan",
65
+ "docs",
63
66
  "validate",
64
67
  ]);
65
68
  const GATE_STAGES_MANUAL: ReadonlySet<HarnessStage> = new Set([
@@ -68,6 +71,7 @@ const GATE_STAGES_MANUAL: ReadonlySet<HarnessStage> = new Set([
68
71
  "design",
69
72
  "plan",
70
73
  "implement",
74
+ "docs",
71
75
  "validate",
72
76
  ]);
73
77
 
@@ -89,6 +93,8 @@ export interface BuildRunnerInput {
89
93
  planInput?: PlanStageInput;
90
94
  /** Required when running the implement stage. */
91
95
  implementInput?: ImplementStageInput;
96
+ /** Optional override for the docs stage (tier, max-units, test-only factories). */
97
+ docsInput?: DocsStageInput;
92
98
  /** Required when running the validate stage. */
93
99
  validateInput?: ValidateStageInput;
94
100
  }
@@ -111,6 +117,8 @@ export function buildHarnessRunner(stage: HarnessStage, input: BuildRunnerInput)
111
117
  throw new Error("buildHarnessRunner: implement stage requires implementInput");
112
118
  }
113
119
  return new HarnessImplementStage(input.implementInput);
120
+ case "docs":
121
+ return new HarnessDocsStage(input.docsInput ?? {});
114
122
  case "validate":
115
123
  if (!input.validateInput) {
116
124
  throw new Error("buildHarnessRunner: validate stage requires validateInput");
@@ -143,6 +151,12 @@ export interface PipelineDriverInput {
143
151
  * complete). Used by per-stage subcommands.
144
152
  */
145
153
  startStage?: HarnessStage;
154
+ /**
155
+ * Stages listed here bypass their `isComplete` short-circuit. Used by the harden
156
+ * path after mutating the persisted design spec to force a fresh re-render even when
157
+ * a prior successful run left the stage's completion artifact on disk.
158
+ */
159
+ forceStages?: ReadonlySet<HarnessStage>;
146
160
  /** Hard cap on stage iterations. */
147
161
  safetyLimit?: number;
148
162
  /** Optional callback for progress events (wire up a progress widget). */
@@ -215,6 +229,15 @@ function formatStageDetail(result: HarnessStageRunResult): string {
215
229
  const layers = typeof d.layerCount === "number" ? `${d.layerCount} layers` : "";
216
230
  return layers ? `${backend} · ${layers}` : `${backend}`;
217
231
  }
232
+ if (result.stage === "docs") {
233
+ const regen = Array.isArray(d.regenerated) ? (d.regenerated as string[]).length : 0;
234
+ const skip = Array.isArray(d.skipped) ? (d.skipped as string[]).length : 0;
235
+ const user = Array.isArray(d.userEdited) ? (d.userEdited as string[]).length : 0;
236
+ if (typeof d.tier === "string" && d.tier === "extensive") {
237
+ return `${regen} regen · ${skip} skip${user > 0 ? ` · ${user} user-edited` : ""}`;
238
+ }
239
+ if (typeof d.reason === "string") return d.reason;
240
+ }
218
241
  if (result.stage === "validate" && typeof d.passed === "boolean") {
219
242
  return d.passed ? "passed" : "issues found";
220
243
  }
@@ -252,14 +275,17 @@ export async function runHarnessPipelineUntilGate(
252
275
  const stageInputs = ensureStageInputs(input, stage);
253
276
  const runner = buildHarnessRunner(stage, stageInputs);
254
277
 
255
- const isComplete = await runner.isComplete({
256
- platform: input.platform,
257
- paths: input.paths,
258
- cwd: input.cwd,
259
- sessionId: input.sessionId,
260
- modelConfig: input.modelConfig,
261
- gateMode: input.gates,
262
- });
278
+ const forced = input.forceStages?.has(stage) ?? false;
279
+ const isComplete = forced
280
+ ? false
281
+ : await runner.isComplete({
282
+ platform: input.platform,
283
+ paths: input.paths,
284
+ cwd: input.cwd,
285
+ sessionId: input.sessionId,
286
+ modelConfig: input.modelConfig,
287
+ gateMode: input.gates,
288
+ });
263
289
 
264
290
  if (isComplete) {
265
291
  input.onProgress?.({ type: "stage-skipped", stage });
@@ -279,9 +305,9 @@ export async function runHarnessPipelineUntilGate(
279
305
 
280
306
  const result = await runner.run(ctx);
281
307
 
282
- // In auto mode, awaiting-user is equivalent to completed — normalize
283
- // both the trace entry and any outcome derived from it so the UI never
284
- // shows a confusing mix of checkmarks and "awaiting user".
308
+ // In auto mode, awaiting-user from authoring stages (design, etc.) is equivalent to
309
+ // completed: the artifact is on disk and the next stage can consume it. Gates honor
310
+ // awaiting-user as a real stop signal.
285
311
  const isGate = gateStages.has(stage);
286
312
  const normalizedStatus: HarnessStageRunResult["status"] =
287
313
  result.status === "awaiting-user" && !isGate
@@ -307,8 +333,6 @@ export async function runHarnessPipelineUntilGate(
307
333
  };
308
334
  }
309
335
 
310
- // In auto mode, awaiting-user is equivalent to completed — the pipeline
311
- // continues without stopping. Only surface the distinction when gated.
312
336
  if (normalizedStatus === "awaiting-user" && isGate) {
313
337
  input.onProgress?.({ type: "awaiting-user", stage, detail: awaitUserDetail(result) });
314
338
  } else {