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.
- package/README.md +5 -6
- package/package.json +4 -2
- package/skills/harness/SKILL.md +1 -0
- package/src/bootstrap.ts +5 -133
- package/src/config/defaults.ts +5 -5
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +2 -6
- 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 +103 -6
- 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/hooks/layer-context-inject.ts +35 -1
- package/src/harness/hooks/register.ts +24 -3
- package/src/harness/pipeline.ts +20 -5
- 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 +877 -0
- package/src/harness/stages/implement.ts +64 -51
- package/src/harness/stages/plan.ts +25 -16
- package/src/harness/stages/validate.ts +370 -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 +87 -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
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* IMPLEMENT stage runner.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
144
|
-
kind: "
|
|
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: "
|
|
165
|
+
status: "completed",
|
|
153
166
|
stage: this.stage,
|
|
154
|
-
artifactPaths
|
|
167
|
+
artifactPaths,
|
|
155
168
|
details: {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
40
|
-
* (
|
|
41
|
-
*
|
|
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: "
|
|
163
|
-
description: "
|
|
164
|
-
files: ["
|
|
165
|
-
criteria:
|
|
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({
|
|
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) {
|