supipowers 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +5 -6
  2. package/package.json +4 -2
  3. package/skills/harness/SKILL.md +1 -0
  4. package/src/bootstrap.ts +8 -133
  5. package/src/commands/optimize-context.ts +153 -16
  6. package/src/commands/runbook.ts +511 -0
  7. package/src/config/defaults.ts +5 -5
  8. package/src/config/loader.ts +1 -0
  9. package/src/config/schema.ts +2 -6
  10. package/src/context/rule-renderer.ts +274 -2
  11. package/src/context/runbook-extension-template.ts +193 -0
  12. package/src/context/startup-check.ts +197 -2
  13. package/src/context/startup-optimizer.ts +133 -10
  14. package/src/context-mode/knowledge/store.ts +381 -43
  15. package/src/context-mode/tools.ts +41 -3
  16. package/src/deps/registry.ts +1 -12
  17. package/src/fix-pr/assessment.ts +1 -0
  18. package/src/fix-pr/prompt-builder.ts +1 -0
  19. package/src/git/commit.ts +76 -18
  20. package/src/harness/command.ts +201 -12
  21. package/src/harness/default-agents/docs.md +39 -0
  22. package/src/harness/docs/config.ts +29 -0
  23. package/src/harness/docs/glob-match.ts +27 -0
  24. package/src/harness/docs/index-renderer.ts +82 -0
  25. package/src/harness/docs/provenance.ts +125 -0
  26. package/src/harness/docs/regen-decision.ts +167 -0
  27. package/src/harness/docs/representative-files.ts +175 -0
  28. package/src/harness/docs/source-hash.ts +106 -0
  29. package/src/harness/docs/validator.ts +233 -0
  30. package/src/harness/git-verification.ts +515 -0
  31. package/src/harness/git-verify-qa.ts +406 -0
  32. package/src/harness/hooks/layer-context-inject.ts +35 -1
  33. package/src/harness/hooks/register.ts +24 -3
  34. package/src/harness/pipeline.ts +37 -13
  35. package/src/harness/pr-comment/baseline.ts +105 -0
  36. package/src/harness/pr-comment/ci-env.ts +120 -0
  37. package/src/harness/pr-comment/gh-poster.ts +227 -0
  38. package/src/harness/pr-comment/handler.ts +198 -0
  39. package/src/harness/pr-comment/render.ts +297 -0
  40. package/src/harness/pr-comment/status.ts +95 -0
  41. package/src/harness/pr-comment/types.ts +73 -0
  42. package/src/harness/pr-comment/workflow-summary.ts +47 -0
  43. package/src/harness/project-paths.ts +95 -0
  44. package/src/harness/stages/design.ts +1 -0
  45. package/src/harness/stages/discover.ts +1 -13
  46. package/src/harness/stages/docs.ts +708 -0
  47. package/src/harness/stages/implement-apply.ts +934 -0
  48. package/src/harness/stages/implement.ts +64 -51
  49. package/src/harness/stages/plan.ts +25 -16
  50. package/src/harness/stages/validate.ts +478 -0
  51. package/src/harness/storage.ts +142 -0
  52. package/src/harness/tools.ts +130 -0
  53. package/src/mempalace/bridge.ts +207 -41
  54. package/src/mempalace/config.ts +10 -4
  55. package/src/mempalace/format.ts +122 -6
  56. package/src/mempalace/hooks.ts +204 -56
  57. package/src/mempalace/installer-helper.ts +18 -4
  58. package/src/mempalace/python/mempalace_bridge.py +128 -3
  59. package/src/mempalace/runtime.ts +53 -16
  60. package/src/mempalace/schema.ts +151 -30
  61. package/src/mempalace/session-summary.ts +5 -0
  62. package/src/mempalace/tool.ts +17 -4
  63. package/src/mempalace/upstream-limits.ts +69 -0
  64. package/src/planning/approval-flow.ts +25 -2
  65. package/src/planning/planning-ask-tool.ts +34 -4
  66. package/src/planning/system-prompt.ts +1 -1
  67. package/src/tool-catalog/active-tool-controller.ts +0 -22
  68. package/src/tool-catalog/active-tool-planner.ts +0 -26
  69. package/src/tool-catalog/tool-groups.ts +1 -9
  70. package/src/types.ts +127 -8
  71. package/src/ui-design/session.ts +114 -8
  72. package/src/utils/executable.ts +10 -1
  73. package/src/workspace/state-paths.ts +1 -1
  74. package/src/commands/mcp.ts +0 -814
  75. package/src/mcp/activation.ts +0 -77
  76. package/src/mcp/config.ts +0 -223
  77. package/src/mcp/docs.ts +0 -154
  78. package/src/mcp/gateway.ts +0 -103
  79. package/src/mcp/lifecycle.ts +0 -79
  80. package/src/mcp/manager-tool.ts +0 -104
  81. package/src/mcp/mcpc.ts +0 -113
  82. package/src/mcp/registry.ts +0 -98
  83. package/src/mcp/triggers.ts +0 -62
  84. package/src/mcp/types.ts +0 -95
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Provenance marker for harness-generated docs.
3
+ *
4
+ * Every doc rendered by the docs stage carries a single HTML-style comment on the first
5
+ * line:
6
+ *
7
+ * <!-- harness-docs:session=<sid> generated=<iso> contentHash=<sha256> -->
8
+ *
9
+ * The marker is the only thing the user must not edit. It lets the regen-decision logic
10
+ * distinguish between (1) a doc that is still in sync with what the harness produced,
11
+ * (2) a doc the user hand-edited and should not be overwritten, and (3) a doc that just
12
+ * needs to be regenerated because its inputs changed.
13
+ *
14
+ * Format is intentionally compact and easy to parse with a regex — the marker is on a
15
+ * single line, fields are `key=value` pairs separated by spaces.
16
+ */
17
+
18
+ import { sha256 } from "./source-hash.js";
19
+
20
+ const MARKER_PREFIX = "<!-- harness-docs:";
21
+ const MARKER_SUFFIX = " -->";
22
+
23
+ export interface DocProvenance {
24
+ /** Session id that produced the doc. */
25
+ sessionId: string;
26
+ /** ISO timestamp the doc was generated. */
27
+ generatedAt: string;
28
+ /** sha256 of the doc body after the marker line (excludes the marker itself). */
29
+ contentHash: string;
30
+ }
31
+
32
+ /** Render a provenance marker line. Always single-line; no trailing newline. */
33
+ export function renderProvenanceMarker(provenance: DocProvenance): string {
34
+ // Each value passes through `encode` so an accidental quote/space cannot break parsing.
35
+ const session = encodeValue(provenance.sessionId);
36
+ const generated = encodeValue(provenance.generatedAt);
37
+ const hash = encodeValue(provenance.contentHash);
38
+ return `${MARKER_PREFIX}session=${session} generated=${generated} contentHash=${hash}${MARKER_SUFFIX}`;
39
+ }
40
+
41
+ /**
42
+ * Parse a markdown doc and return its provenance + the body after the marker. Returns
43
+ * `null` when the first line is not a recognizable marker.
44
+ *
45
+ * Tolerant: the marker MUST be on the first line. A doc without a first-line marker is
46
+ * always treated as user-authored.
47
+ */
48
+ export function parseProvenance(markdown: string): { provenance: DocProvenance; body: string } | null {
49
+ const newlineIndex = markdown.indexOf("\n");
50
+ const firstLine = newlineIndex >= 0 ? markdown.slice(0, newlineIndex) : markdown;
51
+ if (!firstLine.startsWith(MARKER_PREFIX) || !firstLine.endsWith(MARKER_SUFFIX)) {
52
+ return null;
53
+ }
54
+
55
+ const inner = firstLine.slice(MARKER_PREFIX.length, firstLine.length - MARKER_SUFFIX.length).trim();
56
+ const fields = parseFields(inner);
57
+
58
+ const sessionId = fields.get("session");
59
+ const generatedAt = fields.get("generated");
60
+ const contentHash = fields.get("contentHash");
61
+ if (!sessionId || !generatedAt || !contentHash) return null;
62
+
63
+ const body = newlineIndex >= 0 ? markdown.slice(newlineIndex + 1) : "";
64
+ return {
65
+ provenance: { sessionId, generatedAt, contentHash },
66
+ body,
67
+ };
68
+ }
69
+
70
+ /** Compute the content hash for a body (i.e., everything after the marker line). */
71
+ export function computeBodyContentHash(body: string): string {
72
+ return sha256(body);
73
+ }
74
+
75
+ /**
76
+ * Wrap a body with a fresh provenance marker. Convenience for renderers. Returns the full
77
+ * doc string starting with the marker line.
78
+ */
79
+ export function attachProvenance(body: string, provenance: DocProvenance): string {
80
+ return `${renderProvenanceMarker(provenance)}\n${body}`;
81
+ }
82
+
83
+ /**
84
+ * Detect whether a stored doc was hand-edited after the harness produced it. Returns:
85
+ * - `"unmarked"` when there is no marker (treat as user-authored — never overwrite blindly).
86
+ * - `"edited"` when the marker exists but the body hash does not match.
87
+ * - `"intact"` when marker + body hash agree (safe to regen using the marker's sourceHash).
88
+ */
89
+ export function detectUserEdit(markdown: string): "unmarked" | "edited" | "intact" {
90
+ const parsed = parseProvenance(markdown);
91
+ if (!parsed) return "unmarked";
92
+ const actual = computeBodyContentHash(parsed.body);
93
+ return actual === parsed.provenance.contentHash ? "intact" : "edited";
94
+ }
95
+
96
+ // ── Internals ───────────────────────────────────────────────────────────────
97
+
98
+ /** Parse the inner part of a marker into a Map of key/value pairs. */
99
+ function parseFields(inner: string): Map<string, string> {
100
+ const out = new Map<string, string>();
101
+ // Tokenize on whitespace. Values must not contain spaces (encodeValue enforces this).
102
+ for (const token of inner.split(/\s+/)) {
103
+ if (!token) continue;
104
+ const eq = token.indexOf("=");
105
+ if (eq <= 0) continue;
106
+ const key = token.slice(0, eq);
107
+ const value = decodeValue(token.slice(eq + 1));
108
+ out.set(key, value);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * Encode a value so the marker remains parseable. Spaces and the literal `-->` sequence
115
+ * are forbidden in the canonical values we accept (session id pattern, ISO timestamps,
116
+ * hex hashes). The encode step is defensive: spaces become `%20`, the marker terminator
117
+ * is escaped to `--&gt;`. Decoding inverts those.
118
+ */
119
+ function encodeValue(value: string): string {
120
+ return value.replace(/-->/g, "--&gt;").replace(/ /g, "%20");
121
+ }
122
+
123
+ function decodeValue(value: string): string {
124
+ return value.replace(/%20/g, " ").replace(/--&gt;/g, "-->");
125
+ }
@@ -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
+ }