@crewhaus/spec-patch 0.1.8 → 0.2.1

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/dist/index.d.ts CHANGED
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { CrewhausError } from "@crewhaus/errors";
34
34
  import { type Spec } from "@crewhaus/spec";
35
+ export { REDACTED_VALUE, isCredentialKey, maskCredentialTokens } from "./redact";
35
36
  export declare class SpecPatchError extends CrewhausError {
36
37
  readonly name = "SpecPatchError";
37
38
  constructor(message: string, cause?: unknown);
@@ -104,3 +105,60 @@ export declare function formatWriteBackHeader(opts: {
104
105
  readonly iterations: number;
105
106
  readonly timestamp?: string;
106
107
  }): string;
108
+ /**
109
+ * Item 46 — the fields `formatWriteBackHeader` stamped into a written-back
110
+ * spec's leading comment block, recovered by `parseWriteBackHeader`. Only
111
+ * `runId` is guaranteed (it lives on the stamp's first line); the rest are
112
+ * best-effort so a hand-trimmed header still yields its surviving fields.
113
+ */
114
+ export type WriteBackHeaderInfo = {
115
+ readonly runId: string;
116
+ readonly mutator?: string;
117
+ readonly iterations?: number;
118
+ readonly scoreBefore?: number;
119
+ readonly scoreAfter?: number;
120
+ readonly generated?: string;
121
+ };
122
+ /**
123
+ * Inverse of `formatWriteBackHeader`: scan the LEADING comment block of a
124
+ * YAML text for the optimize write-back stamp and return its fields, or
125
+ * `undefined` when the text was never written back. Only the leading block
126
+ * is scanned — a `# crewhaus optimize:` line further down (e.g. quoted
127
+ * inside an instructions prompt) is document content, not a header.
128
+ */
129
+ export declare function parseWriteBackHeader(yamlText: string): WriteBackHeaderInfo | undefined;
130
+ /**
131
+ * Item 46 — one field-level difference between two spec versions (see
132
+ * `diffSpecYaml`). `path` is dot-joined property keys with array indices
133
+ * rendered as `[i]` (e.g. `steps[2].prompt`). `before`/`after` carry the
134
+ * FORMATTED (truncated, credential-REDACTED — see `renderDiffValue`) values,
135
+ * ready for a changelog line — the differ is a reporting primitive, not a
136
+ * patch source.
137
+ */
138
+ export type SpecDiffEntry = {
139
+ readonly kind: "added" | "removed" | "changed";
140
+ readonly path: string;
141
+ /** Formatted old value — absent for `"added"`. */
142
+ readonly before?: string;
143
+ /** Formatted new value — absent for `"removed"`. */
144
+ readonly after?: string;
145
+ };
146
+ /** Diff values longer than this render truncated with a trailing ellipsis. */
147
+ export declare const DIFF_VALUE_MAX_LENGTH = 72;
148
+ /**
149
+ * Render a value for a diff line: scalars as JSON (strings quoted), maps and
150
+ * sequences as compact JSON, truncated to `maxLength` with an ellipsis so a
151
+ * multi-paragraph instructions prompt doesn't flood the changelog. Pure
152
+ * formatting — credential redaction happens upstream in `renderDiffValue`,
153
+ * which is what the differ itself calls.
154
+ */
155
+ export declare function formatDiffValue(value: unknown, maxLength?: number): string;
156
+ /**
157
+ * Item 46 — structural (field-level) diff between two YAML documents.
158
+ * Parses both texts and walks the value trees: maps diff by key, sequences
159
+ * by index, everything else is a leaf compared by value. Comments and
160
+ * formatting are invisible here BY DESIGN — two texts that parse to the
161
+ * same values produce an empty diff. Throws `SpecPatchError` when either
162
+ * text is unparseable.
163
+ */
164
+ export declare function diffSpecYaml(beforeYaml: string, afterYaml: string): ReadonlyArray<SpecDiffEntry>;
package/dist/index.js CHANGED
@@ -32,8 +32,13 @@
32
32
  */
33
33
  import { CrewhausError } from "@crewhaus/errors";
34
34
  import { parseSpec } from "@crewhaus/spec";
35
- import { parseDocument } from "yaml";
35
+ import { parse, parseDocument } from "yaml";
36
36
  import { z } from "zod";
37
+ import { REDACTED_VALUE, isCredentialKey, maskCredentialTokens } from "./redact";
38
+ // Re-exported so downstream renderers (the CLI changelog, future reporters)
39
+ // can apply the SAME redaction to strings the differ never sees (rationales,
40
+ // free-form provenance) without duplicating the pattern table a third time.
41
+ export { REDACTED_VALUE, isCredentialKey, maskCredentialTokens } from "./redact";
37
42
  export class SpecPatchError extends CrewhausError {
38
43
  name = "SpecPatchError";
39
44
  constructor(message, cause) {
@@ -143,6 +148,10 @@ function formatPath(path) {
143
148
  export const OPTIMIZABLE_PATHS = Object.freeze({
144
149
  cli: Object.freeze([
145
150
  Object.freeze(["agent", "instructions"]),
151
+ // Item 14 — `crewhaus advise`'s truncation-pressure rule patches the
152
+ // per-turn output-token cap when max_tokens truncations recur; safe to
153
+ // autotune (a bigger cap can only trade cost for completeness).
154
+ Object.freeze(["agent", "max_tokens"]),
146
155
  Object.freeze(["failure_taxonomy"]),
147
156
  Object.freeze(["compaction", "threshold"]),
148
157
  // Pillar 2 active context curation — eval-optimizer can flip the
@@ -292,3 +301,212 @@ export function formatWriteBackHeader(opts) {
292
301
  "",
293
302
  ].join("\n");
294
303
  }
304
+ /**
305
+ * Inverse of `formatWriteBackHeader`: scan the LEADING comment block of a
306
+ * YAML text for the optimize write-back stamp and return its fields, or
307
+ * `undefined` when the text was never written back. Only the leading block
308
+ * is scanned — a `# crewhaus optimize:` line further down (e.g. quoted
309
+ * inside an instructions prompt) is document content, not a header.
310
+ */
311
+ export function parseWriteBackHeader(yamlText) {
312
+ let runId;
313
+ let mutator;
314
+ let iterations;
315
+ let scoreBefore;
316
+ let scoreAfter;
317
+ let generated;
318
+ for (const rawLine of yamlText.split("\n")) {
319
+ const line = rawLine.trim();
320
+ if (line === "")
321
+ continue;
322
+ if (!line.startsWith("#"))
323
+ break; // end of the leading comment block
324
+ const stamp = /^# crewhaus optimize: runId (\S+)$/.exec(line);
325
+ if (stamp?.[1] !== undefined) {
326
+ runId = stamp[1];
327
+ continue;
328
+ }
329
+ // Field lines only count once the stamp line has been seen, so ordinary
330
+ // leading comments (`# - mutator: ...` in a user's own notes) don't
331
+ // masquerade as header fields.
332
+ if (runId === undefined)
333
+ continue;
334
+ const field = /^# - ([a-z]+): (.*)$/.exec(line);
335
+ if (field?.[1] === undefined || field[2] === undefined)
336
+ continue;
337
+ const value = field[2].trim();
338
+ if (field[1] === "mutator") {
339
+ mutator = value;
340
+ }
341
+ else if (field[1] === "iterations") {
342
+ const n = Number.parseInt(value, 10);
343
+ if (!Number.isNaN(n))
344
+ iterations = n;
345
+ }
346
+ else if (field[1] === "score") {
347
+ const scores = /^([0-9.eE+-]+) → ([0-9.eE+-]+)/.exec(value);
348
+ const before = scores?.[1] !== undefined ? Number.parseFloat(scores[1]) : Number.NaN;
349
+ const after = scores?.[2] !== undefined ? Number.parseFloat(scores[2]) : Number.NaN;
350
+ if (!Number.isNaN(before))
351
+ scoreBefore = before;
352
+ if (!Number.isNaN(after))
353
+ scoreAfter = after;
354
+ }
355
+ else if (field[1] === "generated") {
356
+ generated = value;
357
+ }
358
+ }
359
+ if (runId === undefined)
360
+ return undefined;
361
+ return {
362
+ runId,
363
+ ...(mutator !== undefined ? { mutator } : {}),
364
+ ...(iterations !== undefined ? { iterations } : {}),
365
+ ...(scoreBefore !== undefined ? { scoreBefore } : {}),
366
+ ...(scoreAfter !== undefined ? { scoreAfter } : {}),
367
+ ...(generated !== undefined ? { generated } : {}),
368
+ };
369
+ }
370
+ /** Diff values longer than this render truncated with a trailing ellipsis. */
371
+ export const DIFF_VALUE_MAX_LENGTH = 72;
372
+ /**
373
+ * Render a value for a diff line: scalars as JSON (strings quoted), maps and
374
+ * sequences as compact JSON, truncated to `maxLength` with an ellipsis so a
375
+ * multi-paragraph instructions prompt doesn't flood the changelog. Pure
376
+ * formatting — credential redaction happens upstream in `renderDiffValue`,
377
+ * which is what the differ itself calls.
378
+ */
379
+ export function formatDiffValue(value, maxLength = DIFF_VALUE_MAX_LENGTH) {
380
+ const rendered = value === undefined ? "undefined" : (JSON.stringify(value) ?? String(value));
381
+ if (rendered.length <= maxLength)
382
+ return rendered;
383
+ return `${rendered.slice(0, Math.max(1, maxLength - 1))}…`;
384
+ }
385
+ /**
386
+ * Item 46 — structural (field-level) diff between two YAML documents.
387
+ * Parses both texts and walks the value trees: maps diff by key, sequences
388
+ * by index, everything else is a leaf compared by value. Comments and
389
+ * formatting are invisible here BY DESIGN — two texts that parse to the
390
+ * same values produce an empty diff. Throws `SpecPatchError` when either
391
+ * text is unparseable.
392
+ */
393
+ export function diffSpecYaml(beforeYaml, afterYaml) {
394
+ let before;
395
+ let after;
396
+ try {
397
+ before = parse(beforeYaml);
398
+ }
399
+ catch (err) {
400
+ throw new SpecPatchError("diff: previous YAML is not parseable", err);
401
+ }
402
+ try {
403
+ after = parse(afterYaml);
404
+ }
405
+ catch (err) {
406
+ throw new SpecPatchError("diff: new YAML is not parseable", err);
407
+ }
408
+ const out = [];
409
+ walkDiff(before, after, "", out, []);
410
+ return out;
411
+ }
412
+ function isPlainObject(v) {
413
+ return typeof v === "object" && v !== null && !Array.isArray(v);
414
+ }
415
+ function leafEqual(a, b) {
416
+ if (a === b)
417
+ return true;
418
+ return JSON.stringify(a) === JSON.stringify(b);
419
+ }
420
+ /**
421
+ * Adversarial-review F1 — render a diff value with credentials removed.
422
+ * `diffSpecYaml`'s output lands verbatim in `.crewhaus/specs/<name>/
423
+ * CHANGELOG.md` on every compile and echoes through `crewhaus spec log`, so
424
+ * the redaction has to happen HERE in the differ, for every consumer:
425
+ *
426
+ * - a value under a credential-carrying key — final or parent segment
427
+ * (`agent.api_key`, `mcp_servers.*.env.TOKEN`, sse `headers.*`) —
428
+ * renders as `[redacted]`, never the value;
429
+ * - every other value has nested credential keys redacted and
430
+ * credential-shaped tokens masked out of its strings (an `sk-…` pasted
431
+ * into `instructions` prose) BEFORE `formatDiffValue` truncates.
432
+ *
433
+ * `keyTrail` is the chain of property keys down to the value; array indices
434
+ * are not keys and don't appear in it.
435
+ */
436
+ function renderDiffValue(value, keyTrail) {
437
+ const finalKey = keyTrail[keyTrail.length - 1];
438
+ const parentKey = keyTrail[keyTrail.length - 2];
439
+ if ((finalKey !== undefined && isCredentialKey(finalKey)) ||
440
+ (parentKey !== undefined && isCredentialKey(parentKey))) {
441
+ return REDACTED_VALUE;
442
+ }
443
+ return formatDiffValue(redactTree(value));
444
+ }
445
+ /** Deep-copy `value` with credential-keyed leaves replaced by `[redacted]`
446
+ * and credential-shaped tokens masked inside the remaining strings, so an
447
+ * added/removed SUBTREE (a whole MCP server map with an `env` block) can't
448
+ * smuggle secrets through its compact-JSON rendering. */
449
+ function redactTree(value, underCredentialKey = false) {
450
+ if (Array.isArray(value)) {
451
+ return value.map((v) => redactTree(v, underCredentialKey));
452
+ }
453
+ if (isPlainObject(value)) {
454
+ const out = {};
455
+ for (const [k, v] of Object.entries(value)) {
456
+ out[k] = redactTree(v, underCredentialKey || isCredentialKey(k));
457
+ }
458
+ return out;
459
+ }
460
+ if (underCredentialKey)
461
+ return REDACTED_VALUE;
462
+ return typeof value === "string" ? maskCredentialTokens(value) : value;
463
+ }
464
+ function walkDiff(a, b, path, out, keyTrail) {
465
+ if (isPlainObject(a) && isPlainObject(b)) {
466
+ // Union of keys, a's ordering first — deterministic output.
467
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
468
+ for (const key of keys) {
469
+ const childPath = path === "" ? key : `${path}.${key}`;
470
+ const childTrail = [...keyTrail, key];
471
+ if (!(key in a)) {
472
+ out.push({ kind: "added", path: childPath, after: renderDiffValue(b[key], childTrail) });
473
+ }
474
+ else if (!(key in b)) {
475
+ out.push({
476
+ kind: "removed",
477
+ path: childPath,
478
+ before: renderDiffValue(a[key], childTrail),
479
+ });
480
+ }
481
+ else {
482
+ walkDiff(a[key], b[key], childPath, out, childTrail);
483
+ }
484
+ }
485
+ return;
486
+ }
487
+ if (Array.isArray(a) && Array.isArray(b)) {
488
+ const len = Math.max(a.length, b.length);
489
+ for (let i = 0; i < len; i++) {
490
+ const childPath = `${path}[${i}]`;
491
+ if (i >= a.length) {
492
+ out.push({ kind: "added", path: childPath, after: renderDiffValue(b[i], keyTrail) });
493
+ }
494
+ else if (i >= b.length) {
495
+ out.push({ kind: "removed", path: childPath, before: renderDiffValue(a[i], keyTrail) });
496
+ }
497
+ else {
498
+ walkDiff(a[i], b[i], childPath, out, keyTrail);
499
+ }
500
+ }
501
+ return;
502
+ }
503
+ // Leaf (or type-mismatch, e.g. map → sequence): compare whole values.
504
+ if (!leafEqual(a, b)) {
505
+ out.push({
506
+ kind: "changed",
507
+ path: path === "" ? "(root)" : path,
508
+ before: renderDiffValue(a, keyTrail),
509
+ after: renderDiffValue(b, keyTrail),
510
+ });
511
+ }
512
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Credential redaction for HUMAN-READABLE renderings of spec/IR content —
3
+ * changelog diff lines (`spec-patch`) and generated bundle READMEs (`ir`).
4
+ * Two layers:
5
+ *
6
+ * 1. `isCredentialKey` — key-based: a value stored under a key that NAMES
7
+ * a credential (`api_key`, `botToken`, `headers`, `env`, …) is redacted
8
+ * wholesale, whatever the value looks like.
9
+ * 2. `maskCredentialTokens` — value-based: strings that are NOT under a
10
+ * credential key (instructions prose, command args, URLs) are scanned
11
+ * for well-known credential token shapes (`sk-…`, `ghp_…`, `xoxb-…`,
12
+ * `AKIA…`, `Bearer <token>`) plus high-length opaque tokens preceded by
13
+ * key-ish context words. Deliberately conservative: a bare 32-char
14
+ * identifier with no "key/token/secret/password" context is left alone
15
+ * so normal prose never gets chewed up.
16
+ *
17
+ * KEEP IN SYNC: this module is intentionally duplicated as
18
+ * `packages/ir/src/redact.ts` and `packages/spec-patch/src/redact.ts`.
19
+ * `@crewhaus/ir` keeps ZERO package dependencies (its `readme.ts` already
20
+ * mirrors `OUTWARD_TOOL_NAMES` from tool-builder for the same reason) and
21
+ * `spec-patch` is spec-layer infrastructure that must not grow an edge onto
22
+ * the IR layer — so neither package can host the single copy without a new
23
+ * dependency edge. Change one file, change both.
24
+ */
25
+ /** Placeholder rendered in place of a value under a credential-carrying key. */
26
+ export declare const REDACTED_VALUE = "[redacted]";
27
+ /** Placeholder substituted for a credential-shaped token inside a string. */
28
+ export declare const MASKED_TOKEN = "***";
29
+ /**
30
+ * Whether a property key names a credential. Suffix matches require a word
31
+ * boundary (snake/kebab/camel) so `api_key` / `botToken` / `GITHUB_TOKEN` /
32
+ * `signingSecret` redact while `monkey` / `max_tokens` don't.
33
+ */
34
+ export declare function isCredentialKey(key: string): boolean;
35
+ /** A path segment / standalone word shaped like an opaque credential
36
+ * (Alchemy/Infura-style `/v2/<key>`): 32+ chars of token alphabet. */
37
+ export declare const OPAQUE_TOKEN_RE: RegExp;
38
+ /**
39
+ * Mask credential-shaped tokens inside a string. Non-credential text is
40
+ * returned unchanged (hit/no-hit cases are unit-tested in both packages).
41
+ */
42
+ export declare function maskCredentialTokens(text: string): string;
package/dist/redact.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Credential redaction for HUMAN-READABLE renderings of spec/IR content —
3
+ * changelog diff lines (`spec-patch`) and generated bundle READMEs (`ir`).
4
+ * Two layers:
5
+ *
6
+ * 1. `isCredentialKey` — key-based: a value stored under a key that NAMES
7
+ * a credential (`api_key`, `botToken`, `headers`, `env`, …) is redacted
8
+ * wholesale, whatever the value looks like.
9
+ * 2. `maskCredentialTokens` — value-based: strings that are NOT under a
10
+ * credential key (instructions prose, command args, URLs) are scanned
11
+ * for well-known credential token shapes (`sk-…`, `ghp_…`, `xoxb-…`,
12
+ * `AKIA…`, `Bearer <token>`) plus high-length opaque tokens preceded by
13
+ * key-ish context words. Deliberately conservative: a bare 32-char
14
+ * identifier with no "key/token/secret/password" context is left alone
15
+ * so normal prose never gets chewed up.
16
+ *
17
+ * KEEP IN SYNC: this module is intentionally duplicated as
18
+ * `packages/ir/src/redact.ts` and `packages/spec-patch/src/redact.ts`.
19
+ * `@crewhaus/ir` keeps ZERO package dependencies (its `readme.ts` already
20
+ * mirrors `OUTWARD_TOOL_NAMES` from tool-builder for the same reason) and
21
+ * `spec-patch` is spec-layer infrastructure that must not grow an edge onto
22
+ * the IR layer — so neither package can host the single copy without a new
23
+ * dependency edge. Change one file, change both.
24
+ */
25
+ /** Placeholder rendered in place of a value under a credential-carrying key. */
26
+ export const REDACTED_VALUE = "[redacted]";
27
+ /** Placeholder substituted for a credential-shaped token inside a string. */
28
+ export const MASKED_TOKEN = "***";
29
+ /**
30
+ * Keys that carry credentials, matched case-insensitively after lowercasing.
31
+ * Aligned with the spec schema's credential carriers (`botToken`,
32
+ * `signingSecret`, `appToken`, `secretToken`, `accessToken`, `appSecret`,
33
+ * `retrieve.apiKey`, wallet `keyRef`) and the compiler's `lowerCredential` /
34
+ * `lowerWalletKeyRef` call sites — see `packages/compiler/src/index.ts` §12.
35
+ * `headers` and `env` are container keys: everything under them redacts.
36
+ */
37
+ const CREDENTIAL_KEY_EXACT = new Set([
38
+ "key",
39
+ "apikey",
40
+ "api_key",
41
+ "api-key",
42
+ "token",
43
+ "secret",
44
+ "password",
45
+ "passwd",
46
+ "pwd",
47
+ "authorization",
48
+ "auth",
49
+ "credential",
50
+ "credentials",
51
+ "headers",
52
+ "env",
53
+ "keyref",
54
+ "key_ref",
55
+ "key-ref",
56
+ "privatekey",
57
+ "private_key",
58
+ "private-key",
59
+ ]);
60
+ /**
61
+ * Whether a property key names a credential. Suffix matches require a word
62
+ * boundary (snake/kebab/camel) so `api_key` / `botToken` / `GITHUB_TOKEN` /
63
+ * `signingSecret` redact while `monkey` / `max_tokens` don't.
64
+ */
65
+ export function isCredentialKey(key) {
66
+ const k = key.toLowerCase();
67
+ if (CREDENTIAL_KEY_EXACT.has(k))
68
+ return true;
69
+ if (/[_-](?:key|token|secret|password)$/.test(k))
70
+ return true;
71
+ return /[a-z0-9](?:Key|Token|Secret|Password)$/.test(key);
72
+ }
73
+ /** A path segment / standalone word shaped like an opaque credential
74
+ * (Alchemy/Infura-style `/v2/<key>`): 32+ chars of token alphabet. */
75
+ export const OPAQUE_TOKEN_RE = /^[A-Za-z0-9_-]{32,}$/;
76
+ /** Well-known credential token shapes, masked wherever they appear. */
77
+ const TOKEN_SHAPE_RES = [
78
+ /\bsk-[A-Za-z0-9_-]{8,}/g, // OpenAI/Anthropic/Stripe-style secret keys
79
+ /\bgh[oprsu]_[A-Za-z0-9]{16,}/g, // GitHub tokens (ghp_/gho_/ghu_/ghs_/ghr_)
80
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
81
+ /\bAKIA[A-Z0-9]{12,}/g, // AWS access key ids
82
+ ];
83
+ /** `Bearer <token>` — the scheme word is kept, the token is masked. */
84
+ const BEARER_RE = /\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi;
85
+ /**
86
+ * Generic 32+-char opaque token, masked ONLY when preceded by a key-ish
87
+ * context word — `key: XXXX…`, `token=XXXX…` — so hashes/ids in ordinary
88
+ * prose survive. Group 1 (the context) is kept; the token is masked.
89
+ */
90
+ const CONTEXTUAL_OPAQUE_RE = /\b((?:api[-_ ]?)?(?:key|token|secret|password|credential)s?\b["'\s:=-]{0,5})([A-Za-z0-9+/_-]{32,})/gi;
91
+ /**
92
+ * Mask credential-shaped tokens inside a string. Non-credential text is
93
+ * returned unchanged (hit/no-hit cases are unit-tested in both packages).
94
+ */
95
+ export function maskCredentialTokens(text) {
96
+ let out = text;
97
+ for (const re of TOKEN_SHAPE_RES)
98
+ out = out.replace(re, MASKED_TOKEN);
99
+ out = out.replace(BEARER_RE, `$1 ${MASKED_TOKEN}`);
100
+ out = out.replace(CONTEXTUAL_OPAQUE_RE, `$1${MASKED_TOKEN}`);
101
+ return out;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/spec-patch",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Pillar-2 patch infrastructure — apply a SpecPatch to a YAML source preserving comments and key order via the yaml CST. Drives the active eval optimizer's spec-level mutation loop.",
6
6
  "main": "dist/index.js",
@@ -15,8 +15,8 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.1.8",
19
- "@crewhaus/spec": "0.1.8",
18
+ "@crewhaus/errors": "0.2.1",
19
+ "@crewhaus/spec": "0.2.1",
20
20
  "yaml": "^2.6.0",
21
21
  "zod": "^3.23.8"
22
22
  },