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
@@ -19,6 +19,8 @@
19
19
  import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
21
 
22
+ import { parse as parseYaml } from "yaml";
23
+
22
24
  import type { Platform } from "../../platform/types.js";
23
25
  import type {
24
26
  HarnessAntiSlopBackend,
@@ -53,7 +55,14 @@ import {
53
55
  getHarnessAgentsMdPath,
54
56
  getHarnessArchitectureDocPath,
55
57
  getHarnessGoldenPrinciplesPath,
58
+ getHarnessSessionDir,
56
59
  } from "../project-paths.js";
60
+ import { resolveDocsConfig } from "../docs/config.js";
61
+ import { matchesLayerGlob } from "../docs/glob-match.js";
62
+ import { selectRepresentativeFiles } from "../docs/representative-files.js";
63
+ import { computeLayerSourceHash, sha256 as sha256Hash } from "../docs/source-hash.js";
64
+ import { validateLayerDocMarkdown } from "../docs/validator.js";
65
+ import { computeLayerAddendum } from "../hooks/layer-context-inject.js";
57
66
 
58
67
  export interface ValidateStageInput {
59
68
  /** Selected backend (from the design spec). */
@@ -116,6 +125,13 @@ const CHECK_CONTRACTS: Readonly<Record<string, CheckContract>> = {
116
125
  artifact: "validate-report.json synthetic-edit-test entry",
117
126
  failSafe: "Hook failures are emitted as error findings and block validation.",
118
127
  },
128
+ "docs-validation": {
129
+ invariant: "Per-layer docs must remain valid, complete, indexed, and integrated with the layer-context-inject hook.",
130
+ 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.",
131
+ doesNotProve: "The doc content is high quality, or that the layer rules themselves match the current codebase.",
132
+ artifact: "validate-report.json docs-validation entry plus docs/layers/*.md + docs/README.md",
133
+ failSafe: "Missing docs/layers/ short-circuits the check as a no-op; structural failures emit warnings and surface in the queue.",
134
+ },
119
135
  "ci-local-wiring": {
120
136
  invariant: "Every harness validation gate must have one local command and CI must invoke that command instead of relying on human memory.",
121
137
  proves: "The configured local command exists and the configured CI workflow calls it on the selected PR trigger.",
@@ -243,6 +259,86 @@ function scriptNameFromLocalCommand(command: string): string | null {
243
259
  return null;
244
260
  }
245
261
 
262
+ /**
263
+ * Conservative check that a GitHub Actions workflow grants the write scope needed for
264
+ * the harness PR comment. Only inspects the `permissions:` block — if a user has wired
265
+ * a deploy token or `secrets.GITHUB_TOKEN` with a custom scope, this returns false and
266
+ * the warning is a no-op false positive. That's deliberate: a false-positive warning is
267
+ * cheaper than a silent 403 the first time CI tries to post.
268
+ *
269
+ * Caveats this regex deliberately does not handle:
270
+ * - Job-scoped `permissions:` blocks that grant `pull-requests: write` to a job other
271
+ * than the one running `/supi:harness pr-comment`. Detecting that requires real YAML
272
+ * parsing; a false-positive warning is again cheaper than guessing wrong.
273
+ */
274
+ function workflowGrantsPrCommentPermission(workflow: string): boolean {
275
+ // Strip whole-line YAML comments before matching so a commented-out
276
+ // `# pull-requests: write` does not falsely register as a grant.
277
+ const stripped = workflow.replace(/^[ \t]*#.*$/gm, "");
278
+ // Match either inline mapping `permissions: { pull-requests: write }`, the block form
279
+ // with `pull-requests: write` on its own line, or the broad `permissions: write-all`.
280
+ if (/permissions:\s*write-all\b/.test(stripped)) return true;
281
+ if (/\bpull-requests:\s*write\b/.test(stripped)) return true;
282
+ return false;
283
+ }
284
+
285
+ /**
286
+ * Structurally inspect the rendered workflow for a healthy `verify-pr-source` job.
287
+ *
288
+ * Returns `null` when the job is present, gated on the configured `mainBranch`, and
289
+ * its shell guard names the configured `devBranch`. Returns a short description of
290
+ * the first mismatch otherwise.
291
+ *
292
+ * Parsing the YAML (rather than substring-matching the source) is what catches:
293
+ * - a comment mentioning `verify-pr-source` (the substring lives, the job doesn't),
294
+ * - a stale job referencing a previous mainBranch/devBranch pair after the spec
295
+ * was updated and the workflow was not re-rendered,
296
+ * - a structurally wrong job (no `if`, no `run` block, etc.).
297
+ *
298
+ * Unparseable YAML falls back to a substring sanity check so a malformed workflow
299
+ * still reports *something* useful instead of silently passing.
300
+ */
301
+ function inspectPrSourceGuardrailJob(
302
+ workflow: string,
303
+ mainBranch: string,
304
+ devBranch: string,
305
+ ): string | null {
306
+ let doc: unknown;
307
+ try {
308
+ doc = parseYaml(workflow);
309
+ } catch {
310
+ return workflow.includes("verify-pr-source")
311
+ ? "workflow YAML is unparseable; cannot confirm guardrail is correct"
312
+ : "workflow YAML is unparseable and contains no verify-pr-source job";
313
+ }
314
+ if (!doc || typeof doc !== "object") return "workflow root is not a mapping";
315
+ const jobs = (doc as { jobs?: unknown }).jobs;
316
+ if (!jobs || typeof jobs !== "object") return "workflow has no `jobs:` mapping";
317
+ const job = (jobs as Record<string, unknown>)["verify-pr-source"];
318
+ if (!job || typeof job !== "object") return "job `verify-pr-source` is not defined";
319
+ const ifExpr = (job as { if?: unknown }).if;
320
+ if (typeof ifExpr !== "string" || !ifExpr.includes(`'${mainBranch}'`)) {
321
+ return `job \`verify-pr-source\` is not gated on mainBranch '${mainBranch}'`;
322
+ }
323
+ // The shell guard lives in steps[*].run; concatenate every run block we find so we
324
+ // do not depend on the exact step order or composition.
325
+ const steps = (job as { steps?: unknown }).steps;
326
+ const runBlocks: string[] = [];
327
+ if (Array.isArray(steps)) {
328
+ for (const step of steps) {
329
+ if (step && typeof step === "object") {
330
+ const run = (step as { run?: unknown }).run;
331
+ if (typeof run === "string") runBlocks.push(run);
332
+ }
333
+ }
334
+ }
335
+ const combinedRun = runBlocks.join("\n");
336
+ if (!combinedRun.includes(`"${devBranch}"`)) {
337
+ return `job \`verify-pr-source\` does not guard against devBranch '${devBranch}'`;
338
+ }
339
+ return null;
340
+ }
341
+
246
342
  async function checkCiLocalWiring(
247
343
  paths: HarnessStageRunnerContext["paths"],
248
344
  cwd: string,
@@ -347,6 +443,40 @@ async function checkCiLocalWiring(
347
443
  }
348
444
  }
349
445
  }
446
+ // Informational: when prComment is enabled but the workflow lacks
447
+ // `pull-requests: write`, the `gh api` upsert will fail with 403. Surface a warning
448
+ // so the user notices before the first failed PR run.
449
+ if (spec.value.ci.prComment?.enabled && !workflowGrantsPrCommentPermission(workflow)) {
450
+ findings.push({
451
+ severity: "warning",
452
+ file: spec.value.ci.workflowPath,
453
+ message: "CI workflow does not grant `pull-requests: write` but prComment.enabled is true.",
454
+ remediation: "Add `permissions: { pull-requests: write }` to the workflow so /supi:harness pr-comment can post.",
455
+ source: "ci-local-wiring",
456
+ });
457
+ }
458
+
459
+ // When the design recorded git verification with `enforceMainFromDevOnly: true`,
460
+ // confirm the workflow actually contains the `verify-pr-source` job that the
461
+ // implement stage is supposed to render, with an `if:` that names the current
462
+ // mainBranch and a shell guard that names the current devBranch. A missing or
463
+ // stale job means CI-side enforcement is silently absent — surface it as an error
464
+ // so the user notices. Substring search is intentionally avoided: a comment
465
+ // containing "verify-pr-source", or a stale job from a previous main/dev pairing,
466
+ // would pass a `workflow.includes(...)` check but provide no real enforcement.
467
+ const git = spec.value.ci.git;
468
+ if (git && git.enforceMainFromDevOnly && git.devBranch) {
469
+ const issue = inspectPrSourceGuardrailJob(workflow, git.mainBranch, git.devBranch);
470
+ if (issue) {
471
+ findings.push({
472
+ severity: "error",
473
+ file: spec.value.ci.workflowPath,
474
+ message: `CI workflow's verify-pr-source job is missing or stale: ${issue}.`,
475
+ remediation: `Re-run /supi:harness so the workflow re-renders with the dev/main guardrail (dev=${git.devBranch}, main=${git.mainBranch}).`,
476
+ source: "ci-local-wiring",
477
+ });
478
+ }
479
+ }
350
480
  } catch (error) {
351
481
  findings.push({
352
482
  severity: "error",
@@ -358,6 +488,32 @@ async function checkCiLocalWiring(
358
488
  }
359
489
  }
360
490
 
491
+ // Bubble git-verification findings recorded by the interactive QA step into the
492
+ // validate report. The QA helper records non-fatal issues (gh missing, no permission)
493
+ // so the user sees them in the validation output even if the workflow itself is fine.
494
+ // When a bubbled finding has no remediation of its own, fall back to the manual
495
+ // instructions doc — but only when one was actually written (`manualInstructionsPath`
496
+ // is set). The previous literal-`<session>` placeholder was never substituted and
497
+ // pointed at a file that was never written for declined / completed verifications.
498
+ const gitVerification = spec.value.ci.git?.verification;
499
+ if (gitVerification) {
500
+ const manualPath = gitVerification.manualInstructionsPath
501
+ ? path.join(getHarnessSessionDir(paths, cwd, sessionId), gitVerification.manualInstructionsPath)
502
+ : null;
503
+ const fallbackRemediation = manualPath
504
+ ? `See ${manualPath} for manual steps.`
505
+ : "Re-run /supi:harness to retry git verification.";
506
+ for (const finding of gitVerification.findings) {
507
+ findings.push({
508
+ severity: finding.severity,
509
+ file: spec.value.ci.workflowPath,
510
+ message: `git-verify: ${finding.message}`,
511
+ remediation: finding.remediation ?? fallbackRemediation,
512
+ source: "ci-local-wiring",
513
+ });
514
+ }
515
+ }
516
+
361
517
  return {
362
518
  name: "ci-local-wiring",
363
519
  passed: !findings.some((finding) => finding.severity === "error"),
@@ -539,6 +695,327 @@ function loadLayerRules(cwd: string): HarnessLayerRule[] {
539
695
  }
540
696
  }
541
697
 
698
+ /**
699
+ * Validate the per-layer docs tree: doc validator on each file, index ↔ filesystem
700
+ * consistency, hook integration smoke test, and sourceHash drift. Soft-failure: missing
701
+ * `docs/layers/` short-circuits to a no-op pass — the docs stage is opt-in.
702
+ */
703
+ async function checkDocsValidation(
704
+ ctx: HarnessStageRunnerContext,
705
+ layerRules: readonly HarnessLayerRule[],
706
+ ): Promise<CheckResult> {
707
+ const startedAt = Date.now();
708
+ const findings: HarnessValidateFinding[] = [];
709
+
710
+ const layersDir = path.join(ctx.cwd, "docs", "layers");
711
+ if (!fs.existsSync(layersDir)) {
712
+ return {
713
+ name: "docs-validation",
714
+ passed: true,
715
+ summary: "Per-layer docs disabled (no docs/layers/).",
716
+ findings,
717
+ durationMs: Date.now() - startedAt,
718
+ };
719
+ }
720
+ const config = resolveDocsConfig(ctx.paths, ctx.cwd);
721
+
722
+ // ── Re-validate every layer doc ──────────────────────────────────────
723
+ let docFiles: string[] = [];
724
+ try {
725
+ docFiles = fs.readdirSync(layersDir).filter((f) => f.endsWith(".md")).sort();
726
+ } catch (error) {
727
+ findings.push({
728
+ severity: "warning",
729
+ file: "docs/layers/",
730
+ message: `unable to enumerate docs/layers/: ${error instanceof Error ? error.message : String(error)}`,
731
+ remediation: "Inspect the docs/layers/ directory permissions and re-run validate.",
732
+ source: "docs-validation",
733
+ });
734
+ }
735
+
736
+ for (const fileName of docFiles) {
737
+ const layerId = fileName.replace(/\.md$/, "");
738
+ const layerPath = `docs/layers/${fileName}`;
739
+ const docPath = path.join(layersDir, fileName);
740
+ let contents: string;
741
+ try {
742
+ contents = fs.readFileSync(docPath, "utf8");
743
+ } catch (error) {
744
+ findings.push({
745
+ severity: "warning",
746
+ file: layerPath,
747
+ message: `unable to read doc: ${error instanceof Error ? error.message : String(error)}`,
748
+ remediation: "Re-run `/supi:harness docs` after fixing filesystem permissions.",
749
+ source: "docs-validation",
750
+ });
751
+ continue;
752
+ }
753
+ const recordedHash = readFrontmatterSourceHashForValidate(contents);
754
+ const validation = validateLayerDocMarkdown(contents, {
755
+ expectedLayerId: layerId,
756
+ expectedSourceHash: recordedHash ?? "",
757
+ maxDocLoc: config.max_per_doc_loc,
758
+ maxAgentContextLoc: config.agent_context_loc,
759
+ });
760
+ if (!validation.ok) {
761
+ for (const err of validation.errors) {
762
+ findings.push({
763
+ severity: "warning",
764
+ file: layerPath,
765
+ message: err,
766
+ remediation: "Re-run `/supi:harness docs` to regenerate the per-layer doc.",
767
+ source: "docs-validation",
768
+ });
769
+ }
770
+ }
771
+ }
772
+
773
+ // ── Index ↔ filesystem consistency ────────────────────────────────────
774
+ const indexPath = path.join(ctx.cwd, "docs", "README.md");
775
+ if (!fs.existsSync(indexPath)) {
776
+ findings.push({
777
+ severity: "warning",
778
+ file: "docs/README.md",
779
+ message: "docs/layers/ exists but docs/README.md is missing.",
780
+ remediation: "Re-run `/supi:harness docs` to regenerate the index.",
781
+ source: "docs-validation",
782
+ });
783
+ } else {
784
+ let indexContents: string;
785
+ try {
786
+ indexContents = fs.readFileSync(indexPath, "utf8");
787
+ } catch (error) {
788
+ findings.push({
789
+ severity: "warning",
790
+ file: "docs/README.md",
791
+ message: `unable to read docs/README.md: ${error instanceof Error ? error.message : String(error)}`,
792
+ remediation: "Inspect the file permissions and re-run validate.",
793
+ source: "docs-validation",
794
+ });
795
+ indexContents = "";
796
+ }
797
+ const referenced = new Set<string>();
798
+ for (const match of indexContents.matchAll(/docs\/layers\/([A-Za-z0-9._-]+)\.md/g)) {
799
+ referenced.add(match[1]);
800
+ }
801
+ const onDisk = new Set(docFiles.map((f) => f.replace(/\.md$/, "")));
802
+ for (const layerId of referenced) {
803
+ if (!onDisk.has(layerId)) {
804
+ findings.push({
805
+ severity: "warning",
806
+ file: "docs/README.md",
807
+ message: `index references docs/layers/${layerId}.md but the file is missing.`,
808
+ remediation: "Run `/supi:harness docs` or delete the stale row from docs/README.md.",
809
+ source: "docs-validation",
810
+ });
811
+ }
812
+ }
813
+ for (const layerId of onDisk) {
814
+ if (!referenced.has(layerId)) {
815
+ findings.push({
816
+ severity: "warning",
817
+ file: "docs/README.md",
818
+ message: `docs/layers/${layerId}.md exists but is not listed in the index.`,
819
+ remediation: "Run `/supi:harness docs` to refresh the index.",
820
+ source: "docs-validation",
821
+ });
822
+ }
823
+ }
824
+ }
825
+
826
+ // ── Hook integration smoke test ──────────────────────────────────────
827
+ for (const rule of layerRules) {
828
+ const probeFile = pickSampleFileForLayer(ctx.cwd, rule);
829
+ if (!probeFile) continue;
830
+ const result = computeLayerAddendum({
831
+ cwd: ctx.cwd,
832
+ candidateFile: probeFile,
833
+ config: { enabled: true, addendum_max_chars: 800 },
834
+ });
835
+ const docExists = fs.existsSync(path.join(ctx.cwd, "docs", "layers", `${rule.layer}.md`));
836
+ if (docExists && result.reason !== "matched (per-layer doc)") {
837
+ findings.push({
838
+ severity: "warning",
839
+ file: `docs/layers/${rule.layer}.md`,
840
+ message: `layer-context-inject hook returned "${result.reason}" despite the per-layer doc existing.`,
841
+ remediation: "Verify the per-layer doc has a non-empty ## Agent context section.",
842
+ source: "docs-validation",
843
+ });
844
+ }
845
+ }
846
+
847
+ // ── Source-hash drift ────────────────────────────────────────────────
848
+ if (config.drift_warning.enabled) {
849
+ const promptVersion = readDocsPromptVersion();
850
+ const allFiles = collectAllRepoFilesForValidate(ctx.cwd);
851
+ const goldenPrinciples = readGoldenPrinciplesForValidate(ctx.cwd);
852
+ for (const rule of layerRules) {
853
+ const docPath = path.join(layersDir, `${rule.layer}.md`);
854
+ if (!fs.existsSync(docPath)) continue;
855
+ let contents: string;
856
+ try {
857
+ contents = fs.readFileSync(docPath, "utf8");
858
+ } catch (error) {
859
+ findings.push({
860
+ severity: "warning",
861
+ file: `docs/layers/${rule.layer}.md`,
862
+ message: `unable to read doc for drift check: ${error instanceof Error ? error.message : String(error)}`,
863
+ remediation: "Inspect the file permissions and re-run validate.",
864
+ source: "docs-validation",
865
+ });
866
+ continue;
867
+ }
868
+ const recordedHash = readFrontmatterSourceHashForValidate(contents);
869
+ if (!recordedHash) continue;
870
+
871
+ const globPaths = allFiles
872
+ .filter((file) => rule.globs.some((g) => matchesLayerGlob(file, g)))
873
+ .sort();
874
+ const repSelection = selectRepresentativeFiles({ cwd: ctx.cwd, files: globPaths });
875
+ const peerLayers = layerRules
876
+ .filter((peer) => peer.layer !== rule.layer)
877
+ .map((peer) => ({ id: peer.layer, description: peer.description ?? "" }));
878
+ const currentHash = computeLayerSourceHash({
879
+ layerRule: rule,
880
+ globPaths,
881
+ representativeFiles: repSelection.entries.map((e) => ({
882
+ path: e.path,
883
+ contentHash: e.contentHash,
884
+ })),
885
+ goldenPrinciples,
886
+ peerLayers,
887
+ promptVersion,
888
+ });
889
+ if (currentHash !== recordedHash) {
890
+ findings.push({
891
+ severity: "warning",
892
+ file: `docs/layers/${rule.layer}.md`,
893
+ message: `sourceHash drift: layer inputs changed since the doc was generated.`,
894
+ remediation: "Run `/supi:harness docs` to regenerate the affected layer doc.",
895
+ source: "docs-validation",
896
+ });
897
+ }
898
+ }
899
+ }
900
+
901
+ return {
902
+ name: "docs-validation",
903
+ // Findings are advisory only — they never block the stage; the report records them.
904
+ passed: true,
905
+ summary: findings.length === 0
906
+ ? "Per-layer docs validated."
907
+ : `${findings.length} per-layer docs finding(s).`,
908
+ findings,
909
+ durationMs: Date.now() - startedAt,
910
+ };
911
+ }
912
+
913
+ function readFrontmatterSourceHashForValidate(markdown: string): string | null {
914
+ let body = markdown;
915
+ if (body.startsWith("<!--")) {
916
+ const newline = body.indexOf("\n");
917
+ if (newline > 0) body = body.slice(newline + 1);
918
+ }
919
+ if (!body.startsWith("---")) return null;
920
+ const firstNewline = body.indexOf("\n");
921
+ if (firstNewline < 0) return null;
922
+ const closeIdx = body.indexOf("\n---", firstNewline);
923
+ if (closeIdx < 0) return null;
924
+ const inner = body.slice(firstNewline + 1, closeIdx);
925
+ for (const line of inner.split("\n")) {
926
+ const match = line.match(/^sourceHash\s*:\s*(.+)\s*$/);
927
+ if (match) return match[1].trim();
928
+ }
929
+ return null;
930
+ }
931
+
932
+ function pickSampleFileForLayer(cwd: string, rule: HarnessLayerRule): string | null {
933
+ // Walk the tree until we find one file matching any layer glob; this is enough to
934
+ // exercise the hook integration check without enumerating every file twice.
935
+ const allFiles = collectAllRepoFilesForValidate(cwd);
936
+ // dynamic import keeps the top-level imports list small
937
+ const skipDirs = ["node_modules", ".git", "dist", "build", ".omp", ".cache", ".next"];
938
+ for (const file of allFiles) {
939
+ if (skipDirs.some((d) => file.startsWith(`${d}/`))) continue;
940
+ for (const glob of rule.globs) {
941
+ if (matchesLayerGlobForValidate(file, glob)) return file;
942
+ }
943
+ }
944
+ return null;
945
+ }
946
+
947
+ function matchesLayerGlobForValidate(filePath: string, glob: string): boolean {
948
+ const normalizedFile = filePath.replace(/\\/g, "/");
949
+ const normalizedGlob = glob.replace(/\\/g, "/");
950
+ const regexSrc = normalizedGlob
951
+ .split(/(\*\*|\*)/g)
952
+ .map((segment) => {
953
+ if (segment === "**") return ".*";
954
+ if (segment === "*") return "[^/]*";
955
+ return segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
956
+ })
957
+ .join("");
958
+ return new RegExp(`^${regexSrc}$`).test(normalizedFile);
959
+ }
960
+
961
+ function collectAllRepoFilesForValidate(cwd: string): string[] {
962
+ const out: string[] = [];
963
+ const skip = new Set<string>([
964
+ "node_modules",
965
+ ".git",
966
+ "dist",
967
+ "build",
968
+ ".omp",
969
+ "coverage",
970
+ ".cache",
971
+ ".next",
972
+ ]);
973
+ function walk(absolute: string, relative: string): void {
974
+ let entries: fs.Dirent[];
975
+ try {
976
+ entries = fs.readdirSync(absolute, { withFileTypes: true });
977
+ } catch {
978
+ return;
979
+ }
980
+ for (const entry of entries) {
981
+ if (entry.isDirectory()) {
982
+ if (skip.has(entry.name)) continue;
983
+ walk(path.join(absolute, entry.name), path.posix.join(relative, entry.name));
984
+ } else if (entry.isFile()) {
985
+ out.push(relative === "" ? entry.name : path.posix.join(relative, entry.name));
986
+ }
987
+ }
988
+ }
989
+ walk(cwd, "");
990
+ return out;
991
+ }
992
+
993
+ function readGoldenPrinciplesForValidate(cwd: string): string[] {
994
+ const principlesPath = path.join(cwd, "docs", "golden-principles.md");
995
+ if (!fs.existsSync(principlesPath)) return [];
996
+ try {
997
+ const md = fs.readFileSync(principlesPath, "utf8");
998
+ return md
999
+ .split("\n")
1000
+ .map((line) => line.trim())
1001
+ .filter((line) => /^\d+\.\s+/.test(line))
1002
+ .map((line) => line.replace(/^\d+\.\s+/, ""));
1003
+ } catch {
1004
+ return [];
1005
+ }
1006
+ }
1007
+
1008
+ function readDocsPromptVersion(): string {
1009
+ try {
1010
+ const docsPromptUrl = new URL("../default-agents/docs.md", import.meta.url);
1011
+ const filePath = path.normalize(decodeURI(docsPromptUrl.pathname));
1012
+ const contents = fs.readFileSync(filePath, "utf8");
1013
+ return sha256Hash(contents);
1014
+ } catch {
1015
+ return sha256Hash("harness-docs-prompt-fallback");
1016
+ }
1017
+ }
1018
+
542
1019
  /**
543
1020
  * Run every sub-check and assemble the validate report. Pure-ish: side effects are limited
544
1021
  * to the optional anti-slop scan (which itself is fenced behind the adapter's
@@ -567,6 +1044,7 @@ export async function runValidate(
567
1044
 
568
1045
  const synthetic = checkSyntheticEdit(input, layerRules);
569
1046
  checks.push(synthetic);
1047
+ checks.push(await checkDocsValidation(ctx, layerRules));
570
1048
 
571
1049
  // Persist scan findings to the queue so future runs see them.
572
1050
  for (const finding of slopFindings) {
@@ -29,15 +29,22 @@ import {
29
29
  getHarnessDecisionsPath,
30
30
  getHarnessDesignSpecJsonPath,
31
31
  getHarnessDiscoverPath,
32
+ getHarnessDocsStagingDir,
33
+ getHarnessDocsStagingLayerPath,
34
+ getHarnessDocsStagingReadmePath,
32
35
  getHarnessImplementLogPath,
33
36
  getHarnessManifestPath,
34
37
  getHarnessPipelineLogPath,
35
38
  getHarnessQueuePath,
39
+ getHarnessRepoDocsLayerPath,
40
+ getHarnessRepoDocsLayersDir,
41
+ getHarnessRepoDocsReadmePath,
36
42
  getHarnessRepoScorePath,
37
43
  getHarnessResearchTopicPath,
38
44
  getHarnessScoreHistoryPath,
39
45
  getHarnessSessionDir,
40
46
  getHarnessValidateReportPath,
47
+ HARNESS_DOCS_LAYERS_DIRNAME,
41
48
  } from "./project-paths.js";
42
49
 
43
50
  // ---------------------------------------------------------------------------
@@ -418,6 +425,44 @@ export function appendImplementLog(
418
425
  return appendJsonl(getHarnessImplementLogPath(paths, cwd, sessionId), record);
419
426
  }
420
427
 
428
+ /**
429
+ * Return true if the implement log records a successful programmatic apply for this
430
+ * session: the most recent record has `kind: "applied"` and an empty `errors` array.
431
+ * Used by `HarnessImplementStage.isComplete` to fast-skip reruns.
432
+ */
433
+ export function hasSuccessfulImplementApply(
434
+ paths: PlatformPaths,
435
+ cwd: string,
436
+ sessionId: string,
437
+ ): boolean {
438
+ const logPath = getHarnessImplementLogPath(paths, cwd, sessionId);
439
+ if (!fs.existsSync(logPath)) return false;
440
+ let raw: string;
441
+ try {
442
+ raw = fs.readFileSync(logPath, "utf8");
443
+ } catch {
444
+ return false;
445
+ }
446
+ // Scan from the end so a later failed re-apply correctly overrides an earlier success.
447
+ const lines = raw.split("\n");
448
+ for (let i = lines.length - 1; i >= 0; i--) {
449
+ const line = lines[i].trim();
450
+ if (line.length === 0) continue;
451
+ let record: unknown;
452
+ try {
453
+ record = JSON.parse(line);
454
+ } catch {
455
+ continue;
456
+ }
457
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
458
+ const r = record as { kind?: unknown; errors?: unknown };
459
+ if (r.kind !== "applied") continue;
460
+ const errCount = Array.isArray(r.errors) ? r.errors.length : 0;
461
+ return errCount === 0;
462
+ }
463
+ return false;
464
+ }
465
+
421
466
  // ---------------------------------------------------------------------------
422
467
  // Project-scoped queue + score (shared across worktrees)
423
468
  // ---------------------------------------------------------------------------
@@ -465,3 +510,100 @@ export function appendScoreHistory(
465
510
  ): UltraPlanStorageResult<string> {
466
511
  return appendJsonl(getHarnessScoreHistoryPath(paths, cwd), record);
467
512
  }
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Docs stage — staging + repo promotion.
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /** Save a single layer doc into the session's staging area. Atomic write. */
519
+ export function saveHarnessDocsLayerStaging(
520
+ paths: PlatformPaths,
521
+ cwd: string,
522
+ sessionId: string,
523
+ layerId: string,
524
+ markdown: string,
525
+ ): UltraPlanStorageResult<string> {
526
+ return writeTextAtomic(
527
+ getHarnessDocsStagingLayerPath(paths, cwd, sessionId, layerId),
528
+ markdown,
529
+ );
530
+ }
531
+
532
+ /** Read a single staged layer doc. */
533
+ export function loadHarnessDocsLayerStaging(
534
+ paths: PlatformPaths,
535
+ cwd: string,
536
+ sessionId: string,
537
+ layerId: string,
538
+ ): UltraPlanStorageResult<string> {
539
+ return readTextFile(getHarnessDocsStagingLayerPath(paths, cwd, sessionId, layerId));
540
+ }
541
+
542
+ /** List staged layer ids (file basenames without `.md`). Returns [] when dir is absent. */
543
+ export function listHarnessDocsLayerStaging(
544
+ paths: PlatformPaths,
545
+ cwd: string,
546
+ sessionId: string,
547
+ ): string[] {
548
+ const dir = path.join(
549
+ getHarnessDocsStagingDir(paths, cwd, sessionId),
550
+ HARNESS_DOCS_LAYERS_DIRNAME,
551
+ );
552
+ if (!fs.existsSync(dir)) return [];
553
+ try {
554
+ return fs
555
+ .readdirSync(dir)
556
+ .filter((name) => name.endsWith(".md"))
557
+ .map((name) => name.slice(0, -3))
558
+ .sort();
559
+ } catch {
560
+ return [];
561
+ }
562
+ }
563
+
564
+ /** Save the staged docs index. Atomic write. */
565
+ export function saveHarnessDocsIndexStaging(
566
+ paths: PlatformPaths,
567
+ cwd: string,
568
+ sessionId: string,
569
+ markdown: string,
570
+ ): UltraPlanStorageResult<string> {
571
+ return writeTextAtomic(getHarnessDocsStagingReadmePath(paths, cwd, sessionId), markdown);
572
+ }
573
+
574
+ /**
575
+ * Promote staged docs to the repo-local docs/ tree.
576
+ *
577
+ * Atomicity contract: layer docs are written first (each via temp → rename); the index
578
+ * is written last so an observer reading mid-promotion never sees an index pointing at
579
+ * a yet-to-land layer doc. A failure midway leaves the previous repo state in place for
580
+ * already-rewritten files only when their layer was earlier in the list — callers must
581
+ * therefore treat partial failures as a "blocked" outcome and rely on the next run to
582
+ * re-promote from staging.
583
+ */
584
+ export function promoteHarnessDocsToRepo(
585
+ paths: PlatformPaths,
586
+ cwd: string,
587
+ sessionId: string,
588
+ layerIds: readonly string[],
589
+ ): UltraPlanStorageResult<{ layerPaths: string[]; indexPath: string }> {
590
+ fs.mkdirSync(getHarnessRepoDocsLayersDir(paths, cwd), { recursive: true });
591
+
592
+ const layerPaths: string[] = [];
593
+ for (const layerId of layerIds) {
594
+ const staged = loadHarnessDocsLayerStaging(paths, cwd, sessionId, layerId);
595
+ if (!staged.ok) return staged;
596
+ const repoPath = getHarnessRepoDocsLayerPath(paths, cwd, layerId);
597
+ const wrote = writeTextAtomic(repoPath, staged.value);
598
+ if (!wrote.ok) return wrote;
599
+ layerPaths.push(wrote.value);
600
+ }
601
+
602
+ const indexStaged = readTextFile(getHarnessDocsStagingReadmePath(paths, cwd, sessionId));
603
+ if (!indexStaged.ok) return indexStaged;
604
+ const indexRepo = getHarnessRepoDocsReadmePath(paths, cwd);
605
+ const wroteIndex = writeTextAtomic(indexRepo, indexStaged.value);
606
+ if (!wroteIndex.ok) return wroteIndex;
607
+
608
+ return success({ layerPaths, indexPath: wroteIndex.value });
609
+ }