supipowers 2.0.2 → 2.1.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 (76) 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 +5 -133
  5. package/src/config/defaults.ts +5 -5
  6. package/src/config/loader.ts +1 -0
  7. package/src/config/schema.ts +2 -6
  8. package/src/context-mode/knowledge/store.ts +381 -43
  9. package/src/context-mode/tools.ts +41 -3
  10. package/src/deps/registry.ts +1 -12
  11. package/src/fix-pr/assessment.ts +1 -0
  12. package/src/fix-pr/prompt-builder.ts +1 -0
  13. package/src/git/commit.ts +76 -18
  14. package/src/harness/command.ts +103 -6
  15. package/src/harness/default-agents/docs.md +39 -0
  16. package/src/harness/docs/config.ts +29 -0
  17. package/src/harness/docs/glob-match.ts +27 -0
  18. package/src/harness/docs/index-renderer.ts +82 -0
  19. package/src/harness/docs/provenance.ts +125 -0
  20. package/src/harness/docs/regen-decision.ts +167 -0
  21. package/src/harness/docs/representative-files.ts +175 -0
  22. package/src/harness/docs/source-hash.ts +106 -0
  23. package/src/harness/docs/validator.ts +233 -0
  24. package/src/harness/hooks/layer-context-inject.ts +35 -1
  25. package/src/harness/hooks/register.ts +24 -3
  26. package/src/harness/pipeline.ts +20 -5
  27. package/src/harness/pr-comment/baseline.ts +105 -0
  28. package/src/harness/pr-comment/ci-env.ts +120 -0
  29. package/src/harness/pr-comment/gh-poster.ts +227 -0
  30. package/src/harness/pr-comment/handler.ts +198 -0
  31. package/src/harness/pr-comment/render.ts +297 -0
  32. package/src/harness/pr-comment/status.ts +95 -0
  33. package/src/harness/pr-comment/types.ts +73 -0
  34. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  35. package/src/harness/project-paths.ts +95 -0
  36. package/src/harness/stages/design.ts +1 -0
  37. package/src/harness/stages/discover.ts +1 -13
  38. package/src/harness/stages/docs.ts +708 -0
  39. package/src/harness/stages/implement-apply.ts +877 -0
  40. package/src/harness/stages/implement.ts +64 -51
  41. package/src/harness/stages/plan.ts +25 -16
  42. package/src/harness/stages/validate.ts +370 -0
  43. package/src/harness/storage.ts +142 -0
  44. package/src/harness/tools.ts +130 -0
  45. package/src/mempalace/bridge.ts +207 -41
  46. package/src/mempalace/config.ts +10 -4
  47. package/src/mempalace/format.ts +122 -6
  48. package/src/mempalace/hooks.ts +204 -56
  49. package/src/mempalace/installer-helper.ts +18 -4
  50. package/src/mempalace/python/mempalace_bridge.py +128 -3
  51. package/src/mempalace/runtime.ts +53 -16
  52. package/src/mempalace/schema.ts +151 -30
  53. package/src/mempalace/session-summary.ts +5 -0
  54. package/src/mempalace/tool.ts +17 -4
  55. package/src/mempalace/upstream-limits.ts +69 -0
  56. package/src/planning/approval-flow.ts +25 -2
  57. package/src/planning/planning-ask-tool.ts +34 -4
  58. package/src/planning/system-prompt.ts +1 -1
  59. package/src/tool-catalog/active-tool-controller.ts +0 -22
  60. package/src/tool-catalog/active-tool-planner.ts +0 -26
  61. package/src/tool-catalog/tool-groups.ts +1 -9
  62. package/src/types.ts +87 -8
  63. package/src/ui-design/session.ts +114 -8
  64. package/src/utils/executable.ts +10 -1
  65. package/src/workspace/state-paths.ts +1 -1
  66. package/src/commands/mcp.ts +0 -814
  67. package/src/mcp/activation.ts +0 -77
  68. package/src/mcp/config.ts +0 -223
  69. package/src/mcp/docs.ts +0 -154
  70. package/src/mcp/gateway.ts +0 -103
  71. package/src/mcp/lifecycle.ts +0 -79
  72. package/src/mcp/manager-tool.ts +0 -104
  73. package/src/mcp/mcpc.ts +0 -113
  74. package/src/mcp/registry.ts +0 -98
  75. package/src/mcp/triggers.ts +0 -62
  76. package/src/mcp/types.ts +0 -95
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * IMPLEMENT stage runner.
3
3
  *
