supipowers 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +5 -6
  2. package/package.json +4 -2
  3. package/skills/harness/SKILL.md +1 -0
  4. package/src/bootstrap.ts +5 -133
  5. package/src/config/defaults.ts +5 -5
  6. package/src/config/loader.ts +1 -0
  7. package/src/config/schema.ts +2 -6
  8. package/src/context-mode/knowledge/store.ts +381 -43
  9. package/src/context-mode/tools.ts +41 -3
  10. package/src/deps/registry.ts +1 -12
  11. package/src/fix-pr/assessment.ts +1 -0
  12. package/src/fix-pr/prompt-builder.ts +1 -0
  13. package/src/git/commit.ts +76 -18
  14. package/src/harness/command.ts +103 -6
  15. package/src/harness/default-agents/docs.md +39 -0
  16. package/src/harness/docs/config.ts +29 -0
  17. package/src/harness/docs/glob-match.ts +27 -0
  18. package/src/harness/docs/index-renderer.ts +82 -0
  19. package/src/harness/docs/provenance.ts +125 -0
  20. package/src/harness/docs/regen-decision.ts +167 -0
  21. package/src/harness/docs/representative-files.ts +175 -0
  22. package/src/harness/docs/source-hash.ts +106 -0
  23. package/src/harness/docs/validator.ts +233 -0
  24. package/src/harness/hooks/layer-context-inject.ts +35 -1
  25. package/src/harness/hooks/register.ts +24 -3
  26. package/src/harness/pipeline.ts +20 -5
  27. package/src/harness/pr-comment/baseline.ts +105 -0
  28. package/src/harness/pr-comment/ci-env.ts +120 -0
  29. package/src/harness/pr-comment/gh-poster.ts +227 -0
  30. package/src/harness/pr-comment/handler.ts +198 -0
  31. package/src/harness/pr-comment/render.ts +297 -0
  32. package/src/harness/pr-comment/status.ts +95 -0
  33. package/src/harness/pr-comment/types.ts +73 -0
  34. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  35. package/src/harness/project-paths.ts +95 -0
  36. package/src/harness/stages/design.ts +1 -0
  37. package/src/harness/stages/discover.ts +1 -13
  38. package/src/harness/stages/docs.ts +708 -0
  39. package/src/harness/stages/implement-apply.ts +877 -0
  40. package/src/harness/stages/implement.ts +64 -51
  41. package/src/harness/stages/plan.ts +25 -16
  42. package/src/harness/stages/validate.ts +370 -0
  43. package/src/harness/storage.ts +142 -0
  44. package/src/harness/tools.ts +130 -0
  45. package/src/mempalace/bridge.ts +207 -41
  46. package/src/mempalace/config.ts +10 -4
  47. package/src/mempalace/format.ts +122 -6
  48. package/src/mempalace/hooks.ts +204 -56
  49. package/src/mempalace/installer-helper.ts +18 -4
  50. package/src/mempalace/python/mempalace_bridge.py +128 -3
  51. package/src/mempalace/runtime.ts +53 -16
  52. package/src/mempalace/schema.ts +151 -30
  53. package/src/mempalace/session-summary.ts +5 -0
  54. package/src/mempalace/tool.ts +17 -4
  55. package/src/mempalace/upstream-limits.ts +69 -0
  56. package/src/planning/approval-flow.ts +25 -2
  57. package/src/planning/planning-ask-tool.ts +34 -4
  58. package/src/planning/system-prompt.ts +1 -1
  59. package/src/tool-catalog/active-tool-controller.ts +0 -22
  60. package/src/tool-catalog/active-tool-planner.ts +0 -26
  61. package/src/tool-catalog/tool-groups.ts +1 -9
  62. package/src/types.ts +87 -8
  63. package/src/ui-design/session.ts +114 -8
  64. package/src/utils/executable.ts +10 -1
  65. package/src/workspace/state-paths.ts +1 -1
  66. package/src/commands/mcp.ts +0 -814
  67. package/src/mcp/activation.ts +0 -77
  68. package/src/mcp/config.ts +0 -223
  69. package/src/mcp/docs.ts +0 -154
  70. package/src/mcp/gateway.ts +0 -103
  71. package/src/mcp/lifecycle.ts +0 -79
  72. package/src/mcp/manager-tool.ts +0 -104
  73. package/src/mcp/mcpc.ts +0 -113
  74. package/src/mcp/registry.ts +0 -98
  75. package/src/mcp/triggers.ts +0 -62
  76. package/src/mcp/types.ts +0 -95
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Decide which per-layer docs need regeneration, which to skip, and which were
3
+ * hand-edited by the user.
4
+ *
5
+ * Pure function over filesystem reads. Inputs:
6
+ * - the active layer set
7
+ * - the cwd hosting `docs/layers/<id>.md`
8
+ * - the expected source hash per layer
9
+ *
10
+ * Outputs the three buckets the docs stage acts on:
11
+ * - `regen`: missing doc OR sourceHash mismatch with provenance intact
12
+ * - `skip`: doc exists, provenance intact, sourceHash matches expected
13
+ * - `userEdited`: marker present but body hash mismatch (or marker missing on a file
14
+ * that exists at the well-known path)
15
+ *
16
+ * The docs stage refuses to overwrite `userEdited` files; it surfaces them as warnings.
17
+ */
18
+
19
+ import * as fs from "node:fs";
20
+
21
+ import type { HarnessLayerRule } from "../../types.js";
22
+ import { getHarnessRepoDocsLayerPath } from "../project-paths.js";
23
+ import type { PlatformPaths } from "../../platform/types.js";
24
+ import {
25
+ detectUserEdit,
26
+ parseProvenance,
27
+ } from "./provenance.js";
28
+
29
+ export type RegenAction = "regen" | "skip" | "userEdited";
30
+
31
+ export interface RegenDecisionEntry {
32
+ layerId: string;
33
+ action: RegenAction;
34
+ /** Reason note; useful for tracing decisions. */
35
+ reason: string;
36
+ }
37
+
38
+ export interface DecideRegenSetInput {
39
+ paths: PlatformPaths;
40
+ cwd: string;
41
+ layers: readonly HarnessLayerRule[];
42
+ /** Map from layer id → expected source hash. Missing entries default to "regen". */
43
+ expectedSourceHashes: ReadonlyMap<string, string>;
44
+ }
45
+
46
+ export interface DecideRegenSetResult {
47
+ /** Convenience: layers that must regen. */
48
+ regen: string[];
49
+ /** Convenience: layers that can skip. */
50
+ skip: string[];
51
+ /** Convenience: layers preserved because the user edited them. */
52
+ userEdited: string[];
53
+ /** Full per-layer decision trace. */
54
+ entries: RegenDecisionEntry[];
55
+ }
56
+
57
+ /**
58
+ * Compute the regen decision for every layer. Reads the repo-local docs/layers/<id>.md
59
+ * if it exists; never writes.
60
+ */
61
+ export function decideRegenSet(input: DecideRegenSetInput): DecideRegenSetResult {
62
+ const entries: RegenDecisionEntry[] = [];
63
+ for (const layer of input.layers) {
64
+ const docPath = getHarnessRepoDocsLayerPath(input.paths, input.cwd, layer.layer);
65
+ const expected = input.expectedSourceHashes.get(layer.layer);
66
+
67
+ if (!fs.existsSync(docPath)) {
68
+ entries.push({
69
+ layerId: layer.layer,
70
+ action: "regen",
71
+ reason: "doc missing",
72
+ });
73
+ continue;
74
+ }
75
+
76
+ let contents: string;
77
+ try {
78
+ contents = fs.readFileSync(docPath, "utf8");
79
+ } catch (error) {
80
+ entries.push({
81
+ layerId: layer.layer,
82
+ action: "regen",
83
+ reason: `unable to read doc: ${error instanceof Error ? error.message : String(error)}`,
84
+ });
85
+ continue;
86
+ }
87
+
88
+ const editState = detectUserEdit(contents);
89
+ if (editState === "unmarked") {
90
+ entries.push({
91
+ layerId: layer.layer,
92
+ action: "userEdited",
93
+ reason: "doc has no harness-docs marker; treated as user-authored",
94
+ });
95
+ continue;
96
+ }
97
+ if (editState === "edited") {
98
+ entries.push({
99
+ layerId: layer.layer,
100
+ action: "userEdited",
101
+ reason: "doc body hash differs from marker contentHash; user edits preserved",
102
+ });
103
+ continue;
104
+ }
105
+
106
+ // intact → compare frontmatter sourceHash to expected.
107
+ if (!expected) {
108
+ entries.push({
109
+ layerId: layer.layer,
110
+ action: "regen",
111
+ reason: "no expected source hash supplied",
112
+ });
113
+ continue;
114
+ }
115
+
116
+ const sourceHash = readFrontmatterSourceHash(contents);
117
+ if (!sourceHash) {
118
+ entries.push({
119
+ layerId: layer.layer,
120
+ action: "regen",
121
+ reason: "doc is intact but frontmatter lacks sourceHash",
122
+ });
123
+ continue;
124
+ }
125
+ if (sourceHash !== expected) {
126
+ entries.push({
127
+ layerId: layer.layer,
128
+ action: "regen",
129
+ reason: "frontmatter sourceHash does not match expected (inputs changed)",
130
+ });
131
+ continue;
132
+ }
133
+
134
+ entries.push({
135
+ layerId: layer.layer,
136
+ action: "skip",
137
+ reason: "doc is up-to-date",
138
+ });
139
+ }
140
+
141
+ return {
142
+ regen: entries.filter((e) => e.action === "regen").map((e) => e.layerId),
143
+ skip: entries.filter((e) => e.action === "skip").map((e) => e.layerId),
144
+ userEdited: entries.filter((e) => e.action === "userEdited").map((e) => e.layerId),
145
+ entries,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Extract the `sourceHash:` value from the YAML frontmatter of a docs file. Returns
151
+ * null when the doc lacks well-formed frontmatter.
152
+ */
153
+ function readFrontmatterSourceHash(markdown: string): string | null {
154
+ const parsed = parseProvenance(markdown);
155
+ const body = parsed ? parsed.body : markdown;
156
+ if (!body.startsWith("---")) return null;
157
+ const firstNewline = body.indexOf("\n");
158
+ if (firstNewline < 0) return null;
159
+ const closeIdx = body.indexOf("\n---", firstNewline);
160
+ if (closeIdx < 0) return null;
161
+ const inner = body.slice(firstNewline + 1, closeIdx);
162
+ for (const line of inner.split("\n")) {
163
+ const match = line.match(/^sourceHash\s*:\s*(.+)\s*$/);
164
+ if (match) return match[1].trim();
165
+ }
166
+ return null;
167
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Representative-file selection for the docs stage.
3
+ *
4
+ * Given a list of files in a layer, pick the top-N by LOC and produce a head-K slice of
5
+ * each for the subagent's input bundle. Caps the total payload so even pathological
6
+ * monorepo layers never blow the prompt budget.
7
+ *
8
+ * Deterministic ordering is non-negotiable — the source-hash composition depends on it.
9
+ * Ordering is LOC-descending, then path-ascending as a tiebreaker.
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+
15
+ import { sha256 } from "./source-hash.js";
16
+
17
+ /**
18
+ * Default top-N representative files to include. Matches the plan's Q7a contract.
19
+ */
20
+ export const DEFAULT_REPRESENTATIVE_COUNT = 5;
21
+
22
+ /**
23
+ * Default head-K LOC to sample per representative file. Matches the plan's Q7a contract.
24
+ */
25
+ export const DEFAULT_REPRESENTATIVE_HEAD_LOC = 80;
26
+
27
+ /**
28
+ * Hard cap on the total subagent input bundle in bytes. Keeps us well inside any
29
+ * reasonable model context budget; representative-file output is the only growable
30
+ * component.
31
+ */
32
+ export const DEFAULT_BUNDLE_BYTES_CAP = 25_000;
33
+
34
+ export interface RepresentativeFileEntry {
35
+ /** Forward-slashed path relative to the repo root. */
36
+ path: string;
37
+ /** Total LOC in the underlying file (used for ordering only). */
38
+ loc: number;
39
+ /** Head-K slice of the file body, with trailing "…\n" when the body was truncated. */
40
+ sample: string;
41
+ /** sha256 of the file's full contents at read time. */
42
+ contentHash: string;
43
+ }
44
+
45
+ export interface SelectRepresentativeFilesInput {
46
+ /** Repo root. All `files` paths are resolved relative to this. */
47
+ cwd: string;
48
+ /** Candidate files (forward-slashed, relative to `cwd`). */
49
+ files: readonly string[];
50
+ /** Max number of representative files to keep. */
51
+ topN?: number;
52
+ /** Max LOC to sample per file. */
53
+ headLoc?: number;
54
+ /** Total bundle byte cap; files past it are dropped in tail order. */
55
+ bundleBytesCap?: number;
56
+ }
57
+
58
+ export interface SelectRepresentativeFilesResult {
59
+ /** Selected representative files, sorted by LOC desc → path asc. */
60
+ entries: RepresentativeFileEntry[];
61
+ /** Files we considered but skipped because reading failed; useful for debugging. */
62
+ unreadable: string[];
63
+ }
64
+
65
+ /**
66
+ * Read every candidate file, sort by LOC desc, and emit a deterministic, byte-capped
67
+ * representative sample list. Pure-ish: reads the filesystem but never writes.
68
+ */
69
+ export function selectRepresentativeFiles(
70
+ input: SelectRepresentativeFilesInput,
71
+ ): SelectRepresentativeFilesResult {
72
+ const topN = input.topN ?? DEFAULT_REPRESENTATIVE_COUNT;
73
+ const headLoc = input.headLoc ?? DEFAULT_REPRESENTATIVE_HEAD_LOC;
74
+ const bundleCap = input.bundleBytesCap ?? DEFAULT_BUNDLE_BYTES_CAP;
75
+
76
+ type Stat = { path: string; loc: number; contents: string; contentHash: string };
77
+
78
+ const stats: Stat[] = [];
79
+ const unreadable: string[] = [];
80
+ for (const rel of input.files) {
81
+ const absolute = path.join(input.cwd, rel);
82
+ let contents: string;
83
+ try {
84
+ contents = fs.readFileSync(absolute, "utf8");
85
+ } catch {
86
+ unreadable.push(rel);
87
+ continue;
88
+ }
89
+ const loc = countLines(contents);
90
+ stats.push({
91
+ path: rel,
92
+ loc,
93
+ contents,
94
+ contentHash: sha256(contents),
95
+ });
96
+ }
97
+
98
+ stats.sort((a, b) => {
99
+ if (a.loc !== b.loc) return b.loc - a.loc;
100
+ return a.path.localeCompare(b.path);
101
+ });
102
+
103
+ const limited = stats.slice(0, topN);
104
+
105
+ // Byte-cap pass: build samples and stop when the cumulative byte count would exceed
106
+ // bundleBytesCap. We always emit the largest-LOC file (top of the list) even if it
107
+ // alone exceeds the cap — the runner must know about it. Subsequent files yield to
108
+ // the cap.
109
+ const entries: RepresentativeFileEntry[] = [];
110
+ let used = 0;
111
+ for (let i = 0; i < limited.length; i += 1) {
112
+ const stat = limited[i];
113
+ const sample = headSlice(stat.contents, headLoc);
114
+ const cost = Buffer.byteLength(sample, "utf8");
115
+ if (i > 0 && used + cost > bundleCap) break;
116
+ used += cost;
117
+ entries.push({
118
+ path: stat.path,
119
+ loc: stat.loc,
120
+ sample,
121
+ contentHash: stat.contentHash,
122
+ });
123
+ }
124
+
125
+ return { entries, unreadable };
126
+ }
127
+
128
+ function countLines(contents: string): number {
129
+ if (contents.length === 0) return 0;
130
+ let count = 1;
131
+ for (let i = 0; i < contents.length; i += 1) {
132
+ if (contents.charCodeAt(i) === 10 /* \n */) count += 1;
133
+ }
134
+ // A trailing newline implies the last "line" is empty; subtract one to match `wc -l`-ish
135
+ // semantics callers expect.
136
+ if (contents.charCodeAt(contents.length - 1) === 10) count -= 1;
137
+ return count;
138
+ }
139
+
140
+ function headSlice(contents: string, headLoc: number): string {
141
+ if (headLoc <= 0) return "";
142
+ let consumed = 0;
143
+ let cursor = 0;
144
+ for (let i = 0; i < contents.length; i += 1) {
145
+ if (contents.charCodeAt(i) === 10 /* \n */) {
146
+ consumed += 1;
147
+ if (consumed >= headLoc) {
148
+ cursor = i + 1;
149
+ break;
150
+ }
151
+ }
152
+ cursor = i + 1;
153
+ }
154
+ if (cursor >= contents.length) return contents;
155
+ return `${contents.slice(0, cursor)}…\n`;
156
+ }
157
+
158
+ /**
159
+ * Render the subagent input bundle's representative-files block.
160
+ *
161
+ * Format mirrors the plan's Q7a contract:
162
+ * --- src/path/a.ts ---
163
+ * <sample>
164
+ * --- src/path/b.ts ---
165
+ * ...
166
+ */
167
+ export function renderRepresentativeBlock(entries: readonly RepresentativeFileEntry[]): string {
168
+ if (entries.length === 0) return "(no representative files)";
169
+ const out: string[] = [];
170
+ for (const entry of entries) {
171
+ out.push(`--- ${entry.path} ---`);
172
+ out.push(entry.sample.endsWith("\n") ? entry.sample.slice(0, -1) : entry.sample);
173
+ }
174
+ return out.join("\n");
175
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Source-hash composition for the docs stage.
3
+ *
4
+ * The hash is the single trigger that decides whether a per-layer doc needs to be
5
+ * regenerated. It composes every input the subagent sees (layer rule, layer file paths,
6
+ * representative file contents, golden principles, peer layer descriptors, prompt
7
+ * version) into a stable JSON payload, then sha256s the result.
8
+ *
9
+ * Determinism is non-negotiable — any inadvertently mutable input (filesystem order,
10
+ * representative-file ordering, etc.) defeats the cache and forces wasteful re-runs.
11
+ */
12
+
13
+ import * as crypto from "node:crypto";
14
+
15
+ import type { HarnessLayerRule } from "../../types.js";
16
+
17
+ export interface RepresentativeFileFingerprint {
18
+ /** Path relative to the repo root, forward-slashed. */
19
+ path: string;
20
+ /** sha256 of the file contents at hash-compute time. */
21
+ contentHash: string;
22
+ }
23
+
24
+ export interface PeerLayerFingerprint {
25
+ /** Layer id. */
26
+ id: string;
27
+ /** Human-readable description; empty string when the layer rule omits one. */
28
+ description: string;
29
+ }
30
+
31
+ export interface ComputeLayerSourceHashInput {
32
+ /** Layer rule under consideration. Embedded verbatim into the hash payload. */
33
+ layerRule: HarnessLayerRule;
34
+ /** Sorted (lexicographic) list of every file path matching the layer glob. */
35
+ globPaths: readonly string[];
36
+ /** Representative files the subagent reads (top-N by LOC), each with a content hash. */
37
+ representativeFiles: readonly RepresentativeFileFingerprint[];
38
+ /** Repo-wide golden principles, in their original document order. */
39
+ goldenPrinciples: readonly string[];
40
+ /** Peer layer descriptors (id + description). Sorted internally before hashing. */
41
+ peerLayers: readonly PeerLayerFingerprint[];
42
+ /** sha256 of the subagent system prompt at build time. */
43
+ promptVersion: string;
44
+ }
45
+
46
+ /**
47
+ * Compute the deterministic source hash for a single layer doc. Pure function.
48
+ *
49
+ * Sort behavior:
50
+ * - `globPaths` are sorted defensively (lexicographic).
51
+ * - `representativeFiles` are sorted by `path` (lexicographic).
52
+ * - `peerLayers` are sorted by `id` (lexicographic).
53
+ *
54
+ * Determinism contract: any deep-equal set of inputs yields the same hash regardless of
55
+ * caller-provided ordering on the three sorted arrays.
56
+ */
57
+ export function computeLayerSourceHash(input: ComputeLayerSourceHashInput): string {
58
+ const sortedGlobPaths = [...input.globPaths].sort((a, b) => a.localeCompare(b));
59
+ const sortedRepFiles = [...input.representativeFiles]
60
+ .map((entry) => ({ path: entry.path, contentHash: entry.contentHash }))
61
+ .sort((a, b) => a.path.localeCompare(b.path));
62
+ const sortedPeerLayers = [...input.peerLayers]
63
+ .map((entry) => ({ id: entry.id, description: entry.description }))
64
+ .sort((a, b) => a.id.localeCompare(b.id));
65
+
66
+ const payload = {
67
+ layerRule: serializeLayerRule(input.layerRule),
68
+ globPaths: sortedGlobPaths,
69
+ representativeFiles: sortedRepFiles,
70
+ goldenPrinciples: [...input.goldenPrinciples],
71
+ peerLayers: sortedPeerLayers,
72
+ promptVersion: input.promptVersion,
73
+ };
74
+
75
+ return sha256Json(payload);
76
+ }
77
+
78
+ /** Compute sha256 of a UTF-8 string. */
79
+ export function sha256(text: string): string {
80
+ return crypto.createHash("sha256").update(text, "utf8").digest("hex");
81
+ }
82
+
83
+ /** Compute sha256 of a JSON-serializable payload. Object key order is preserved verbatim. */
84
+ export function sha256Json(payload: unknown): string {
85
+ return sha256(JSON.stringify(payload));
86
+ }
87
+
88
+ /**
89
+ * Reduce a layer rule to a deterministic shape (sorted import lists, normalized description)
90
+ * so two semantically-equal rules produce the same hash regardless of source ordering.
91
+ */
92
+ function serializeLayerRule(rule: HarnessLayerRule): {
93
+ layer: string;
94
+ globs: string[];
95
+ allowedImports: string[];
96
+ forbiddenImports: string[];
97
+ description: string;
98
+ } {
99
+ return {
100
+ layer: rule.layer,
101
+ globs: [...rule.globs].sort((a, b) => a.localeCompare(b)),
102
+ allowedImports: [...rule.allowedImports].sort((a, b) => a.localeCompare(b)),
103
+ forbiddenImports: [...rule.forbiddenImports].sort((a, b) => a.localeCompare(b)),
104
+ description: rule.description ?? "",
105
+ };
106
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Synchronous validator for a per-layer doc rendered by a docs-stage subagent.
3
+ *
4
+ * Validation is mechanical: LOC caps, required headings in order, frontmatter shape,
5
+ * agent-context section cap, sourceHash match, no TODO/XXX markers. The validator runs
6
+ * inside the `harness_docs_record` tool handler, so failures must be safe to surface to
7
+ * the subagent (structured error strings).
8
+ */
9
+
10
+ import { parseProvenance } from "./provenance.js";
11
+
12
+ export interface ValidateLayerDocOptions {
13
+ /** Expected layer id (from the assignment). */
14
+ expectedLayerId: string;
15
+ /** Expected sourceHash; the renderer must embed this verbatim. */
16
+ expectedSourceHash: string;
17
+ /** Hard cap on total LOC (default 150). */
18
+ maxDocLoc?: number;
19
+ /** Hard cap on the `## Agent context` section LOC (default 30). */
20
+ maxAgentContextLoc?: number;
21
+ }
22
+
23
+ export const DEFAULT_MAX_DOC_LOC = 150;
24
+ export const DEFAULT_MAX_AGENT_CONTEXT_LOC = 30;
25
+
26
+ /** Required headings, in the order the doc must place them. */
27
+ export const REQUIRED_HEADINGS: readonly string[] = [
28
+ "## Agent context",
29
+ "## Purpose",
30
+ "## Files",
31
+ "## Imports",
32
+ "## Conventions",
33
+ ];
34
+
35
+ const PLACEHOLDER_PATTERN = /\b(TODO|XXX|FIXME|TBD|<placeholder>)\b/;
36
+
37
+ export interface ValidateLayerDocResult {
38
+ ok: boolean;
39
+ errors: string[];
40
+ }
41
+
42
+ /**
43
+ * Validate a layer-doc markdown body. Returns `{ ok, errors }`. `errors` is empty when
44
+ * `ok === true`.
45
+ */
46
+ export function validateLayerDocMarkdown(
47
+ markdown: string,
48
+ options: ValidateLayerDocOptions,
49
+ ): ValidateLayerDocResult {
50
+ const errors: string[] = [];
51
+ const maxDocLoc = options.maxDocLoc ?? DEFAULT_MAX_DOC_LOC;
52
+ const maxAgentContextLoc = options.maxAgentContextLoc ?? DEFAULT_MAX_AGENT_CONTEXT_LOC;
53
+
54
+ // 1. Provenance marker on first line.
55
+ const parsed = parseProvenance(markdown);
56
+ if (!parsed) {
57
+ errors.push("missing or malformed provenance marker on the first line");
58
+ }
59
+
60
+ // 2. LOC budget.
61
+ const lineCount = countDocLines(markdown);
62
+ if (lineCount > maxDocLoc) {
63
+ errors.push(`doc has ${lineCount} LOC; max is ${maxDocLoc}`);
64
+ }
65
+
66
+ // 3. Frontmatter — between the first --- after the marker and the next ---.
67
+ const frontmatter = extractFrontmatter(parsed?.body ?? markdown);
68
+ if (!frontmatter) {
69
+ errors.push("missing YAML frontmatter (---\\n…\\n---) immediately after the marker");
70
+ } else {
71
+ if (frontmatter.layer !== options.expectedLayerId) {
72
+ errors.push(
73
+ `frontmatter layer mismatch (got "${frontmatter.layer ?? ""}", expected "${options.expectedLayerId}")`,
74
+ );
75
+ }
76
+ if (!frontmatter.generatedAt) {
77
+ errors.push("frontmatter is missing `generatedAt`");
78
+ }
79
+ if (!frontmatter.sourceHash) {
80
+ errors.push("frontmatter is missing `sourceHash`");
81
+ } else if (frontmatter.sourceHash !== options.expectedSourceHash) {
82
+ errors.push(
83
+ `frontmatter sourceHash mismatch (got "${frontmatter.sourceHash}", expected "${options.expectedSourceHash}")`,
84
+ );
85
+ }
86
+ }
87
+
88
+ // 4. Required headings in order.
89
+ const headingResult = checkHeadings(markdown);
90
+ if (headingResult.missing.length > 0) {
91
+ errors.push(`missing required heading(s): ${headingResult.missing.join(", ")}`);
92
+ }
93
+ if (headingResult.outOfOrder) {
94
+ errors.push(`required headings appear out of order — expected: ${REQUIRED_HEADINGS.join(" → ")}`);
95
+ }
96
+
97
+ // 5. Agent-context cap.
98
+ const agentSectionLoc = sectionLoc(markdown, "## Agent context");
99
+ if (agentSectionLoc > maxAgentContextLoc) {
100
+ errors.push(`## Agent context section is ${agentSectionLoc} LOC; max is ${maxAgentContextLoc}`);
101
+ }
102
+
103
+ // 6. Placeholder markers.
104
+ if (PLACEHOLDER_PATTERN.test(markdown)) {
105
+ errors.push("doc contains a TODO/XXX/FIXME/TBD placeholder marker; remove before recording");
106
+ }
107
+
108
+ return { ok: errors.length === 0, errors };
109
+ }
110
+
111
+ /** Count LOC ignoring a trailing empty line (matches representativeFiles `countLines`). */
112
+ function countDocLines(markdown: string): number {
113
+ if (markdown.length === 0) return 0;
114
+ let count = 1;
115
+ for (let i = 0; i < markdown.length; i += 1) {
116
+ if (markdown.charCodeAt(i) === 10 /* \n */) count += 1;
117
+ }
118
+ if (markdown.charCodeAt(markdown.length - 1) === 10) count -= 1;
119
+ return count;
120
+ }
121
+
122
+ interface ParsedFrontmatter {
123
+ layer?: string;
124
+ generatedAt?: string;
125
+ sourceHash?: string;
126
+ /** Raw map for tests / future fields. */
127
+ raw: Map<string, string>;
128
+ }
129
+
130
+ function extractFrontmatter(body: string): ParsedFrontmatter | null {
131
+ if (!body.startsWith("---")) return null;
132
+ const newlineAfterOpen = body.indexOf("\n");
133
+ if (newlineAfterOpen < 0) return null;
134
+ // Find the next "\n---" closer.
135
+ const closeIdx = body.indexOf("\n---", newlineAfterOpen);
136
+ if (closeIdx < 0) return null;
137
+
138
+ const inner = body.slice(newlineAfterOpen + 1, closeIdx);
139
+ const map = new Map<string, string>();
140
+ for (const line of inner.split("\n")) {
141
+ const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
142
+ if (!m) continue;
143
+ map.set(m[1], m[2].trim());
144
+ }
145
+ return {
146
+ layer: map.get("layer"),
147
+ generatedAt: map.get("generatedAt"),
148
+ sourceHash: map.get("sourceHash"),
149
+ raw: map,
150
+ };
151
+ }
152
+
153
+ function checkHeadings(markdown: string): { missing: string[]; outOfOrder: boolean } {
154
+ const seen: { heading: string; index: number }[] = [];
155
+ for (const heading of REQUIRED_HEADINGS) {
156
+ const pattern = new RegExp(`^${escapeRegex(heading)}\\s*$`, "m");
157
+ const match = markdown.match(pattern);
158
+ if (match && typeof match.index === "number") {
159
+ seen.push({ heading, index: match.index });
160
+ }
161
+ }
162
+
163
+ const missing = REQUIRED_HEADINGS.filter(
164
+ (h) => !seen.some((entry) => entry.heading === h),
165
+ );
166
+
167
+ // Check ordering only for headings we did see.
168
+ let outOfOrder = false;
169
+ let lastIndex = -1;
170
+ for (const heading of REQUIRED_HEADINGS) {
171
+ const entry = seen.find((s) => s.heading === heading);
172
+ if (!entry) continue;
173
+ if (entry.index < lastIndex) {
174
+ outOfOrder = true;
175
+ break;
176
+ }
177
+ lastIndex = entry.index;
178
+ }
179
+ return { missing: missing.slice(), outOfOrder };
180
+ }
181
+
182
+ /** Count LOC of the section starting at `heading` (exclusive) up to the next `## ` heading. */
183
+ export function sectionLoc(markdown: string, heading: string): number {
184
+ const lines = markdown.split("\n");
185
+ let start = -1;
186
+ for (let i = 0; i < lines.length; i += 1) {
187
+ if (lines[i] === heading || lines[i].startsWith(`${heading} `)) {
188
+ start = i;
189
+ break;
190
+ }
191
+ }
192
+ if (start < 0) return 0;
193
+ let end = lines.length;
194
+ for (let i = start + 1; i < lines.length; i += 1) {
195
+ if (lines[i].startsWith("## ") || lines[i] === "##") {
196
+ end = i;
197
+ break;
198
+ }
199
+ }
200
+ // Trim trailing blank lines.
201
+ while (end > start + 1 && lines[end - 1].trim() === "") end -= 1;
202
+ return Math.max(0, end - start - 1);
203
+ }
204
+
205
+ /** Extract the body of the `## Agent context` section. Returns "" when missing. */
206
+ export function extractAgentContextSection(markdown: string, maxLoc?: number): string {
207
+ const lines = markdown.split("\n");
208
+ let start = -1;
209
+ for (let i = 0; i < lines.length; i += 1) {
210
+ if (lines[i] === "## Agent context" || lines[i].startsWith("## Agent context ")) {
211
+ start = i;
212
+ break;
213
+ }
214
+ }
215
+ if (start < 0) return "";
216
+ let end = lines.length;
217
+ for (let i = start + 1; i < lines.length; i += 1) {
218
+ if (lines[i].startsWith("## ") || lines[i] === "##") {
219
+ end = i;
220
+ break;
221
+ }
222
+ }
223
+ while (end > start + 1 && lines[end - 1].trim() === "") end -= 1;
224
+ let bodyLines = lines.slice(start + 1, end);
225
+ if (maxLoc !== undefined && bodyLines.length > maxLoc) {
226
+ bodyLines = bodyLines.slice(0, maxLoc);
227
+ }
228
+ return bodyLines.join("\n");
229
+ }
230
+
231
+ function escapeRegex(input: string): string {
232
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
233
+ }