@warpgogol/werkstatt-shared 0.15.0 → 0.16.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 (43) hide show
  1. package/package.json +18 -1
  2. package/src/agent/api-catalog.ts +14 -1
  3. package/src/agent/guidance.ts +52 -0
  4. package/src/agent/health.ts +68 -0
  5. package/src/agent/index.ts +5 -1
  6. package/src/agent/manifest.ts +70 -7
  7. package/src/agent/openapi.ts +97 -4
  8. package/src/agent/receipt.ts +123 -0
  9. package/src/agent/search.test.ts +3 -0
  10. package/src/agent/search.ts +25 -4
  11. package/src/agent/tests/api-catalog.test.ts +6 -0
  12. package/src/agent/tests/ard-catalog.test.ts +9 -0
  13. package/src/agent/tests/manifest.test.ts +71 -0
  14. package/src/agent/tests/mcp-card.test.ts +4 -0
  15. package/src/agent/tests/openapi.test.ts +117 -0
  16. package/src/component/index.ts +3 -1
  17. package/src/fingerprint/index.ts +3 -1
  18. package/src/integration/idempotency.ts +88 -0
  19. package/src/integration/index.ts +4 -0
  20. package/src/integration/tests/idempotency.test.ts +81 -0
  21. package/src/kernel/drift-guard.ts +166 -0
  22. package/src/kernel/index.ts +8 -1
  23. package/src/kernel/tests/drift-guard.test.ts +112 -0
  24. package/src/kernel/tests/workpiece-env.test.ts +44 -0
  25. package/src/kernel/types.ts +63 -4
  26. package/src/kernel/workpiece-env.ts +48 -0
  27. package/src/middleware/access-protection.ts +3 -0
  28. package/src/middleware/language-redirect.ts +3 -0
  29. package/src/observability/index.ts +8 -0
  30. package/src/observability/pipeline-log.ts +81 -0
  31. package/src/ontology/capabilities/lead.prepare.yaml +44 -0
  32. package/src/ontology/capabilities/lead.submit.yaml +20 -3
  33. package/src/ontology/operations/index.ts +6 -0
  34. package/src/ontology/operations/notausgang.ts +44 -0
  35. package/src/ontology/schemas/capability.ts +55 -3
  36. package/src/passport/claim-sign.ts +120 -0
  37. package/src/passport/index.ts +3 -0
  38. package/src/plugin/plugin-contract.ts +1 -1
  39. package/src/semantic/canonical-uri.ts +28 -8
  40. package/src/semantic/llms.ts +10 -2
  41. package/src/semantic/tests/canonical-uri.test.ts +18 -6
  42. package/src/signing/index.ts +3 -1
  43. package/src/stack/run-tool.ts +2 -4
