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.
- package/README.md +5 -6
- package/package.json +4 -2
- package/skills/harness/SKILL.md +1 -0
- package/src/bootstrap.ts +8 -133
- package/src/commands/optimize-context.ts +153 -16
- package/src/commands/runbook.ts +511 -0
- package/src/config/defaults.ts +5 -5
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +2 -6
- package/src/context/rule-renderer.ts +274 -2
- package/src/context/runbook-extension-template.ts +193 -0
- package/src/context/startup-check.ts +197 -2
- package/src/context/startup-optimizer.ts +133 -10
- package/src/context-mode/knowledge/store.ts +381 -43
- package/src/context-mode/tools.ts +41 -3
- package/src/deps/registry.ts +1 -12
- package/src/fix-pr/assessment.ts +1 -0
- package/src/fix-pr/prompt-builder.ts +1 -0
- package/src/git/commit.ts +76 -18
- package/src/harness/command.ts +201 -12
- package/src/harness/default-agents/docs.md +39 -0
- package/src/harness/docs/config.ts +29 -0
- package/src/harness/docs/glob-match.ts +27 -0
- package/src/harness/docs/index-renderer.ts +82 -0
- package/src/harness/docs/provenance.ts +125 -0
- package/src/harness/docs/regen-decision.ts +167 -0
- package/src/harness/docs/representative-files.ts +175 -0
- package/src/harness/docs/source-hash.ts +106 -0
- package/src/harness/docs/validator.ts +233 -0
- package/src/harness/git-verification.ts +515 -0
- package/src/harness/git-verify-qa.ts +406 -0
- package/src/harness/hooks/layer-context-inject.ts +35 -1
- package/src/harness/hooks/register.ts +24 -3
- package/src/harness/pipeline.ts +37 -13
- package/src/harness/pr-comment/baseline.ts +105 -0
- package/src/harness/pr-comment/ci-env.ts +120 -0
- package/src/harness/pr-comment/gh-poster.ts +227 -0
- package/src/harness/pr-comment/handler.ts +198 -0
- package/src/harness/pr-comment/render.ts +297 -0
- package/src/harness/pr-comment/status.ts +95 -0
- package/src/harness/pr-comment/types.ts +73 -0
- package/src/harness/pr-comment/workflow-summary.ts +47 -0
- package/src/harness/project-paths.ts +95 -0
- package/src/harness/stages/design.ts +1 -0
- package/src/harness/stages/discover.ts +1 -13
- package/src/harness/stages/docs.ts +708 -0
- package/src/harness/stages/implement-apply.ts +934 -0
- package/src/harness/stages/implement.ts +64 -51
- package/src/harness/stages/plan.ts +25 -16
- package/src/harness/stages/validate.ts +478 -0
- package/src/harness/storage.ts +142 -0
- package/src/harness/tools.ts +130 -0
- package/src/mempalace/bridge.ts +207 -41
- package/src/mempalace/config.ts +10 -4
- package/src/mempalace/format.ts +122 -6
- package/src/mempalace/hooks.ts +204 -56
- package/src/mempalace/installer-helper.ts +18 -4
- package/src/mempalace/python/mempalace_bridge.py +128 -3
- package/src/mempalace/runtime.ts +53 -16
- package/src/mempalace/schema.ts +151 -30
- package/src/mempalace/session-summary.ts +5 -0
- package/src/mempalace/tool.ts +17 -4
- package/src/mempalace/upstream-limits.ts +69 -0
- package/src/planning/approval-flow.ts +25 -2
- package/src/planning/planning-ask-tool.ts +34 -4
- package/src/planning/system-prompt.ts +1 -1
- package/src/tool-catalog/active-tool-controller.ts +0 -22
- package/src/tool-catalog/active-tool-planner.ts +0 -26
- package/src/tool-catalog/tool-groups.ts +1 -9
- package/src/types.ts +127 -8
- package/src/ui-design/session.ts +114 -8
- package/src/utils/executable.ts +10 -1
- package/src/workspace/state-paths.ts +1 -1
- package/src/commands/mcp.ts +0 -814
- package/src/mcp/activation.ts +0 -77
- package/src/mcp/config.ts +0 -223
- package/src/mcp/docs.ts +0 -154
- package/src/mcp/gateway.ts +0 -103
- package/src/mcp/lifecycle.ts +0 -79
- package/src/mcp/manager-tool.ts +0 -104
- package/src/mcp/mcpc.ts +0 -113
- package/src/mcp/registry.ts +0 -98
- package/src/mcp/triggers.ts +0 -62
- 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) {
|
package/src/harness/storage.ts
CHANGED
|
@@ -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
|
+
}
|