mustflow 1.30.0 → 2.11.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 (82) hide show
  1. package/README.md +35 -11
  2. package/dist/cli/commands/classify.js +61 -6
  3. package/dist/cli/commands/contract-lint.js +13 -4
  4. package/dist/cli/commands/dashboard.js +6 -0
  5. package/dist/cli/commands/index.js +5 -0
  6. package/dist/cli/commands/run.js +224 -48
  7. package/dist/cli/commands/upgrade.js +65 -0
  8. package/dist/cli/commands/verify.js +550 -33
  9. package/dist/cli/i18n/en.js +73 -10
  10. package/dist/cli/i18n/es.js +73 -10
  11. package/dist/cli/i18n/fr.js +73 -10
  12. package/dist/cli/i18n/hi.js +73 -10
  13. package/dist/cli/i18n/ko.js +73 -10
  14. package/dist/cli/i18n/zh.js +73 -10
  15. package/dist/cli/index.js +27 -46
  16. package/dist/cli/lib/command-registry.js +5 -0
  17. package/dist/cli/lib/dashboard-export.js +62 -12
  18. package/dist/cli/lib/dashboard-html/client-script.js +1936 -0
  19. package/dist/cli/lib/dashboard-html/locale-bootstrap.js +8 -0
  20. package/dist/cli/lib/dashboard-html/styles.js +572 -0
  21. package/dist/cli/lib/dashboard-html/template.js +134 -0
  22. package/dist/cli/lib/dashboard-html/types.js +1 -0
  23. package/dist/cli/lib/dashboard-html.js +1 -1907
  24. package/dist/cli/lib/dashboard-locale.js +37 -0
  25. package/dist/cli/lib/local-index/constants.js +48 -0
  26. package/dist/cli/lib/local-index/index.js +2256 -0
  27. package/dist/cli/lib/local-index/sql.js +15 -0
  28. package/dist/cli/lib/local-index/types.js +1 -0
  29. package/dist/cli/lib/local-index.js +1 -1908
  30. package/dist/cli/lib/reporter.js +6 -0
  31. package/dist/cli/lib/run-plan.js +96 -4
  32. package/dist/cli/lib/templates.js +18 -1
  33. package/dist/cli/lib/validation/command-intents.js +11 -0
  34. package/dist/cli/lib/validation/constants.js +238 -0
  35. package/dist/cli/lib/validation/index.js +1384 -0
  36. package/dist/cli/lib/validation/primitives.js +198 -0
  37. package/dist/cli/lib/validation/test-selection.js +95 -0
  38. package/dist/cli/lib/validation/types.js +1 -0
  39. package/dist/cli/lib/validation.js +1 -1661
  40. package/dist/core/bounded-output.js +38 -0
  41. package/dist/core/change-classification.js +6 -2
  42. package/dist/core/change-verification.js +240 -6
  43. package/dist/core/check-issues.js +12 -0
  44. package/dist/core/command-contract-validation.js +20 -0
  45. package/dist/core/command-effects.js +13 -0
  46. package/dist/core/completion-verdict.js +209 -0
  47. package/dist/core/contract-lint.js +316 -7
  48. package/dist/core/dashboard-verification.js +8 -0
  49. package/dist/core/external-evidence.js +9 -0
  50. package/dist/core/public-json-contracts.js +28 -0
  51. package/dist/core/repeated-failure.js +17 -0
  52. package/dist/core/repro-evidence.js +53 -0
  53. package/dist/core/run-performance-history.js +307 -0
  54. package/dist/core/run-profile.js +87 -0
  55. package/dist/core/run-receipt.js +171 -4
  56. package/dist/core/run-write-drift.js +18 -2
  57. package/dist/core/scope-risk.js +64 -0
  58. package/dist/core/skill-route-alignment.js +110 -0
  59. package/dist/core/source-anchor-status.js +4 -1
  60. package/dist/core/test-selection.js +227 -0
  61. package/dist/core/validation-ratchet.js +52 -0
  62. package/dist/core/verification-decision-graph.js +67 -0
  63. package/dist/core/verification-evidence.js +249 -0
  64. package/dist/core/verification-scheduler.js +96 -2
  65. package/examples/README.md +12 -4
  66. package/package.json +1 -1
  67. package/schemas/README.md +18 -4
  68. package/schemas/change-verification-report.schema.json +169 -5
  69. package/schemas/commands.schema.json +51 -1
  70. package/schemas/contract-lint-report.schema.json +80 -0
  71. package/schemas/dashboard-export.schema.json +500 -0
  72. package/schemas/explain-report.schema.json +2 -0
  73. package/schemas/latest-run-pointer.schema.json +384 -0
  74. package/schemas/run-receipt.schema.json +113 -0
  75. package/schemas/test-selection.schema.json +81 -0
  76. package/schemas/verify-report.schema.json +361 -1
  77. package/schemas/verify-run-manifest.schema.json +410 -0
  78. package/templates/default/common/.mustflow/config/commands.toml +1 -1
  79. package/templates/default/i18n.toml +1 -1
  80. package/templates/default/locales/en/.mustflow/skills/INDEX.md +124 -29
  81. package/templates/default/locales/en/.mustflow/skills/routes.toml +289 -0
  82. package/templates/default/manifest.toml +29 -2