@@ -0,0 +1,81 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>RFC-1112: verify the Upstash Redis REST receipt store. get parses the stored
4
+ { receipt, bodyHash } record or returns null on a miss; put issues SET NX EX with the
5
+ receipt JSON and the configured key prefix. Both throw on transport errors so the
6
+ caller can decide fail-open semantics. fetch is stubbed — no network.</purpose>
7
+ <responsibilities>
8
+ <item>get → null on miss, parsed record on hit, correct key prefix.</item>
9
+ <item>put → SET key JSON NX EX ttl command shape.</item>
10
+ <item>transport errors propagate (throw).</item>
11
+ </responsibilities>
12
+ <non-goals><item>No network — fetch stubbed.</item></non-goals>
13
+ </MODULE_CONTRACT>
14
+ <CHANGE_SUMMARY><item>RFC-1112: initial receipt store test.</item></CHANGE_SUMMARY>
15
+ */
16
+
17
+ import { test, expect } from "vitest";
18
+ import { restRedisReceiptStore, type ReceiptStoreRecord } from "../idempotency.ts";
19
+ import type { ActionReceipt } from "@warpgogol/werkstatt-shared/agent";
20
+
21
+ const RECEIPT: ActionReceipt = {
22
+ receiptId: "11111111-1111-1111-1111-111111111111",
23
+ status: "accepted",
24
+ duplicate: false,
25
+ submittedAt: "2026-07-05T00:00:00.000Z",
26
+ };
27
+
28
+ const CONFIG = { url: "https://eu-redis.upstash.io", token: "redis-secret" };
29
+
30
+ function stubFetch(status: number, result: unknown) {
31
+ return async () => new Response(JSON.stringify({ result }), { status });
32
+ }
33
+
34
+ test("restRedisReceiptStore.get returns null on a miss", async () => {
35
+ const store = restRedisReceiptStore(CONFIG, stubFetch(200, null) as unknown as typeof fetch);
36
+ expect(await store.get("key-1")).toBeNull();
37
+ });
38
+
39
+ test("restRedisReceiptStore.get parses the stored record", async () => {
40
+ const record: ReceiptStoreRecord = { receipt: RECEIPT, bodyHash: "abc123" };
41
+ const store = restRedisReceiptStore(
42
+ CONFIG,
43
+ stubFetch(200, JSON.stringify(record)) as unknown as typeof fetch,
44
+ );
45
+ const got = await store.get("key-1");
46
+ expect(got).toEqual(record);
47
+ });
48
+
49
+ test("restRedisReceiptStore.get uses the configured key prefix", async () => {
50
+ let capturedBody = "";
51
+ const fetchSpy = (async (_url: unknown, init?: { body?: string }) => {
52
+ capturedBody = init?.body ?? "";
53
+ return new Response(JSON.stringify({ result: null }), { status: 200 });
54
+ }) as unknown as typeof fetch;
55
+ const store = restRedisReceiptStore({ ...CONFIG, prefix: "custom-prefix" }, fetchSpy);
56
+ await store.get("key-9");
57
+ expect(capturedBody).toBe(JSON.stringify(["GET", "custom-prefix:key-9"]));
58
+ });
59
+
60
+ test("restRedisReceiptStore.put issues SET key JSON NX EX ttl", async () => {
61
+ let capturedBody = "";
62
+ const fetchSpy = (async (_url: unknown, init?: { body?: string }) => {
63
+ capturedBody = init?.body ?? "";
64
+ return new Response(JSON.stringify({ result: "OK" }), { status: 200 });
65
+ }) as unknown as typeof fetch;
66
+ const store = restRedisReceiptStore(CONFIG, fetchSpy);
67
+ await store.put("key-1", "hash-1", RECEIPT, 86400);
68
+ const command = JSON.parse(capturedBody) as string[];
69
+ expect(command[0]).toBe("SET");
70
+ expect(command[1]).toBe("gogol-agent-idem:key-1");
71
+ const record = JSON.parse(command[2]!) as ReceiptStoreRecord;
72
+ expect(record.bodyHash).toBe("hash-1");
73
+ expect(record.receipt.receiptId).toBe(RECEIPT.receiptId);
74
+ expect(command.slice(3)).toEqual(["NX", "EX", "86400"]);
75
+ });
76
+
77
+ test("restRedisReceiptStore throws on transport errors", async () => {
78
+ const store = restRedisReceiptStore(CONFIG, stubFetch(500, null) as unknown as typeof fetch);
79
+ await expect(() => store.get("key-1")).rejects.toThrow();
80
+ await expect(() => store.put("key-1", "h", RECEIPT, 60)).rejects.toThrow();
81
+ });
@@ -0,0 +1,166 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1126: pre-overwrite drift guard. Generators that rewrite git-tracked files
5
+ call guardTrackedOverwrite before writing; when the on-disk content carries
6
+ manual edits that the new content would destroy, the guard warns (default) or
7
+ throws (--strict) listing the exact keys/sections that would be lost.
8
+ </purpose>
9
+ <non-goals>
10
+ <item>Does not perform the write itself — callers write via context.io / writeFileIfChanged after the guard returns.</item>
11
+ <item>Does not decide marker-protocol skips — callers run hasGeneratedMarker checks first; the guard only sees files about to be written.</item>
12
+ <item>Does not guard non-tracked files — the caller decides which paths are git-tracked generated outputs.</item>
13
+ </non-goals>
14
+ </MODULE_CONTRACT>
15
+ <KEY_DECISIONS>
16
+ <item>Warn-by-default: an unattended pipeline must not break on pre-existing drift; --strict is the sole fail-hard opt-in (never --force, which keeps DNA-93 cache-clearing semantics).</item>
17
+ <item>The guard is a pre-write side effect, never a cached result — command-result caching (RFC-0390) must not cache it.</item>
18
+ </KEY_DECISIONS>
19
+ <CHANGE_SUMMARY>
20
+ <item>RFC-1126: initial implementation — DriftReport, DriftGuardOptions, guardTrackedOverwrite with json/yaml/text key extraction.</item>
21
+ <item>RFC-1126: step 1 — shared contracts (drift guard + workpiece env)
22
+
23
+ Add guardTrackedOverwrite (json/yaml/text lost-key diff, warn-by-default, --strict fail-hard) and loadWorkpieceEnv (dotenv.parse wrapper) to werkstatt-shared kernel subpath; extend KernelRuntimeContext with optional workpieceEnv field.</item>
24
+ </CHANGE_SUMMARY>
25
+ */
26
+
27
+ import { readFile } from "node:fs/promises";
28
+ import { parse as yamlParse } from "yaml";
29
+
30
+ export interface DriftReport {
31
+ file: string;
32
+ /** Keys/sections present on disk but absent or changed in the new content (dotted paths). */
33
+ wouldLose: string[];
34
+ /** True when on-disk content already equals new content — caller may skip the write. */
35
+ identical: boolean;
36
+ }
37
+
38
+ export interface DriftGuardOptions {
39
+ format: "json" | "yaml" | "text";
40
+ /** Fail-hard instead of warning. */
41
+ strict?: boolean;
42
+ /** Remediation hint appended to the warning/error message. */
43
+ fixHint?: string;
44
+ }
45
+
46
+ export interface DriftGuardLogger {
47
+ warn(msg: string): void;
48
+ }
49
+
50
+ const MAX_REPORTED_KEYS = 20;
51
+
52
+ /** Collect dotted paths present in `oldValue` but absent or changed in `newValue`. */
53
+ function collectLostKeys(
54
+ oldValue: unknown,
55
+ newValue: unknown,
56
+ prefix: string,
57
+ out: string[],
58
+ ): void {
59
+ if (out.length >= MAX_REPORTED_KEYS) return;
60
+ if (oldValue === null || typeof oldValue !== "object") {
61
+ if (oldValue !== newValue) out.push(prefix);
62
+ return;
63
+ }
64
+ if (Array.isArray(oldValue)) {
65
+ if (!Array.isArray(newValue)) {
66
+ out.push(prefix);
67
+ return;
68
+ }
69
+ for (let i = 0; i < oldValue.length; i++) {
70
+ if (i >= newValue.length) {
71
+ out.push(`${prefix}[${i}]`);
72
+ } else {
73
+ collectLostKeys(oldValue[i], newValue[i], `${prefix}[${i}]`, out);
74
+ }
75
+ if (out.length >= MAX_REPORTED_KEYS) return;
76
+ }
77
+ return;
78
+ }
79
+ const oldObj = oldValue as Record<string, unknown>;
80
+ const newObj =
81
+ newValue !== null && typeof newValue === "object" && !Array.isArray(newValue)
82
+ ? (newValue as Record<string, unknown>)
83
+ : {};
84
+ for (const key of Object.keys(oldObj)) {
85
+ const path = prefix ? `${prefix}.${key}` : key;
86
+ if (!(key in newObj)) {
87
+ out.push(path);
88
+ } else {
89
+ collectLostKeys(oldObj[key], newObj[key], path, out);
90
+ }
91
+ if (out.length >= MAX_REPORTED_KEYS) return;
92
+ }
93
+ }
94
+
95
+ function diffStructured(format: "json" | "yaml", existing: string, next: string): string[] {
96
+ try {
97
+ const oldValue: unknown = format === "json" ? JSON.parse(existing) : yamlParse(existing);
98
+ const newValue: unknown = format === "json" ? JSON.parse(next) : yamlParse(next);
99
+ const out: string[] = [];
100
+ collectLostKeys(oldValue, newValue, "", out);
101
+ return out;
102
+ } catch {
103
+ // Unparseable existing content — fall back to line diff so drift is still visible.
104
+ return diffText(existing, next);
105
+ }
106
+ }
107
+
108
+ function diffText(existing: string, next: string): string[] {
109
+ const nextLines = new Set(next.split("\n").map((l) => l.trim()));
110
+ const lost: string[] = [];
111
+ for (const line of existing.split("\n")) {
112
+ const trimmed = line.trim();
113
+ if (trimmed && !nextLines.has(trimmed)) {
114
+ lost.push(trimmed.length > 80 ? `${trimmed.slice(0, 77)}…` : trimmed);
115
+ if (lost.length >= MAX_REPORTED_KEYS) break;
116
+ }
117
+ }
118
+ return lost;
119
+ }
120
+
121
+ /**
122
+ * Compare newContent against the on-disk file before overwriting. Returns a
123
+ * DriftReport; warns via logger (or throws when options.strict) listing the
124
+ * keys that would be destroyed. Never throws for a missing file — nothing to
125
+ * lose. Callers should skip the write entirely when report.identical is true.
126
+ */
127
+ export async function guardTrackedOverwrite(
128
+ filePath: string,
129
+ newContent: string,
130
+ options: DriftGuardOptions,
131
+ logger: DriftGuardLogger,
132
+ ): Promise<DriftReport> {
133
+ let existing: string | null = null;
134
+ try {
135
+ existing = await readFile(filePath, "utf-8");
136
+ } catch {
137
+ // File absent — nothing to lose.
138
+ }
139
+
140
+ if (existing === null) {
141
+ return { file: filePath, wouldLose: [], identical: false };
142
+ }
143
+ if (existing === newContent) {
144
+ return { file: filePath, wouldLose: [], identical: true };
145
+ }
146
+
147
+ const wouldLose =
148
+ options.format === "text"
149
+ ? diffText(existing, newContent)
150
+ : diffStructured(options.format, existing, newContent);
151
+
152
+ if (wouldLose.length === 0) {
153
+ return { file: filePath, wouldLose, identical: false };
154
+ }
155
+
156
+ const hint = options.fixHint ? ` ${options.fixHint}` : "";
157
+ const message =
158
+ `DRIFT-GUARD-01: ${filePath} — manual edits would be destroyed: ` +
159
+ `${wouldLose.join(", ")}.${hint}`;
160
+
161
+ if (options.strict) {
162
+ throw new Error(message);
163
+ }
164
+ logger.warn(message);
165
+ return { file: filePath, wouldLose, identical: false };
166
+ }
@@ -1,10 +1,15 @@
1
1
  /*
2
2
  <MODULE_CONTRACT>
3
3
  <purpose>Barrel export for @warpgogol/werkstatt-shared/kernel sub-path. Re-exports the kernel contract cluster (command/pipeline types, workspace IO, atomic fs, desired state, diagnostic schemas) sunk from @warpgogol/werkstatt-engine per RFC-1104.</purpose>
4
- <non-goals>Does not re-export engine runtime modules (executor, module registry, CLI).</non-goals>
4
+ <non-goals>
5
+ <item>Does not re-export engine runtime modules (executor, module registry, CLI).</item>
6
+ </non-goals>
5
7
  </MODULE_CONTRACT>
6
8
  <CHANGE_SUMMARY>
7
9
  <item>RFC-1104: initial barrel — kernel contract cluster sunk from werkstatt-engine.</item>
10
+ <item>RFC-1126: step 1 — shared contracts (drift guard + workpiece env)
11
+
12
+ Add guardTrackedOverwrite (json/yaml/text lost-key diff, warn-by-default, --strict fail-hard) and loadWorkpieceEnv (dotenv.parse wrapper) to werkstatt-shared kernel subpath; extend KernelRuntimeContext with optional workpieceEnv field.</item>
8
13
  </CHANGE_SUMMARY>
9
14
  */