4
- * Counts the tasks in the approved plan and decides whether to run them in-session (steer
5
- * loop, mirrors `/supi:plan`) or to hand off to `/supi:ultraplan` batch / worktree
6
- * runtime. The threshold is configurable via `harness.implement_in_session_threshold`
7
- * (default 10).
4
+ * Programmatic apply of every Tier 1 artifact defined by the design spec. Mirrors the
5
+ * `/supi:checks` pattern: the stage runs deterministically inside the harness command,
6
+ * with no handoff to the user's active agent. After this stage completes, the pipeline
7
+ * naturally continues to docs (per-layer subagent dispatch) and validate (mechanical
8
+ * checks) inside the same `/supi:harness` invocation.
8
9
  *
9
- * The actual execution loop lives in the command handler the stage runner records the
10
- * routing decision and validates pre-conditions (clean git tree, plan readable, etc.).
10
+ * `decideImplementRouting` is retained as an exported helper for tests + future tooling;
11
+ * the in-session-vs-batch heuristic is no longer used by the stage runner itself because
12
+ * the apply path no longer needs the active agent.
11
13
  */
12
14
 
13
15
  import * as fs from "node:fs";
14
- import * as path from "node:path";
15
16
 
16
17
  import type { Plan } from "../../types.js";
17
- import { parsePlan } from "../../storage/plans.js";
18
18
  import {
19
19
  type HarnessStageRunResult,
20
20
  type HarnessStageRunner,
@@ -23,7 +23,10 @@ import {
23
23
  } from "../stage-runner.js";
24
24
  import {
25
25
  appendImplementLog,
26
+ hasSuccessfulImplementApply,
27
+ loadHarnessDesignSpecJson,
26
28
  } from "../storage.js";
29
+ import { applyHarnessPlan } from "./implement-apply.js";
27
30
 
28
31
  const DEFAULT_IN_SESSION_THRESHOLD = 10;
29
32
 
@@ -81,20 +84,15 @@ export class HarnessImplementStage implements HarnessStageRunner {
81
84
  return fs.existsSync(this.input.planPath);
82
85
  }
83
86
 
87
+ /**
88
+ * Implement is driven by the programmatic apply, so `isComplete` returns true once a
89
+ * successful apply has been recorded in `implement-log.jsonl`. This keeps reruns
90
+ * idempotent without re-walking every applier (the appliers themselves are also
91
+ * idempotent — this is a fast-skip for the common case). A subsequent failed apply in
92
+ * the same session resets the result via the scan-from-end logic in storage.
93
+ */
84
94
  async isComplete(ctx: HarnessStageRunnerContext): Promise<boolean> {
85
- // Implement is complete when the post-implement self-check has been recorded in
86
- // implement-log.jsonl with `kind: "self-check-passed"`. The command handler appends
87
- // that record after running typecheck/test/scan.
88
- const logPath = path.join(
89
- path.dirname(this.input.planPath),
90
- "..",
91
- "harness",
92
- "sessions",
93
- ctx.sessionId,
94
- "implement-log.jsonl",
95
- );
96
- void logPath; // tracked but the stage runner does not introspect it directly.
97
- return false;
95
+ return hasSuccessfulImplementApply(ctx.paths, ctx.cwd, ctx.sessionId);
98
96
  }
99
97
 
100
98
  async run(ctx: HarnessStageRunnerContext): Promise<HarnessStageRunResult> {
@@ -111,51 +109,66 @@ export class HarnessImplementStage implements HarnessStageRunner {
111
109
  blocker: { code: "implement-preflight-failed", message: errors.join("; ") },
112
110
  };
113
111
  }
114
- let raw: string;
115
- try {
116
- raw = fs.readFileSync(this.input.planPath, "utf8");
117
- } catch (error) {
112
+ const designResult = loadHarnessDesignSpecJson(ctx.paths, ctx.cwd, ctx.sessionId);
113
+ if (!designResult.ok) {
118
114
  return {
119
- status: "failed",
120
- stage: this.stage,
121
- artifactPaths: [],
122
- error: `unable to read plan: ${error instanceof Error ? error.message : String(error)}`,
123
- };
124
- }
125
- let plan: Plan;
126
- try {
127
- plan = parsePlan(raw, this.input.planPath);
128
- } catch (error) {
129
- return {
130
- status: "failed",
115
+ status: "blocked",
131
116
  stage: this.stage,
132
117
  artifactPaths: [],
133
- error: `unable to parse plan: ${error instanceof Error ? error.message : String(error)}`,
118
+ blocker: {
119
+ code: "design-spec-missing",
120
+ message: "implement stage requires <session>/design-spec.json. Run /supi:harness design first.",
121
+ },
134
122
  };
135
123
  }
136
124
 
137
- const decision = decideImplementRouting({
138
- plan,
139
- threshold: this.input.threshold ?? DEFAULT_IN_SESSION_THRESHOLD,
125
+ const recordedAt = nowIso(ctx);
126
+ const outcome = await applyHarnessPlan({
127
+ platform: ctx.platform,
128
+ paths: ctx.paths,
129
+ cwd: ctx.cwd,
130
+ spec: designResult.value,
131
+ apply: true,
140
132
  });
141
133
 
142
134
  appendImplementLog(ctx.paths, ctx.cwd, ctx.sessionId, {
143
- recordedAt: nowIso(ctx),
144
- kind: "routing-decision",
145
- routing: decision.routing,
146
- taskCount: decision.taskCount,
147
- reason: decision.reason,
135
+ recordedAt,
136
+ kind: "applied",
148
137
  planPath: this.input.planPath,
138
+ applied: outcome.applied,
139
+ warnings: outcome.warnings,
140
+ errors: outcome.errors,
149
141
  });
150
142
 
143
+ const artifactPaths = outcome.applied
144
+ .filter((entry) => entry.action === "wrote" || entry.action === "patched")
145
+ .map((entry) => entry.path);
146
+
147
+ if (outcome.errors.length > 0) {
148
+ const summary = outcome.errors
149
+ .map((err) => `${err.step}: ${err.message}`)
150
+ .join("; ");
151
+ return {
152
+ status: "blocked",
153
+ stage: this.stage,
154
+ artifactPaths,
155
+ blocker: { code: "implement-apply-failed", message: summary },
156
+ details: {
157
+ applied: outcome.applied.length,
158
+ errors: outcome.errors.length,
159
+ warnings: outcome.warnings.length,
160
+ },
161
+ };
162
+ }
163
+
151
164
  return {
152
- status: "awaiting-user",
165
+ status: "completed",
153
166
  stage: this.stage,
154
- artifactPaths: ["implement-log.jsonl"],
167
+ artifactPaths,
155
168
  details: {
156
- routing: decision.routing,
157
- taskCount: decision.taskCount,
158
- reason: decision.reason,
169
+ applied: outcome.applied.length,
170
+ warnings: outcome.warnings.length,
171
+ wrote: artifactPaths.length,
159
172
  },
160
173
  };
161
174
  }
@@ -24,6 +24,7 @@ import {
24
24
  } from "../stage-runner.js";
25
25
  import {
26
26
  loadHarnessDesignSpecJson,
27
+ loadHarnessSession,
27
28
  } from "../storage.js";
28
29
 
29
30
  export interface HarnessPlanTask {
@@ -36,22 +37,14 @@ export interface HarnessPlanTask {
36
37
  }
37
38
 
38
39
  /**
39
- * Build the canonical task list from a design spec. Always emits the "harden" tasks
40
- * (AGENTS.md, docs/architecture.md, docs/golden-principles.md, lint/structural/eval
41
- * configs); appends the conditional anti-slop tasks per Design's backend choice.
40
+ * Build the canonical task list from a design spec. Always emits the source
41
+ * harness artifacts first (docs, tooling, CI, queue, review wiring), then ends
42
+ * with AGENTS.md so the agent-facing summary can reference completed artifacts.
42
43
  */
43
44
  export function buildHarnessPlanTasks(spec: HarnessDesignSpec): HarnessPlanTask[] {
44
45
  const tasks: HarnessPlanTask[] = [];
45
46
  let id = 1;
46
47
 
47
- tasks.push({
48
- id: id++,
49
- name: "Generate AGENTS.md",
50
- description: "Write a ≤120-line AGENTS.md at the repo root summarizing the harness contract for any agent.",
51
- files: ["AGENTS.md"],
52
- criteria: "AGENTS.md exists, references docs/architecture.md and docs/golden-principles.md, and ends with a 'When in doubt' section.",
53
- complexity: "small",
54
- });
55
48
 
56
49
  tasks.push({
57
50
  id: id++,
@@ -159,10 +152,10 @@ export function buildHarnessPlanTasks(spec: HarnessDesignSpec): HarnessPlanTask[
159
152
 
160
153
  tasks.push({
161
154
  id: id++,
162
- name: "Register anti-slop hooks",
163
- description: "Ensure src/harness/hooks/register.ts wires pre-edit dupe probe, post-session sweep, and layer-context-inject only when the harness marker exists.",
164
- files: ["src/harness/hooks/register.ts"],
165
- criteria: "Hooks are registered idempotently and gated by the marker file.",
155
+ name: "Enable repo-local anti-slop hooks",
156
+ description: "Create the repo-local harness marker. The installed Supipowers extension already registers the runtime hooks; the marker gates them for this repository.",
157
+ files: [".omp/supipowers/harness/marker.json"],
158
+ criteria: `Marker JSON exists with backend ${spec.antiSlop.backend}; no supipowers extension source files are modified.`,
166
159
  complexity: "small",
167
160
  });
168
161
 
@@ -206,9 +199,20 @@ export function buildHarnessPlanTasks(spec: HarnessDesignSpec): HarnessPlanTask[
206
199
  });
207
200
  }
208
201
 
202
+ tasks.push({
203
+ id: id++,
204
+ name: "Generate AGENTS.md",
205
+ description: "Write a ≤120-line AGENTS.md at the repo root summarizing the harness contract for any agent.",
206
+ files: ["AGENTS.md"],
207
+ criteria: "AGENTS.md exists, references docs/architecture.md and docs/golden-principles.md, and ends with a 'When in doubt' section.",
208
+ complexity: "small",
209
+ });
210
+
209
211
  return tasks;
210
212
  }
211
213
 
214
+
215
+
212
216
  /** Render the plan markdown that lands in the canonical plans directory. */
213
217
  export function renderHarnessPlanMarkdown(input: {
214
218
  spec: HarnessDesignSpec;
@@ -321,7 +325,12 @@ export function emitHarnessPlanFromSpec(input: {
321
325
  const recordedAt = input.recordedAt ?? new Date().toISOString();
322
326
  const planName = input.planName ?? `harness-${input.spec.sessionId}`;
323
327
  const tasks = buildHarnessPlanTasks(input.spec);
324
- const planMarkdown = renderHarnessPlanMarkdown({ spec: input.spec, tasks, recordedAt, planName });
328
+ const planMarkdown = renderHarnessPlanMarkdown({
329
+ spec: input.spec,
330
+ tasks,
331
+ recordedAt,
332
+ planName,
333
+ });
325
334
  const filename = `${planName}.md`;
326
335
  const planPath = savePlan(input.ctx.paths, input.ctx.cwd, filename, planMarkdown);
327
336
  return { planPath, planMarkdown, tasks };
@@ -54,6 +54,12 @@ import {
54
54
  getHarnessArchitectureDocPath,
55
55
  getHarnessGoldenPrinciplesPath,
56
56
  } from "../project-paths.js";
57
+ import { resolveDocsConfig } from "../docs/config.js";
58
+ import { matchesLayerGlob } from "../docs/glob-match.js";
59
+ import { selectRepresentativeFiles } from "../docs/representative-files.js";
60
+ import { computeLayerSourceHash, sha256 as sha256Hash } from "../docs/source-hash.js";
61
+ import { validateLayerDocMarkdown } from "../docs/validator.js";
62
+ import { computeLayerAddendum } from "../hooks/layer-context-inject.js";
57
63
 
58
64
  export interface ValidateStageInput {
59
65
  /** Selected backend (from the design spec). */
@@ -116,6 +122,13 @@ const CHECK_CONTRACTS: Readonly<Record<string, CheckContract>> = {
116
122
  artifact: "validate-report.json synthetic-edit-test entry",
117
123
  failSafe: "Hook failures are emitted as error findings and block validation.",
118
124
  },
125
+ "docs-validation": {
126
+ invariant: "Per-layer docs must remain valid, complete, indexed, and integrated with the layer-context-inject hook.",
127
+ proves: "Every docs/layers/*.md passes the validator, docs/README.md ↔ filesystem are consistent, the hook prefers the per-layer doc, and drift between layer inputs and recorded sourceHash is surfaced.",
128
+ doesNotProve: "The doc content is high quality, or that the layer rules themselves match the current codebase.",
129
+ artifact: "validate-report.json docs-validation entry plus docs/layers/*.md + docs/README.md",
130
+ failSafe: "Missing docs/layers/ short-circuits the check as a no-op; structural failures emit warnings and surface in the queue.",
131
+ },
119
132
  "ci-local-wiring": {
120
133
  invariant: "Every harness validation gate must have one local command and CI must invoke that command instead of relying on human memory.",
121
134
  proves: "The configured local command exists and the configured CI workflow calls it on the selected PR trigger.",
@@ -243,6 +256,29 @@ function scriptNameFromLocalCommand(command: string): string | null {
243
256
  return null;
244
257
  }
245
258
 
259
+ /**
260
+ * Conservative check that a GitHub Actions workflow grants the write scope needed for
261
+ * the harness PR comment. Only inspects the `permissions:` block — if a user has wired
262
+ * a deploy token or `secrets.GITHUB_TOKEN` with a custom scope, this returns false and
263
+ * the warning is a no-op false positive. That's deliberate: a false-positive warning is
264
+ * cheaper than a silent 403 the first time CI tries to post.
265
+ *
266
+ * Caveats this regex deliberately does not handle:
267
+ * - Job-scoped `permissions:` blocks that grant `pull-requests: write` to a job other
268
+ * than the one running `/supi:harness pr-comment`. Detecting that requires real YAML
269
+ * parsing; a false-positive warning is again cheaper than guessing wrong.
270
+ */
271
+ function workflowGrantsPrCommentPermission(workflow: string): boolean {
272
+ // Strip whole-line YAML comments before matching so a commented-out
273
+ // `# pull-requests: write` does not falsely register as a grant.
274
+ const stripped = workflow.replace(/^[ \t]*#.*$/gm, "");
275
+ // Match either inline mapping `permissions: { pull-requests: write }`, the block form
276
+ // with `pull-requests: write` on its own line, or the broad `permissions: write-all`.
277
+ if (/permissions:\s*write-all\b/.test(stripped)) return true;
278
+ if (/\bpull-requests:\s*write\b/.test(stripped)) return true;
279
+ return false;
280
+ }
281
+
246
282
  async function checkCiLocalWiring(
247
283
  paths: HarnessStageRunnerContext["paths"],
248
284
  cwd: string,
@@ -347,6 +383,18 @@ async function checkCiLocalWiring(
347
383
  }
348
384
  }
349
385
  }
386
+ // Informational: when prComment is enabled but the workflow lacks
387
+ // `pull-requests: write`, the `gh api` upsert will fail with 403. Surface a warning
388
+ // so the user notices before the first failed PR run.
389
+ if (spec.value.ci.prComment?.enabled && !workflowGrantsPrCommentPermission(workflow)) {
390
+ findings.push({
391
+ severity: "warning",
392
+ file: spec.value.ci.workflowPath,
393
+ message: "CI workflow does not grant `pull-requests: write` but prComment.enabled is true.",
394
+ remediation: "Add `permissions: { pull-requests: write }` to the workflow so /supi:harness pr-comment can post.",
395
+ source: "ci-local-wiring",
396
+ });
397
+ }
350
398
  } catch (error) {
351
399
  findings.push({
352
400
  severity: "error",
@@ -539,6 +587,327 @@ function loadLayerRules(cwd: string): HarnessLayerRule[] {
539
587
  }
540
588
  }
541
589
 
590
+ /**
591
+ * Validate the per-layer docs tree: doc validator on each file, index ↔ filesystem
592
+ * consistency, hook integration smoke test, and sourceHash drift. Soft-failure: missing
593
+ * `docs/layers/` short-circuits to a no-op pass — the docs stage is opt-in.
594
+ */
595
+ async function checkDocsValidation(
596
+ ctx: HarnessStageRunnerContext,
597
+ layerRules: readonly HarnessLayerRule[],
598
+ ): Promise<CheckResult> {
599
+ const startedAt = Date.now();
600
+ const findings: HarnessValidateFinding[] = [];
601
+
602
+ const layersDir = path.join(ctx.cwd, "docs", "layers");
603
+ if (!fs.existsSync(layersDir)) {
604
+ return {
605
+ name: "docs-validation",
606
+ passed: true,
607
+ summary: "Per-layer docs disabled (no docs/layers/).",
608
+ findings,
609
+ durationMs: Date.now() - startedAt,
610
+ };
611
+ }
612
+ const config = resolveDocsConfig(ctx.paths, ctx.cwd);
613
+
614
+ // ── Re-validate every layer doc ──────────────────────────────────────
615
+ let docFiles: string[] = [];
616
+ try {
617
+ docFiles = fs.readdirSync(layersDir).filter((f) => f.endsWith(".md")).sort();
618
+ } catch (error) {
619
+ findings.push({
620
+ severity: "warning",
621
+ file: "docs/layers/",
622
+ message: `unable to enumerate docs/layers/: ${error instanceof Error ? error.message : String(error)}`,
623
+ remediation: "Inspect the docs/layers/ directory permissions and re-run validate.",
624
+ source: "docs-validation",
625
+ });
626
+ }
627
+
628
+ for (const fileName of docFiles) {
629
+ const layerId = fileName.replace(/\.md$/, "");
630
+ const layerPath = `docs/layers/${fileName}`;
631
+ const docPath = path.join(layersDir, fileName);
632
+ let contents: string;
633
+ try {
634
+ contents = fs.readFileSync(docPath, "utf8");
635
+ } catch (error) {
636
+ findings.push({
637
+ severity: "warning",
638
+ file: layerPath,
639
+ message: `unable to read doc: ${error instanceof Error ? error.message : String(error)}`,
640
+ remediation: "Re-run `/supi:harness docs` after fixing filesystem permissions.",
641
+ source: "docs-validation",
642
+ });
643
+ continue;
644
+ }
645
+ const recordedHash = readFrontmatterSourceHashForValidate(contents);
646
+ const validation = validateLayerDocMarkdown(contents, {
647
+ expectedLayerId: layerId,
648
+ expectedSourceHash: recordedHash ?? "",
649
+ maxDocLoc: config.max_per_doc_loc,
650
+ maxAgentContextLoc: config.agent_context_loc,
651
+ });
652
+ if (!validation.ok) {
653
+ for (const err of validation.errors) {
654
+ findings.push({
655
+ severity: "warning",
656
+ file: layerPath,
657
+ message: err,
658
+ remediation: "Re-run `/supi:harness docs` to regenerate the per-layer doc.",
659
+ source: "docs-validation",
660
+ });
661
+ }
662
+ }
663
+ }
664
+
665
+ // ── Index ↔ filesystem consistency ────────────────────────────────────
666
+ const indexPath = path.join(ctx.cwd, "docs", "README.md");
667
+ if (!fs.existsSync(indexPath)) {
668
+ findings.push({
669
+ severity: "warning",
670
+ file: "docs/README.md",
671
+ message: "docs/layers/ exists but docs/README.md is missing.",
672
+ remediation: "Re-run `/supi:harness docs` to regenerate the index.",
673
+ source: "docs-validation",
674
+ });
675
+ } else {
676
+ let indexContents: string;
677
+ try {
678
+ indexContents = fs.readFileSync(indexPath, "utf8");
679
+ } catch (error) {
680
+ findings.push({
681
+ severity: "warning",
682
+ file: "docs/README.md",
683
+ message: `unable to read docs/README.md: ${error instanceof Error ? error.message : String(error)}`,
684
+ remediation: "Inspect the file permissions and re-run validate.",
685
+ source: "docs-validation",
686
+ });
687
+ indexContents = "";
688
+ }
689
+ const referenced = new Set<string>();
690
+ for (const match of indexContents.matchAll(/docs\/layers\/([A-Za-z0-9._-]+)\.md/g)) {
691
+ referenced.add(match[1]);
692
+ }
693
+ const onDisk = new Set(docFiles.map((f) => f.replace(/\.md$/, "")));
694
+ for (const layerId of referenced) {
695
+ if (!onDisk.has(layerId)) {
696
+ findings.push({
697
+ severity: "warning",
698
+ file: "docs/README.md",
699
+ message: `index references docs/layers/${layerId}.md but the file is missing.`,
700
+ remediation: "Run `/supi:harness docs` or delete the stale row from docs/README.md.",
701
+ source: "docs-validation",
702
+ });
703
+ }
704
+ }
705
+ for (const layerId of onDisk) {
706
+ if (!referenced.has(layerId)) {
707
+ findings.push({
708
+ severity: "warning",
709
+ file: "docs/README.md",
710
+ message: `docs/layers/${layerId}.md exists but is not listed in the index.`,
711
+ remediation: "Run `/supi:harness docs` to refresh the index.",
712
+ source: "docs-validation",
713
+ });
714
+ }
715
+ }
716
+ }
717
+
718
+ // ── Hook integration smoke test ──────────────────────────────────────
719
+ for (const rule of layerRules) {
720
+ const probeFile = pickSampleFileForLayer(ctx.cwd, rule);
721
+ if (!probeFile) continue;
722
+ const result = computeLayerAddendum({
723
+ cwd: ctx.cwd,
724
+ candidateFile: probeFile,
725
+ config: { enabled: true, addendum_max_chars: 800 },
726
+ });
727
+ const docExists = fs.existsSync(path.join(ctx.cwd, "docs", "layers", `${rule.layer}.md`));
728
+ if (docExists && result.reason !== "matched (per-layer doc)") {
729
+ findings.push({
730
+ severity: "warning",
731
+ file: `docs/layers/${rule.layer}.md`,
732
+ message: `layer-context-inject hook returned "${result.reason}" despite the per-layer doc existing.`,
733
+ remediation: "Verify the per-layer doc has a non-empty ## Agent context section.",
734
+ source: "docs-validation",
735
+ });
736
+ }
737
+ }
738
+
739
+ // ── Source-hash drift ────────────────────────────────────────────────
740
+ if (config.drift_warning.enabled) {
741
+ const promptVersion = readDocsPromptVersion();
742
+ const allFiles = collectAllRepoFilesForValidate(ctx.cwd);
743
+ const goldenPrinciples = readGoldenPrinciplesForValidate(ctx.cwd);
744
+ for (const rule of layerRules) {
745
+ const docPath = path.join(layersDir, `${rule.layer}.md`);
746
+ if (!fs.existsSync(docPath)) continue;
747
+ let contents: string;
748
+ try {
749
+ contents = fs.readFileSync(docPath, "utf8");
750
+ } catch (error) {
751
+ findings.push({
752
+ severity: "warning",
753
+ file: `docs/layers/${rule.layer}.md`,
754
+ message: `unable to read doc for drift check: ${error instanceof Error ? error.message : String(error)}`,
755
+ remediation: "Inspect the file permissions and re-run validate.",
756
+ source: "docs-validation",
757
+ });
758
+ continue;
759
+ }
760
+ const recordedHash = readFrontmatterSourceHashForValidate(contents);
761
+ if (!recordedHash) continue;
762
+
763
+ const globPaths = allFiles
764
+ .filter((file) => rule.globs.some((g) => matchesLayerGlob(file, g)))
765
+ .sort();
766
+ const repSelection = selectRepresentativeFiles({ cwd: ctx.cwd, files: globPaths });
767
+ const peerLayers = layerRules
768
+ .filter((peer) => peer.layer !== rule.layer)
769
+ .map((peer) => ({ id: peer.layer, description: peer.description ?? "" }));
770
+ const currentHash = computeLayerSourceHash({
771
+ layerRule: rule,
772
+ globPaths,
773
+ representativeFiles: repSelection.entries.map((e) => ({
774
+ path: e.path,
775
+ contentHash: e.contentHash,
776
+ })),
777
+ goldenPrinciples,
778
+ peerLayers,
779
+ promptVersion,
780
+ });
781
+ if (currentHash !== recordedHash) {
782
+ findings.push({
783
+ severity: "warning",
784
+ file: `docs/layers/${rule.layer}.md`,
785
+ message: `sourceHash drift: layer inputs changed since the doc was generated.`,
786
+ remediation: "Run `/supi:harness docs` to regenerate the affected layer doc.",
787
+ source: "docs-validation",
788
+ });
789
+ }
790
+ }
791
+ }
792
+
793
+ return {
794
+ name: "docs-validation",
795
+ // Findings are advisory only — they never block the stage; the report records them.
796
+ passed: true,
797
+ summary: findings.length === 0
798
+ ? "Per-layer docs validated."
799
+ : `${findings.length} per-layer docs finding(s).`,
800
+ findings,
801
+ durationMs: Date.now() - startedAt,
802
+ };
803
+ }
804
+
805
+ function readFrontmatterSourceHashForValidate(markdown: string): string | null {
806
+ let body = markdown;
807
+ if (body.startsWith("<!--")) {
808
+ const newline = body.indexOf("\n");
809
+ if (newline > 0) body = body.slice(newline + 1);
810
+ }
811
+ if (!body.startsWith("---")) return null;
812
+ const firstNewline = body.indexOf("\n");
813
+ if (firstNewline < 0) return null;
814
+ const closeIdx = body.indexOf("\n---", firstNewline);
815
+ if (closeIdx < 0) return null;
816
+ const inner = body.slice(firstNewline + 1, closeIdx);
817
+ for (const line of inner.split("\n")) {
818
+ const match = line.match(/^sourceHash\s*:\s*(.+)\s*$/);
819
+ if (match) return match[1].trim();
820
+ }
821
+ return null;
822
+ }
823
+
824
+ function pickSampleFileForLayer(cwd: string, rule: HarnessLayerRule): string | null {
825
+ // Walk the tree until we find one file matching any layer glob; this is enough to
826
+ // exercise the hook integration check without enumerating every file twice.
827
+ const allFiles = collectAllRepoFilesForValidate(cwd);
828
+ // dynamic import keeps the top-level imports list small
829
+ const skipDirs = ["node_modules", ".git", "dist", "build", ".omp", ".cache", ".next"];
830
+ for (const file of allFiles) {
831
+ if (skipDirs.some((d) => file.startsWith(`${d}/`))) continue;
832
+ for (const glob of rule.globs) {
833
+ if (matchesLayerGlobForValidate(file, glob)) return file;
834
+ }
835
+ }
836
+ return null;
837
+ }
838
+
839
+ function matchesLayerGlobForValidate(filePath: string, glob: string): boolean {
840
+ const normalizedFile = filePath.replace(/\\/g, "/");
841
+ const normalizedGlob = glob.replace(/\\/g, "/");
842
+ const regexSrc = normalizedGlob
843
+ .split(/(\*\*|\*)/g)
844
+ .map((segment) => {
845
+ if (segment === "**") return ".*";
846
+ if (segment === "*") return "[^/]*";
847
+ return segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
848
+ })
849
+ .join("");
850
+ return new RegExp(`^${regexSrc}$`).test(normalizedFile);
851
+ }
852
+
853
+ function collectAllRepoFilesForValidate(cwd: string): string[] {
854
+ const out: string[] = [];
855
+ const skip = new Set<string>([
856
+ "node_modules",
857
+ ".git",
858
+ "dist",
859
+ "build",
860
+ ".omp",
861
+ "coverage",
862
+ ".cache",
863
+ ".next",
864
+ ]);
865
+ function walk(absolute: string, relative: string): void {
866
+ let entries: fs.Dirent[];
867
+ try {
868
+ entries = fs.readdirSync(absolute, { withFileTypes: true });
869
+ } catch {
870
+ return;
871
+ }
872
+ for (const entry of entries) {
873
+ if (entry.isDirectory()) {
874
+ if (skip.has(entry.name)) continue;
875
+ walk(path.join(absolute, entry.name), path.posix.join(relative, entry.name));
876
+ } else if (entry.isFile()) {
877
+ out.push(relative === "" ? entry.name : path.posix.join(relative, entry.name));
878
+ }
879
+ }
880
+ }
881
+ walk(cwd, "");
882
+ return out;
883
+ }
884
+
885
+ function readGoldenPrinciplesForValidate(cwd: string): string[] {
886
+ const principlesPath = path.join(cwd, "docs", "golden-principles.md");
887
+ if (!fs.existsSync(principlesPath)) return [];
888
+ try {
889
+ const md = fs.readFileSync(principlesPath, "utf8");
890
+ return md
891
+ .split("\n")
892
+ .map((line) => line.trim())
893
+ .filter((line) => /^\d+\.\s+/.test(line))
894
+ .map((line) => line.replace(/^\d+\.\s+/, ""));
895
+ } catch {
896
+ return [];
897
+ }
898
+ }
899
+
900
+ function readDocsPromptVersion(): string {
901
+ try {
902
+ const docsPromptUrl = new URL("../default-agents/docs.md", import.meta.url);
903
+ const filePath = path.normalize(decodeURI(docsPromptUrl.pathname));
904
+ const contents = fs.readFileSync(filePath, "utf8");
905
+ return sha256Hash(contents);
906
+ } catch {
907
+ return sha256Hash("harness-docs-prompt-fallback");
908
+ }
909
+ }
910
+
542
911
  /**
543
912
  * Run every sub-check and assemble the validate report. Pure-ish: side effects are limited
544
913
  * to the optional anti-slop scan (which itself is fenced behind the adapter's
@@ -567,6 +936,7 @@ export async function runValidate(
567
936
 
568
937
  const synthetic = checkSyntheticEdit(input, layerRules);
569
938
  checks.push(synthetic);
939
+ checks.push(await checkDocsValidation(ctx, layerRules));
570
940
 
571
941
  // Persist scan findings to the queue so future runs see them.
572
942
  for (const finding of slopFindings) {