@@ -0,0 +1,307 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const PERFORMANCE_HISTORY_SCHEMA_VERSION = '1';
4
+ const PERFORMANCE_HISTORY_DIR = path.join('.mustflow', 'state', 'perf');
5
+ const PERFORMANCE_SAMPLES_FILE = 'samples.json';
6
+ const PERFORMANCE_SUMMARY_FILE = 'summary.json';
7
+ const MAX_AGE_DAYS = 30;
8
+ const MAX_TOTAL_KB = 256;
9
+ const MAX_TOTAL_BYTES = MAX_TOTAL_KB * 1024;
10
+ const MAX_SAMPLES_TOTAL = 500;
11
+ const MAX_SAMPLES_PER_INTENT = 40;
12
+ const MAX_SAMPLES_PER_INTENT_FINGERPRINT = 20;
13
+ const MAX_FAILED_SAMPLES_PER_INTENT = 5;
14
+ const MAX_FINGERPRINTS_PER_INTENT = 3;
15
+ const EWMA_ALPHA = 0.3;
16
+ function getRetention() {
17
+ return {
18
+ max_age_days: MAX_AGE_DAYS,
19
+ max_total_kb: MAX_TOTAL_KB,
20
+ max_samples_total: MAX_SAMPLES_TOTAL,
21
+ max_samples_per_intent: MAX_SAMPLES_PER_INTENT,
22
+ max_samples_per_intent_fingerprint: MAX_SAMPLES_PER_INTENT_FINGERPRINT,
23
+ max_failed_samples_per_intent: MAX_FAILED_SAMPLES_PER_INTENT,
24
+ max_fingerprints_per_intent: MAX_FINGERPRINTS_PER_INTENT,
25
+ timestamp_granularity: 'day',
26
+ stores_output_tails: false,
27
+ stores_command_line: false,
28
+ stores_environment_values: false,
29
+ stores_absolute_paths: false,
30
+ stores_test_names: false,
31
+ };
32
+ }
33
+ function toObservedDay(value) {
34
+ return value.slice(0, 10);
35
+ }
36
+ function toDayIndex(day) {
37
+ const time = Date.parse(`${day}T00:00:00.000Z`);
38
+ return Math.floor(time / 86_400_000);
39
+ }
40
+ function getRunnerBucket(runner) {
41
+ return `${runner.kind}/${runner.platform_family}/${runner.arch_family}/${runner.runtime}@${runner.runtime_major}`;
42
+ }
43
+ function createSample(receipt) {
44
+ if (!receipt.performance.quality.usable_for_history) {
45
+ return null;
46
+ }
47
+ if (receipt.performance.result_summary.status !== 'passed' && receipt.performance.result_summary.status !== 'failed') {
48
+ return null;
49
+ }
50
+ return {
51
+ observed_day: toObservedDay(receipt.finished_at),
52
+ intent: receipt.intent,
53
+ intent_fingerprint: receipt.performance.intent_fingerprint,
54
+ command_fingerprint: receipt.performance.command_fingerprint,
55
+ contract_fingerprint: receipt.performance.contract_fingerprint,
56
+ runner_bucket: getRunnerBucket(receipt.performance.runner),
57
+ duration_ms: receipt.performance.duration_ms,
58
+ ...(typeof receipt.performance.executor_overhead_ms === 'number'
59
+ ? { executor_overhead_ms: receipt.performance.executor_overhead_ms }
60
+ : {}),
61
+ timeout_ratio: receipt.performance.timeout_ratio,
62
+ status: receipt.performance.result_summary.status,
63
+ exit_code_class: receipt.performance.result_summary.exit_code_class,
64
+ timed_out: false,
65
+ error_kind: receipt.performance.result_summary.error_kind,
66
+ stdout_bytes: receipt.performance.output_summary.stdout_bytes,
67
+ stderr_bytes: receipt.performance.output_summary.stderr_bytes,
68
+ ...(receipt.performance.phases && receipt.performance.phases.length > 0
69
+ ? { phase_durations_ms: toPhaseDurations(receipt.performance.phases) }
70
+ : {}),
71
+ ...(receipt.performance.selection
72
+ ? {
73
+ selection_strategy: receipt.performance.selection.strategy,
74
+ changed_file_count: receipt.performance.selection.changed_file_count,
75
+ changed_surface_counts: receipt.performance.selection.changed_surface_counts,
76
+ selected_target_count: receipt.performance.selection.selected_target_count,
77
+ fallback_used: receipt.performance.selection.fallback_used,
78
+ }
79
+ : {}),
80
+ };
81
+ }
82
+ function toPhaseDurations(phases) {
83
+ const durations = {};
84
+ for (const phase of phases) {
85
+ durations[phase.name] = phase.duration_ms;
86
+ }
87
+ return durations;
88
+ }
89
+ function readSamples(samplesPath) {
90
+ if (!existsSync(samplesPath)) {
91
+ return [];
92
+ }
93
+ try {
94
+ const parsed = JSON.parse(readFileSync(samplesPath, 'utf8'));
95
+ return Array.isArray(parsed.samples) ? parsed.samples.filter(isRunPerformanceSample) : [];
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ }
101
+ function isRunPerformanceSample(value) {
102
+ if (!value || typeof value !== 'object') {
103
+ return false;
104
+ }
105
+ const sample = value;
106
+ return (typeof sample.observed_day === 'string' &&
107
+ typeof sample.intent === 'string' &&
108
+ typeof sample.intent_fingerprint === 'string' &&
109
+ typeof sample.command_fingerprint === 'string' &&
110
+ typeof sample.contract_fingerprint === 'string' &&
111
+ typeof sample.runner_bucket === 'string' &&
112
+ typeof sample.duration_ms === 'number' &&
113
+ typeof sample.timeout_ratio === 'number' &&
114
+ (sample.status === 'passed' || sample.status === 'failed') &&
115
+ typeof sample.stdout_bytes === 'number' &&
116
+ typeof sample.stderr_bytes === 'number' &&
117
+ (sample.selection_strategy === undefined || typeof sample.selection_strategy === 'string') &&
118
+ (sample.changed_file_count === undefined || isNonNegativeNumber(sample.changed_file_count)) &&
119
+ (sample.changed_surface_counts === undefined || isChangedSurfaceCounts(sample.changed_surface_counts)) &&
120
+ (sample.selected_target_count === undefined || isNonNegativeNumber(sample.selected_target_count)) &&
121
+ (sample.fallback_used === undefined || typeof sample.fallback_used === 'boolean'));
122
+ }
123
+ function isNonNegativeNumber(value) {
124
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
125
+ }
126
+ function isChangedSurfaceCounts(value) {
127
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
128
+ return false;
129
+ }
130
+ return Object.entries(value).every(([surface, count]) => /^[a-z][a-z0-9_]*$/.test(surface) && isNonNegativeNumber(count));
131
+ }
132
+ function keepMostRecentByLimit(items, limit, key) {
133
+ const counts = new Map();
134
+ const kept = [];
135
+ for (let index = items.length - 1; index >= 0; index -= 1) {
136
+ const item = items[index];
137
+ const itemKey = key(item);
138
+ const count = counts.get(itemKey) ?? 0;
139
+ if (count < limit) {
140
+ kept.push(item);
141
+ counts.set(itemKey, count + 1);
142
+ }
143
+ }
144
+ return kept.reverse();
145
+ }
146
+ function keepMostRecentFailuresByIntent(samples) {
147
+ const failures = new Map();
148
+ const keepIndexes = new Set();
149
+ for (let index = samples.length - 1; index >= 0; index -= 1) {
150
+ const sample = samples[index];
151
+ if (sample.status === 'passed') {
152
+ keepIndexes.add(index);
153
+ continue;
154
+ }
155
+ const count = failures.get(sample.intent) ?? 0;
156
+ if (count < MAX_FAILED_SAMPLES_PER_INTENT) {
157
+ keepIndexes.add(index);
158
+ failures.set(sample.intent, count + 1);
159
+ }
160
+ }
161
+ return samples.filter((_, index) => keepIndexes.has(index));
162
+ }
163
+ function keepRecentFingerprintsByIntent(samples) {
164
+ const latestByIntent = new Map();
165
+ for (const sample of samples) {
166
+ const byFingerprint = latestByIntent.get(sample.intent) ?? new Map();
167
+ byFingerprint.set(sample.intent_fingerprint, Math.max(byFingerprint.get(sample.intent_fingerprint) ?? 0, toDayIndex(sample.observed_day)));
168
+ latestByIntent.set(sample.intent, byFingerprint);
169
+ }
170
+ const allowed = new Map();
171
+ for (const [intent, fingerprints] of latestByIntent.entries()) {
172
+ const allowedFingerprints = [...fingerprints.entries()]
173
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
174
+ .slice(0, MAX_FINGERPRINTS_PER_INTENT)
175
+ .map(([fingerprint]) => fingerprint);
176
+ allowed.set(intent, new Set(allowedFingerprints));
177
+ }
178
+ return samples.filter((sample) => allowed.get(sample.intent)?.has(sample.intent_fingerprint) ?? false);
179
+ }
180
+ function pruneSamples(samples, today) {
181
+ const todayIndex = toDayIndex(today);
182
+ let pruned = samples.filter((sample) => todayIndex - toDayIndex(sample.observed_day) < MAX_AGE_DAYS);
183
+ pruned = keepRecentFingerprintsByIntent(pruned);
184
+ pruned = keepMostRecentByLimit(pruned, MAX_SAMPLES_PER_INTENT_FINGERPRINT, (sample) => `${sample.intent}\0${sample.intent_fingerprint}`);
185
+ pruned = keepMostRecentByLimit(pruned, MAX_SAMPLES_PER_INTENT, (sample) => sample.intent);
186
+ pruned = keepMostRecentFailuresByIntent(pruned);
187
+ return pruned.slice(Math.max(0, pruned.length - MAX_SAMPLES_TOTAL));
188
+ }
189
+ function percentile(values, percentileValue) {
190
+ if (values.length === 0) {
191
+ return 0;
192
+ }
193
+ const sorted = [...values].sort((left, right) => left - right);
194
+ const index = Math.ceil((percentileValue / 100) * sorted.length) - 1;
195
+ return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
196
+ }
197
+ function calculateEwma(values) {
198
+ if (values.length === 0) {
199
+ return 0;
200
+ }
201
+ let current = values[0] ?? 0;
202
+ for (const value of values.slice(1)) {
203
+ current = EWMA_ALPHA * value + (1 - EWMA_ALPHA) * current;
204
+ }
205
+ return Math.round(current);
206
+ }
207
+ function summarizeRunnerBuckets(samples) {
208
+ const buckets = {};
209
+ const groups = groupBy(samples, (sample) => sample.runner_bucket);
210
+ for (const [bucket, bucketSamples] of groups.entries()) {
211
+ const durations = bucketSamples.map((sample) => sample.duration_ms);
212
+ buckets[bucket] = {
213
+ sample_count: bucketSamples.length,
214
+ p50_duration_ms: percentile(durations, 50),
215
+ };
216
+ }
217
+ return buckets;
218
+ }
219
+ function summarizeFingerprint(samples) {
220
+ const durations = samples.map((sample) => sample.duration_ms);
221
+ const successes = samples.filter((sample) => sample.status === 'passed');
222
+ const failures = samples.filter((sample) => sample.status !== 'passed');
223
+ const lastSuccess = successes.at(-1);
224
+ return {
225
+ sample_count: samples.length,
226
+ success_count: successes.length,
227
+ timeout_count: samples.filter((sample) => sample.timed_out).length,
228
+ failure_count: failures.length,
229
+ p50_duration_ms: percentile(durations, 50),
230
+ p75_duration_ms: percentile(durations, 75),
231
+ p95_duration_ms: percentile(durations, 95),
232
+ min_duration_ms: Math.min(...durations),
233
+ max_duration_ms: Math.max(...durations),
234
+ ewma_duration_ms: calculateEwma(durations),
235
+ last_success_duration_ms: lastSuccess?.duration_ms ?? null,
236
+ last_observed_day: samples.at(-1)?.observed_day ?? '',
237
+ runner_buckets: summarizeRunnerBuckets(samples),
238
+ };
239
+ }
240
+ function createSummary(samples, generatedDay) {
241
+ const intents = {};
242
+ const byIntent = groupBy(samples, (sample) => sample.intent);
243
+ for (const [intent, intentSamples] of byIntent.entries()) {
244
+ const fingerprints = {};
245
+ const byFingerprint = groupBy(intentSamples, (sample) => sample.intent_fingerprint);
246
+ for (const [fingerprint, fingerprintSamples] of byFingerprint.entries()) {
247
+ fingerprints[fingerprint] = summarizeFingerprint(fingerprintSamples);
248
+ }
249
+ intents[intent] = { fingerprints };
250
+ }
251
+ return {
252
+ schema_version: PERFORMANCE_HISTORY_SCHEMA_VERSION,
253
+ generated_day: generatedDay,
254
+ retention: getRetention(),
255
+ intents,
256
+ };
257
+ }
258
+ function groupBy(items, keyFor) {
259
+ const groups = new Map();
260
+ for (const item of items) {
261
+ const key = keyFor(item);
262
+ const group = groups.get(key) ?? [];
263
+ group.push(item);
264
+ groups.set(key, group);
265
+ }
266
+ return groups;
267
+ }
268
+ function createSamplesFile(samples) {
269
+ return {
270
+ schema_version: PERFORMANCE_HISTORY_SCHEMA_VERSION,
271
+ retention: getRetention(),
272
+ samples,
273
+ };
274
+ }
275
+ function serialize(value) {
276
+ return `${JSON.stringify(value, null, 2)}\n`;
277
+ }
278
+ function enforceSizeLimit(samples, today) {
279
+ let pruned = [...samples];
280
+ let summary = createSummary(pruned, today);
281
+ while (pruned.length > 0 &&
282
+ Buffer.byteLength(serialize(createSamplesFile(pruned)), 'utf8') + Buffer.byteLength(serialize(summary), 'utf8') > MAX_TOTAL_BYTES) {
283
+ pruned = pruned.slice(1);
284
+ summary = createSummary(pruned, today);
285
+ }
286
+ return pruned;
287
+ }
288
+ export function recordRunPerformanceHistory(projectRoot, receipt) {
289
+ const sample = createSample(receipt);
290
+ if (!sample) {
291
+ return;
292
+ }
293
+ try {
294
+ const historyDir = path.join(projectRoot, PERFORMANCE_HISTORY_DIR);
295
+ const samplesPath = path.join(historyDir, PERFORMANCE_SAMPLES_FILE);
296
+ const summaryPath = path.join(historyDir, PERFORMANCE_SUMMARY_FILE);
297
+ const samples = enforceSizeLimit(pruneSamples([...readSamples(samplesPath), sample], sample.observed_day), sample.observed_day);
298
+ const samplesFile = createSamplesFile(samples);
299
+ const summaryFile = createSummary(samples, sample.observed_day);
300
+ mkdirSync(historyDir, { recursive: true });
301
+ writeFileSync(samplesPath, serialize(samplesFile));
302
+ writeFileSync(summaryPath, serialize(summaryFile));
303
+ }
304
+ catch {
305
+ // Performance history is a local optimization hint. A write failure must not affect command execution.
306
+ }
307
+ }
@@ -0,0 +1,87 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { performance } from 'node:perf_hooks';
3
+ import path from 'node:path';
4
+ const RUN_PROFILE_SCHEMA_VERSION = '1';
5
+ const RUN_PROFILE_ENV = 'MUSTFLOW_RUN_PROFILE';
6
+ const RUN_PROFILE_DIR = path.join('.mustflow', 'state', 'runs');
7
+ const LATEST_RUN_PROFILE = 'latest.profile.json';
8
+ function isRunProfileEnabled() {
9
+ const value = process.env[RUN_PROFILE_ENV];
10
+ return value === '1' || value?.toLowerCase() === 'true';
11
+ }
12
+ function roundDurationMs(durationMs) {
13
+ return Math.max(0, Math.round(durationMs * 1000) / 1000);
14
+ }
15
+ function getProfileRelativePath() {
16
+ return path.join(RUN_PROFILE_DIR, LATEST_RUN_PROFILE).split(path.sep).join('/');
17
+ }
18
+ export class RunProfiler {
19
+ enabled;
20
+ startedAt;
21
+ startedAtMs;
22
+ phases = [];
23
+ constructor(enabled = isRunProfileEnabled()) {
24
+ this.enabled = enabled;
25
+ this.startedAt = new Date();
26
+ this.startedAtMs = performance.now();
27
+ }
28
+ measure(name, callback) {
29
+ if (!this.enabled) {
30
+ return callback();
31
+ }
32
+ const startedAtMs = performance.now();
33
+ try {
34
+ return callback();
35
+ }
36
+ finally {
37
+ this.recordPhase(name, startedAtMs);
38
+ }
39
+ }
40
+ async measureAsync(name, callback) {
41
+ if (!this.enabled) {
42
+ return callback();
43
+ }
44
+ const startedAtMs = performance.now();
45
+ try {
46
+ return await callback();
47
+ }
48
+ finally {
49
+ this.recordPhase(name, startedAtMs);
50
+ }
51
+ }
52
+ getReceiptPhases() {
53
+ if (!this.enabled) {
54
+ return [];
55
+ }
56
+ return [...this.phases];
57
+ }
58
+ writeLatest(input) {
59
+ if (!this.enabled) {
60
+ return;
61
+ }
62
+ const finishedAt = new Date();
63
+ const profile = {
64
+ schema_version: RUN_PROFILE_SCHEMA_VERSION,
65
+ command: 'run',
66
+ profile: true,
67
+ profile_window: 'run_command_handler',
68
+ intent: input.intent,
69
+ status: input.status,
70
+ preview_mode: input.previewMode,
71
+ started_at: this.startedAt.toISOString(),
72
+ finished_at: finishedAt.toISOString(),
73
+ duration_ms: roundDurationMs(performance.now() - this.startedAtMs),
74
+ phases: [...this.phases],
75
+ profile_path: getProfileRelativePath(),
76
+ };
77
+ const profilePath = path.join(input.projectRoot, RUN_PROFILE_DIR, LATEST_RUN_PROFILE);
78
+ mkdirSync(path.dirname(profilePath), { recursive: true });
79
+ writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`);
80
+ }
81
+ recordPhase(name, startedAtMs) {
82
+ this.phases.push({
83
+ name,
84
+ duration_ms: roundDurationMs(performance.now() - startedAtMs),
85
+ });
86
+ }
87
+ }
@@ -1,4 +1,5 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import { DEFAULT_RUN_RECEIPT_TAIL_BYTES } from './retention-policy.js';
4
5
  import { redactSecretLikeText } from './secret-redaction.js';
@@ -44,15 +45,17 @@ function summarizeOutput(output, maxOutputBytes, tailBytes, field, state) {
44
45
  redaction_kinds: [],
45
46
  };
46
47
  }
47
- const text = output.toString();
48
- const bytes = Buffer.byteLength(text, 'utf8');
49
48
  const tailLimit = Math.min(tailBytes, maxOutputBytes);
49
+ const text = typeof output === 'object' && 'tail' in output && 'bytes' in output ? output.tail : output.toString();
50
+ const bytes = typeof output === 'object' && 'tail' in output && 'bytes' in output
51
+ ? output.bytes
52
+ : Buffer.byteLength(text, 'utf8');
50
53
  const tail = truncateTextByBytes(text, tailLimit);
51
54
  const redaction = redactSecretLikeText(tail.text);
52
55
  recordRedaction(state, `${field}.tail`, redaction);
53
56
  return {
54
57
  bytes,
55
- truncated: tail.truncated,
58
+ truncated: tail.truncated || bytes > Buffer.byteLength(tail.text, 'utf8'),
56
59
  tail: redaction.text,
57
60
  redacted: redaction.redacted,
58
61
  redaction_count: redaction.redactionCount,
@@ -62,6 +65,146 @@ function summarizeOutput(output, maxOutputBytes, tailBytes, field, state) {
62
65
  function getReceiptRelativePath() {
63
66
  return toPosixPath(path.join(RUN_RECEIPT_DIR, LATEST_RUN_RECEIPT));
64
67
  }
68
+ function stableJson(value) {
69
+ if (Array.isArray(value)) {
70
+ return `[${value.map((entry) => stableJson(entry)).join(',')}]`;
71
+ }
72
+ if (value && typeof value === 'object') {
73
+ const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
74
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(',')}}`;
75
+ }
76
+ return JSON.stringify(value);
77
+ }
78
+ function fingerprint(value) {
79
+ return `sha256:${createHash('sha256').update(stableJson(value)).digest('hex')}`;
80
+ }
81
+ function getRuntimeMajor() {
82
+ const version = getBunRuntimeVersion() ?? process.versions.node;
83
+ const major = Number.parseInt(version.split('.')[0] ?? '', 10);
84
+ return Number.isFinite(major) ? major : 0;
85
+ }
86
+ function getBunRuntimeVersion() {
87
+ const versions = process.versions;
88
+ return versions.bun;
89
+ }
90
+ function getExitCodeClass(status, exitCode) {
91
+ if (exitCode === null) {
92
+ return 'no_exit_code';
93
+ }
94
+ return status === 'passed' ? 'success' : 'failure';
95
+ }
96
+ function getErrorKind(status, exitCode) {
97
+ if (status === 'timed_out') {
98
+ return 'timeout';
99
+ }
100
+ if (status === 'start_failed') {
101
+ return 'start_failed';
102
+ }
103
+ if (status === 'failed' && exitCode !== null) {
104
+ return 'exit_code';
105
+ }
106
+ return null;
107
+ }
108
+ function createPerformanceSummary(input) {
109
+ const contractIdentity = {
110
+ cwd: input.cwd,
111
+ env_allowlist: input.envAllowlist,
112
+ env_policy: input.envPolicy,
113
+ lifecycle: input.lifecycle,
114
+ max_output_bytes: input.maxOutputBytes,
115
+ mode: input.mode,
116
+ run_policy: input.runPolicy,
117
+ success_exit_codes: input.successExitCodes,
118
+ timeout_seconds: input.timeoutSeconds,
119
+ };
120
+ const commandIdentity = {
121
+ argv: input.argv ?? null,
122
+ cmd: input.cmd ?? null,
123
+ mode: input.mode,
124
+ };
125
+ const timeoutBudgetMs = input.timeoutSeconds * 1000;
126
+ const timeoutRatio = timeoutBudgetMs > 0 ? input.durationMs / timeoutBudgetMs : 0;
127
+ const phaseTimings = sanitizePhaseTimings(input.phaseTimings ?? []);
128
+ const selection = sanitizeSelectionSummary(input.selectionSummary);
129
+ return {
130
+ schema_version: '1',
131
+ measurement: 'wall_clock',
132
+ duration_ms: input.durationMs,
133
+ ...(typeof input.executorOverheadMs === 'number' ? { executor_overhead_ms: input.executorOverheadMs } : {}),
134
+ ...(phaseTimings.length > 0 ? { phases: phaseTimings } : {}),
135
+ ...(selection ? { selection } : {}),
136
+ timeout_ratio: Number(timeoutRatio.toFixed(6)),
137
+ command_fingerprint: fingerprint(commandIdentity),
138
+ intent_fingerprint: fingerprint({ intent: input.intent, ...contractIdentity }),
139
+ contract_fingerprint: fingerprint(contractIdentity),
140
+ runner: {
141
+ kind: 'local',
142
+ platform_family: process.platform,
143
+ arch_family: process.arch,
144
+ runtime: getBunRuntimeVersion() ? 'bun' : 'node',
145
+ runtime_major: getRuntimeMajor(),
146
+ },
147
+ output_summary: {
148
+ stdout_bytes: input.stdout.bytes,
149
+ stderr_bytes: input.stderr.bytes,
150
+ stdout_truncated: input.stdout.truncated,
151
+ stderr_truncated: input.stderr.truncated,
152
+ },
153
+ result_summary: {
154
+ status: input.status,
155
+ exit_code_class: getExitCodeClass(input.status, input.exitCode),
156
+ timed_out: input.timedOut,
157
+ error_kind: getErrorKind(input.status, input.exitCode),
158
+ },
159
+ quality: {
160
+ phase_timings_source: phaseTimings.length > 0 ? 'structured_report' : 'none',
161
+ target_timings_source: 'none',
162
+ usable_for_history: input.status === 'passed' || input.status === 'failed',
163
+ },
164
+ };
165
+ }
166
+ function isSafePerformanceKey(value) {
167
+ return /^[a-z][a-z0-9_]*$/.test(value);
168
+ }
169
+ function toNonNegativeInteger(value) {
170
+ if (!Number.isFinite(value) || value < 0) {
171
+ return null;
172
+ }
173
+ return Math.trunc(value);
174
+ }
175
+ function sanitizeSelectionSummary(selection) {
176
+ if (!selection || !isSafePerformanceKey(selection.strategy) || typeof selection.fallback_used !== 'boolean') {
177
+ return undefined;
178
+ }
179
+ const changedFileCount = toNonNegativeInteger(selection.changed_file_count);
180
+ const selectedTargetCount = toNonNegativeInteger(selection.selected_target_count);
181
+ if (changedFileCount === null || selectedTargetCount === null) {
182
+ return undefined;
183
+ }
184
+ const changedSurfaceCounts = {};
185
+ for (const [surface, count] of Object.entries(selection.changed_surface_counts).sort(([left], [right]) => left.localeCompare(right))) {
186
+ const sanitizedCount = toNonNegativeInteger(count);
187
+ if (!isSafePerformanceKey(surface) || sanitizedCount === null) {
188
+ return undefined;
189
+ }
190
+ changedSurfaceCounts[surface] = sanitizedCount;
191
+ }
192
+ return {
193
+ strategy: selection.strategy,
194
+ changed_file_count: changedFileCount,
195
+ changed_surface_counts: changedSurfaceCounts,
196
+ selected_target_count: selectedTargetCount,
197
+ fallback_used: selection.fallback_used,
198
+ };
199
+ }
200
+ function sanitizePhaseTimings(phases) {
201
+ return phases
202
+ .filter((phase) => isSafePerformanceKey(phase.name) && Number.isFinite(phase.duration_ms) && phase.duration_ms >= 0)
203
+ .map((phase) => ({
204
+ name: phase.name,
205
+ duration_ms: Math.round(phase.duration_ms * 1000) / 1000,
206
+ }));
207
+ }
65
208
  export function createRunReceipt(input) {
66
209
  const relativeCwd = path.relative(input.projectRoot, input.cwd);
67
210
  const stdoutTailBytes = input.stdoutTailBytes ?? DEFAULT_RUN_RECEIPT_TAIL_BYTES;
@@ -72,6 +215,7 @@ export function createRunReceipt(input) {
72
215
  const error = input.error ? redactReceiptString(input.error, 'error', redactionState) : null;
73
216
  const stdout = summarizeOutput(input.stdout, input.maxOutputBytes, stdoutTailBytes, 'stdout', redactionState);
74
217
  const stderr = summarizeOutput(input.stderr, input.maxOutputBytes, stderrTailBytes, 'stderr', redactionState);
218
+ const durationMs = input.finishedAt.getTime() - input.startedAt.getTime();
75
219
  return {
76
220
  schema_version: RUN_RECEIPT_SCHEMA_VERSION,
77
221
  command: 'run',
@@ -80,7 +224,7 @@ export function createRunReceipt(input) {
80
224
  timed_out: input.timedOut,
81
225
  started_at: input.startedAt.toISOString(),
82
226
  finished_at: input.finishedAt.toISOString(),
83
- duration_ms: input.finishedAt.getTime() - input.startedAt.getTime(),
227
+ duration_ms: durationMs,
84
228
  cwd: relativeCwd.length > 0 ? toPosixPath(relativeCwd) : '.',
85
229
  lifecycle: input.lifecycle,
86
230
  run_policy: input.runPolicy,
@@ -99,6 +243,29 @@ export function createRunReceipt(input) {
99
243
  stdout,
100
244
  stderr,
101
245
  write_drift: input.writeDrift,
246
+ performance: createPerformanceSummary({
247
+ intent: input.intent,
248
+ status: input.status,
249
+ timedOut: input.timedOut,
250
+ durationMs,
251
+ executorOverheadMs: input.executorOverheadMs,
252
+ mode: input.mode,
253
+ argv,
254
+ cmd,
255
+ cwd: relativeCwd.length > 0 ? toPosixPath(relativeCwd) : '.',
256
+ lifecycle: input.lifecycle,
257
+ runPolicy: input.runPolicy,
258
+ envPolicy: input.envPolicy,
259
+ envAllowlist: input.envAllowlist,
260
+ timeoutSeconds: input.timeoutSeconds,
261
+ maxOutputBytes: input.maxOutputBytes,
262
+ successExitCodes: input.successExitCodes,
263
+ exitCode: input.exitCode,
264
+ stdout,
265
+ stderr,
266
+ phaseTimings: input.phaseTimings,
267
+ selectionSummary: input.selectionSummary,
268
+ }),
102
269
  redaction: {
103
270
  redacted: redactionState.count > 0,
104
271
  redaction_count: redactionState.count,
@@ -4,8 +4,13 @@ import path from 'node:path';
4
4
  import { normalizeCommandEffects } from './command-effects.js';
5
5
  const MAX_SNAPSHOT_FILES = 20_000;
6
6
  const MAX_REPORTED_PATHS = 200;
7
+ const RECURSIVE_SNAPSHOT_ENV = 'MUSTFLOW_WRITE_DRIFT_SNAPSHOT';
7
8
  const EXCLUDED_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
8
9
  const EXCLUDED_RELATIVE_DIRECTORY_PATHS = new Set(['.mustflow/state/runs']);
10
+ function isRecursiveSnapshotEnabled() {
11
+ const value = process.env[RECURSIVE_SNAPSHOT_ENV];
12
+ return value === '1' || value?.toLowerCase() === 'true';
13
+ }
9
14
  function toPosixPath(value) {
10
15
  return value.split(path.sep).join('/');
11
16
  }
@@ -52,16 +57,25 @@ function captureSnapshot(projectRoot) {
52
57
  if (gitSnapshot) {
53
58
  return gitSnapshot;
54
59
  }
60
+ if (!isRecursiveSnapshotEnabled()) {
61
+ return {
62
+ ok: false,
63
+ entries: new Map(),
64
+ reason: 'git_status_unavailable_recursive_snapshot_disabled',
65
+ source: 'unavailable',
66
+ };
67
+ }
55
68
  try {
56
69
  const entries = new Map();
57
70
  collectSnapshotEntries(projectRoot, projectRoot, entries);
58
- return { ok: true, entries, reason: null };
71
+ return { ok: true, entries, reason: null, source: 'recursive_snapshot' };
59
72
  }
60
73
  catch (error) {
61
74
  return {
62
75
  ok: false,
63
76
  entries: new Map(),
64
77
  reason: error instanceof Error && error.message.length > 0 ? error.message : 'snapshot_unavailable',
78
+ source: 'unavailable',
65
79
  };
66
80
  }
67
81
  }
@@ -93,12 +107,14 @@ function captureGitStatusSnapshot(projectRoot) {
93
107
  ok: true,
94
108
  entries,
95
109
  reason: null,
110
+ source: 'git_status',
96
111
  };
97
112
  }
98
113
  function listDeclaredWritePaths(projectRoot, contract, intentName) {
99
114
  const paths = normalizeCommandEffects(projectRoot, contract, intentName)
100
115
  .filter((effect) => effect.access === 'write')
101
- .map((effect) => effect.path);
116
+ .map((effect) => effect.path)
117
+ .filter((effectPath) => typeof effectPath === 'string');
102
118
  return [...new Set(paths.map(normalizeRelativePath))].sort((left, right) => left.localeCompare(right));
103
119
  }
104
120
  function listObservedChangedPaths(before, after) {