10
15
 
@@ -13,3 +18,5 @@ export * from "./workspace-io.ts";
13
18
  export * from "./fs-atomic.ts";
14
19
  export * from "./desired-state.ts";
15
20
  export * from "./diagnostic.ts";
21
+ export * from "./drift-guard.ts";
22
+ export * from "./workpiece-env.ts";
@@ -0,0 +1,112 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { guardTrackedOverwrite } from "../drift-guard.ts";
6
+
7
+ describe("guardTrackedOverwrite (RFC-1126)", () => {
8
+ let dir: string;
9
+ const logger = { warn: vi.fn() };
10
+
11
+ beforeEach(() => {
12
+ dir = mkdtempSync(join(tmpdir(), "drift-guard-"));
13
+ logger.warn.mockClear();
14
+ });
15
+
16
+ afterEach(() => {
17
+ rmSync(dir, { recursive: true, force: true });
18
+ });
19
+
20
+ it("reports nothing to lose when the file does not exist", async () => {
21
+ const report = await guardTrackedOverwrite(
22
+ join(dir, "missing.json"),
23
+ "{}",
24
+ { format: "json" },
25
+ logger,
26
+ );
27
+ expect(report.wouldLose).toEqual([]);
28
+ expect(report.identical).toBe(false);
29
+ expect(logger.warn).not.toHaveBeenCalled();
30
+ });
31
+
32
+ it("reports identical when content matches", async () => {
33
+ const file = join(dir, "same.json");
34
+ writeFileSync(file, '{"a":1}\n');
35
+ const report = await guardTrackedOverwrite(file, '{"a":1}\n', { format: "json" }, logger);
36
+ expect(report.identical).toBe(true);
37
+ expect(logger.warn).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it("warns with the lost key path for JSON drift", async () => {
41
+ const file = join(dir, "package.json");
42
+ writeFileSync(file, JSON.stringify({ dependencies: { "extra-dep": "^1.0.0" } }));
43
+ const report = await guardTrackedOverwrite(
44
+ file,
45
+ JSON.stringify({ dependencies: {} }),
46
+ { format: "json", fixHint: "edit the manifest" },
47
+ logger,
48
+ );
49
+ expect(report.wouldLose).toContain("dependencies.extra-dep");
50
+ expect(logger.warn).toHaveBeenCalledOnce();
51
+ expect(logger.warn.mock.calls[0]![0]).toContain("DRIFT-GUARD-01");
52
+ expect(logger.warn.mock.calls[0]![0]).toContain("dependencies.extra-dep");
53
+ expect(logger.warn.mock.calls[0]![0]).toContain("edit the manifest");
54
+ });
55
+
56
+ it("detects changed scalar values as lost edits", async () => {
57
+ const file = join(dir, "cfg.json");
58
+ writeFileSync(file, JSON.stringify({ version: "2.0.0" }));
59
+ const report = await guardTrackedOverwrite(
60
+ file,
61
+ JSON.stringify({ version: "1.0.0" }),
62
+ { format: "json" },
63
+ logger,
64
+ );
65
+ expect(report.wouldLose).toContain("version");
66
+ });
67
+
68
+ it("warns for YAML drift", async () => {
69
+ const file = join(dir, "config.yaml");
70
+ writeFileSync(file, "keep: yes\ndrop: gone\n");
71
+ const report = await guardTrackedOverwrite(file, "keep: yes\n", { format: "yaml" }, logger);
72
+ expect(report.wouldLose).toContain("drop");
73
+ expect(logger.warn).toHaveBeenCalledOnce();
74
+ });
75
+
76
+ it("warns for text drift listing removed lines", async () => {
77
+ const file = join(dir, "list.txt");
78
+ writeFileSync(file, "alpha\nbeta\ngamma\n");
79
+ const report = await guardTrackedOverwrite(file, "alpha\n", { format: "text" }, logger);
80
+ expect(report.wouldLose).toEqual(expect.arrayContaining(["beta", "gamma"]));
81
+ });
82
+
83
+ it("does not warn when new content only adds keys", async () => {
84
+ const file = join(dir, "grow.json");
85
+ writeFileSync(file, JSON.stringify({ a: 1 }));
86
+ const report = await guardTrackedOverwrite(
87
+ file,
88
+ JSON.stringify({ a: 1, b: 2 }),
89
+ { format: "json" },
90
+ logger,
91
+ );
92
+ expect(report.wouldLose).toEqual([]);
93
+ expect(report.identical).toBe(false);
94
+ expect(logger.warn).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("throws under strict instead of warning", async () => {
98
+ const file = join(dir, "strict.json");
99
+ writeFileSync(file, JSON.stringify({ manual: true }));
100
+ await expect(
101
+ guardTrackedOverwrite(file, "{}", { format: "json", strict: true }, logger),
102
+ ).rejects.toThrow("DRIFT-GUARD-01");
103
+ expect(logger.warn).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("falls back to line diff when existing JSON is unparseable", async () => {
107
+ const file = join(dir, "broken.json");
108
+ writeFileSync(file, "{ not json, custom-line\n");
109
+ const report = await guardTrackedOverwrite(file, "{}", { format: "json" }, logger);
110
+ expect(report.wouldLose.length).toBeGreaterThan(0);
111
+ });
112
+ });
@@ -0,0 +1,44 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { EMPTY_WORKPIECE_ENV, loadWorkpieceEnv } from "../workpiece-env.ts";
6
+
7
+ describe("loadWorkpieceEnv (RFC-1126)", () => {
8
+ let dir: string;
9
+
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "workpiece-env-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(dir, { recursive: true, force: true });
16
+ });
17
+
18
+ it("returns the shared empty map when siteDirectory is undefined", async () => {
19
+ expect(await loadWorkpieceEnv(undefined)).toBe(EMPTY_WORKPIECE_ENV);
20
+ });
21
+
22
+ it("returns an empty map when .env is missing", async () => {
23
+ const env = await loadWorkpieceEnv(dir);
24
+ expect(env.size).toBe(0);
25
+ });
26
+
27
+ it("parses KEY=VALUE pairs including quoted values", async () => {
28
+ writeFileSync(
29
+ join(dir, ".env"),
30
+ 'PUBLIC_IMAGE_PROVIDER=build-portable\nQUOTED="with space"\n# comment\n\n',
31
+ );
32
+ const env = await loadWorkpieceEnv(dir);
33
+ expect(env.get("PUBLIC_IMAGE_PROVIDER")).toBe("build-portable");
34
+ expect(env.get("QUOTED")).toBe("with space");
35
+ expect(env.has("# comment")).toBe(false);
36
+ });
37
+
38
+ it("does not mutate process.env", async () => {
39
+ writeFileSync(join(dir, ".env"), "WORKPIECE_ONLY_KEY=from-file\n");
40
+ delete process.env["WORKPIECE_ONLY_KEY"];
41
+ await loadWorkpieceEnv(dir);
42
+ expect(process.env["WORKPIECE_ONLY_KEY"]).toBeUndefined();
43
+ });
44
+ });
@@ -11,16 +11,23 @@
11
11
  <item>Kernel command contracts must stay explicit so agents cannot pass untyped command inputs.</item>
12
12
  </KEY_DECISIONS>
13
13
  <CHANGE_SUMMARY>
14
- <item>RFC-1027: add RemediationHint interface and optional remediationHints field to KernelExecutionReport for agent-actionable fix suggestions.</item>
15
- <item>RFC-1028: add moduleBasePath to KernelRegisteredCommandInfo, derived from modulePath (RFC-0960) for dynamic moduleSrcDir resolution in the pipeline executor.</item>
16
- <item>RFC-1038: replace KernelModule with ModuleExport, KernelCommandDefinition with CommandDeclaration, KernelRuntimeContext.registry with actualState, KernelAppConfig uses ModuleExport.</item>
17
14
  <item>RFC-1097: step 6 — compass.migrate codemod run
18
15
 
19
16
  Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.</item>
20
17
  <item>RFC-1097: sweep — werkstatt-engine clean
21
18
 
22
19
  Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.</item>
23
- <history>RFC-0260, RFC-0267, RFC-0326, RFC-0390, RFC-0518, RFC-0579, RFC-0686, RFC-0960, RFC-1026</history>
20
+ <item>RFC-1126: step 1 — shared contracts (drift guard + workpiece env)
21
+
22
+ Add guardTrackedOverwrite (json/yaml/text lost-key diff, warn-by-default, --strict fail-hard) and loadWorkpieceEnv (dotenv.parse wrapper) to werkstatt-shared kernel subpath; extend KernelRuntimeContext with optional workpieceEnv field.</item>
23
+ <item>RFC-1130: qa.independent.run cacheable + cacheBypassFlags
24
+
25
+ Steps 1-4: cacheBypassFlags field on KernelCommandDefinition, executor bypass at both cache call sites (hasCacheBypassFlag), qa.independent.run flipped to cacheable with narrowed reads + modulePaths, 14 new contract tests.</item>
26
+ <item>RFC-1133: cache direct executeKernelCommand executions with flag-keyed results</item>
27
+ <item>ADR-0089: kernel and shared code must not depend on ambient process state
28
+
29
+ KernelRuntimeContext is the explicit-context carrier (workspaceRoot, site, workpieceEnv) — command handlers and shared code must not fall back to process.cwd() or process.env.NODE_ENV for correctness; callers pass roots and modes explicitly.</item>
30
+ <history>RFC-0260, RFC-0267, RFC-0326, RFC-0390, RFC-0518, RFC-0579, RFC-0686, RFC-0960, RFC-1026, RFC-1027, RFC-1028, RFC-1038</history>
24
31
  </CHANGE_SUMMARY>
25
32
  */
26
33
 
@@ -94,6 +101,12 @@ export interface KernelCommandMetadata {
94
101
  * command.reads.validate enforces this. Defaults to true.
95
102
  */
96
103
  cacheable?: boolean;
104
+ /**
105
+ * RFC-1130: flag names that bypass the result cache when present in argv
106
+ * (`--name` or `--name=value` forms). Both the cache read and the cache
107
+ * write are skipped — for flags that reduce coverage (e.g. `--rfc`).
108
+ */
109
+ cacheBypassFlags?: string[];
97
110
  /**
98
111
  * RFC-0518: declarative gate metadata. Optional. When present, describes the
99
112
  * gate's severity, phase, conditional logic, surfaces protected, rules enforced,
@@ -275,6 +288,52 @@ export interface KernelRuntimeContext {
275
288
  * plugin. Validators consume this instead of a static constant.
276
289
  */
277
290
  ownershipMap?: GeneratorOwnershipEntry[];
291
+ /**
292
+ * RFC-1126: parsed `.env` of the resolved site workspace (mission workpiece
293
+ * for mission.* commands, cache clone otherwise). Populated by the executor
294
+ * from `context.site.directory/.env` via dotenv.parse; absent or empty when
295
+ * no site is resolved or no .env exists. Fallback only — `process.env`
296
+ * always wins: `process.env["KEY"] ?? context.workpieceEnv?.get("KEY")`.
297
+ */
298
+ workpieceEnv?: ReadonlyMap<string, string>;
299
+ /**
300
+ * RFC-1133: per-invocation command-result cache carrier. Pipeline executors
301
+ * populate it with the shared layer plus `treeIndex`/`moduleHashCache`
302
+ * amortization; `executeKernelCommand` sets `{ layer }` once per invocation.
303
+ * When absent, `executeRegisteredCommand` lazily opens a layer for that call.
304
+ */
305
+ resultCache?: KernelResultCacheContext;
306
+ }
307
+
308
+ /**
309
+ * RFC-1133: narrow structural port over the engine's `CacheLayer`.
310
+ * werkstatt-shared cannot import engine types (SHARED-04), so the carrier is
311
+ * declared here and satisfied structurally — `CacheLayer` needs no explicit
312
+ * `implements`.
313
+ */
314
+ export interface KernelResultCacheStore {
315
+ readonly available: boolean;
316
+ get(namespace: string, key: string): Promise<{ data: unknown } | null>;
317
+ set(
318
+ namespace: string,
319
+ key: string,
320
+ data: unknown,
321
+ mtime: number,
322
+ contentHash: string,
323
+ ): Promise<void>;
324
+ close(): Promise<void>;
325
+ }
326
+
327
+ /**
328
+ * RFC-1133: cache structures shared across one pipeline run or one direct
329
+ * `executeKernelCommand` invocation.
330
+ */
331
+ export interface KernelResultCacheContext {
332
+ layer: KernelResultCacheStore;
333
+ /** Pipeline-only: workspace tree index enabling the mtime fast path (RFC-0685). */
334
+ treeIndex?: Map<string, { mtimeMs: number; size: number }>;
335
+ /** Pipeline-only: per-run module hash memo (RFC-0637). */
336
+ moduleHashCache?: Map<string, string>;
278
337
  }
279
338
 
280
339
  /**
@@ -0,0 +1,48 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1126: lazy .env loader for the resolved site workspace. The kernel CLI loads
5
+ dotenv from process.cwd() (the workshop root) only — a workpiece .env is invisible
6
+ to pipeline commands unless each command implements its own fallback (the m000075
7
+ readWorkpieceEnv pattern). This module generalizes that fallback once: the executor
8
+ populates KernelRuntimeContext.workpieceEnv from <context.site.directory>/.env.
9
+ </purpose>
10
+ <non-goals>
11
+ <item>Does not override process.env — real environment always wins; this map is a fallback only.</item>
12
+ <item>Does not implement a general .env hierarchy (shell profiles, CI secrets) — only the resolved-site .env.</item>
13
+ </non-goals>
14
+ </MODULE_CONTRACT>
15
+ <KEY_DECISIONS>
16
+ <item>dotenv.parse is reused (already a kernel dependency) instead of a hand-rolled regex parser.</item>
17
+ <item>Node-only module (node:fs) — kernel-subpath export only, never re-exported from client-reachable barrels.</item>
18
+ </KEY_DECISIONS>
19
+ <CHANGE_SUMMARY>
20
+ <item>RFC-1126: initial implementation — loadWorkpieceEnv + EMPTY_WORKPIECE_ENV.</item>
21
+ <item>RFC-1126: step 1 — shared contracts (drift guard + workpiece env)
22
+
23
+ Add guardTrackedOverwrite (json/yaml/text lost-key diff, warn-by-default, --strict fail-hard) and loadWorkpieceEnv (dotenv.parse wrapper) to werkstatt-shared kernel subpath; extend KernelRuntimeContext with optional workpieceEnv field.</item>
24
+ </CHANGE_SUMMARY>
25
+ */
26
+
27
+ import { readFile } from "node:fs/promises";
28
+ import { join } from "node:path";
29
+ import { parse as dotenvParse } from "dotenv";
30
+
31
+ /** Shared empty map for contexts without a resolved site or .env file. */
32
+ export const EMPTY_WORKPIECE_ENV: ReadonlyMap<string, string> = new Map();
33
+
34
+ /**
35
+ * Parse `<siteDirectory>/.env` into a ReadonlyMap. Returns an empty map when
36
+ * the site is undefined or the file is missing/unreadable — never throws.
37
+ */
38
+ export async function loadWorkpieceEnv(
39
+ siteDirectory: string | undefined,
40
+ ): Promise<ReadonlyMap<string, string>> {
41
+ if (!siteDirectory) return EMPTY_WORKPIECE_ENV;
42
+ try {
43
+ const raw = await readFile(join(siteDirectory, ".env"), "utf-8");
44
+ return new Map(Object.entries(dotenvParse(raw)));
45
+ } catch {
46
+ return EMPTY_WORKPIECE_ENV;
47
+ }
48
+ }
@@ -9,6 +9,9 @@
9
9
  <item>Do not use Node.js-specific APIs (Buffer, crypto.timingSafeEqual) — runs in Cloudflare Workers runtime.</item>
10
10
  </non-goals>
11
11
  </MODULE_CONTRACT>
12
+ <KEY_DECISIONS>
13
+ <item>PIN check runs in the Workers runtime — Web Crypto only, no Node APIs.</item>
14
+ </KEY_DECISIONS>
12
15
  <CHANGE_SUMMARY>
13
16
  <item>RFC-0899: Initial access protection middleware for dev/alt subdomains.</item>
14
17
  <item>RFC-1097: step 6 — compass.migrate codemod run
@@ -6,6 +6,9 @@
6
6
  </non-goals>
7
7
  <!-- @ai-invariant Generated factory output (RFC-0055) flows through system.md i18n config; do not hand-edit app copies. -->
8
8
  </MODULE_CONTRACT>
9
+ <KEY_DECISIONS>
10
+ <item>Redirect target derives from system.md i18n config — no hardcoded locale list.</item>
11
+ </KEY_DECISIONS>
9
12
  <CHANGE_SUMMARY>
10
13
  <item>RFC-0133: backfilled MODULE_MAP and CHANGE_SUMMARY markers for compass.validate compliance.</item>
11
14
  </CHANGE_SUMMARY>
@@ -50,6 +50,14 @@ export {
50
50
  type UnixNanoString,
51
51
  } from "./otlp-json.ts";
52
52
 
53
+ export {
54
+ emitPipelineLogEvent,
55
+ getPipelineLogEvents,
56
+ type PipelineLogEvent,
57
+ type PipelineLogKind,
58
+ type PipelineLogSeverity,
59
+ } from "./pipeline-log.ts";
60
+
53
61
  export { createMetricsPusher, type MetricsPusher, type MetricsPusherEnv } from "./pusher.ts";
54
62
 
55
63
  export { redactUrl } from "./redact.ts";