@kujolang/paperclip 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +116 -0
  4. package/SECURITY.md +35 -0
  5. package/VERSION +1 -0
  6. package/bundled/components/changebucket/LICENSE +9 -0
  7. package/bundled/components/changebucket/changebucket.kujo +12 -0
  8. package/bundled/components/changebucket/src/analyze.kujo +240 -0
  9. package/bundled/components/changebucket/src/budget.kujo +92 -0
  10. package/bundled/components/changebucket/src/classify.kujo +221 -0
  11. package/bundled/components/changebucket/src/cli.kujo +324 -0
  12. package/bundled/components/changebucket/src/diffsrc.kujo +252 -0
  13. package/bundled/components/changebucket/src/render.kujo +346 -0
  14. package/bundled/components/changebucket/src/util.kujo +62 -0
  15. package/bundled/components/context/LICENSE +9 -0
  16. package/bundled/components/context/scent.kujo +3250 -0
  17. package/bundled/components/failure-evidence/LICENSE +9 -0
  18. package/bundled/components/failure-evidence/casefile.kujo +2061 -0
  19. package/bundled/components/patchbrief/LICENSE +9 -0
  20. package/bundled/components/patchbrief/patchbrief.kujo +153 -0
  21. package/bundled/components/patchbrief/schemas/patchbrief-handoff.schema.json +38 -0
  22. package/bundled/components/patchbrief/schemas/patchbrief-summary.schema.json +46 -0
  23. package/bundled/components/patchbrief/src/common.kujo +212 -0
  24. package/bundled/components/patchbrief/src/git.kujo +250 -0
  25. package/bundled/components/patchbrief/src/handoff.kujo +145 -0
  26. package/bundled/components/patchbrief/src/suggest_tests.kujo +137 -0
  27. package/bundled/components/patchbrief/src/summarize.kujo +309 -0
  28. package/bundled/kujo-components.lock.json +134 -0
  29. package/dist/manifest.js +14696 -0
  30. package/dist/ui/index.js +179 -0
  31. package/dist/worker.js +30536 -0
  32. package/docs/ARCHITECTURE.md +28 -0
  33. package/docs/CATALOG_SUBMISSION.md +25 -0
  34. package/docs/COMPATIBILITY.md +21 -0
  35. package/docs/CONFIGURATION.md +33 -0
  36. package/docs/INSTALLATION.md +53 -0
  37. package/docs/OPERATIONS.md +60 -0
  38. package/docs/README.md +18 -0
  39. package/docs/RELEASE_READINESS.md +46 -0
  40. package/docs/THREAT_MODEL.md +26 -0
  41. package/docs/TROUBLESHOOTING.md +12 -0
  42. package/docs/USAGE.md +95 -0
  43. package/examples/agent-workflow.md +20 -0
  44. package/package.json +71 -0
  45. package/schemas/changebucket-analysis.schema.json +25 -0
  46. package/schemas/context-pack.schema.json +23 -0
  47. package/schemas/failure-evidence.schema.json +18 -0
  48. package/schemas/review-pack.schema.json +19 -0
  49. package/skills/scoped-repository-context/SKILL.md +9 -0
@@ -0,0 +1,2061 @@
1
+ #!/usr/bin/env kujo
2
+
3
+ VERSION := "1.0.0"
4
+
5
+ DEFAULT_REDACTION_PATTERNS := [
6
+ "(?i)(api[_-]?key|secret|token|password|private[_-]?key)\\s*[:=]\\s*[^\\s]+",
7
+ "(?i)Authorization\\s*:\\s*(Bearer\\s+)?[^\\s]+",
8
+ "(?i)Bearer\\s+[A-Za-z0-9._~+/-]+=*",
9
+ "AKIA[0-9A-Z]{16}",
10
+ "-----BEGIN [A-Z ]*PRIVATE KEY-----",
11
+ "api[_-]?key",
12
+ "secret",
13
+ "token",
14
+ "password",
15
+ "private[_-]?key",
16
+ "authorization"
17
+ ]
18
+
19
+ FAILURE_KEYWORDS := [
20
+ "error",
21
+ "failed",
22
+ "failure",
23
+ "exception",
24
+ "traceback",
25
+ "panic",
26
+ "segmentation fault",
27
+ "fatal",
28
+ "assertion",
29
+ "expected",
30
+ "actual",
31
+ "timeout",
32
+ "permission denied",
33
+ "not found",
34
+ "cannot find module",
35
+ "syntax error",
36
+ "type error",
37
+ "undefined",
38
+ "null reference"
39
+ ]
40
+
41
+ PROJECT_HINT_FILES := [
42
+ "package.json",
43
+ "pnpm-lock.yaml",
44
+ "yarn.lock",
45
+ "package-lock.json",
46
+ "pyproject.toml",
47
+ "requirements.txt",
48
+ "composer.json",
49
+ "go.mod"
50
+ ]
51
+
52
+ func safe_to_string(value) {
53
+ try {
54
+ return to_string(value)
55
+ } except err {
56
+ return "<unprintable>"
57
+ }
58
+ }
59
+
60
+ func print_lines(lines) {
61
+ for line in lines {
62
+ print(line)
63
+ }
64
+ }
65
+
66
+ func print_kv_lines(prefix, pairs) {
67
+ for pair in pairs {
68
+ print(prefix + safe_to_string(pair[0]) + ": " + safe_to_string(pair[1]))
69
+ }
70
+ }
71
+
72
+ func print_capture_error(message) {
73
+ print("capture error: " + safe_to_string(message))
74
+ }
75
+
76
+ func print_validation_error(message) {
77
+ print("Validation failed: " + safe_to_string(message))
78
+ }
79
+
80
+ func is_truthy(value) {
81
+ value_type := type(value)
82
+ if value_type == "bool" {
83
+ return value
84
+ }
85
+ if value_type == "int" {
86
+ return value != 0
87
+ }
88
+ if value_type == "float" {
89
+ return value != 0.0
90
+ }
91
+ if value_type == "string" {
92
+ v := to_lower(trim(value))
93
+ if v == "true" || v == "1" || v == "yes" || v == "on" {
94
+ return true
95
+ }
96
+ return false
97
+ }
98
+ if value == null {
99
+ return false
100
+ }
101
+ return true
102
+ }
103
+
104
+ func dict_has_key(dict_value, key) {
105
+ if type(dict_value) != "dict" {
106
+ return false
107
+ }
108
+ return is_truthy(has_key(dict_value, key))
109
+ }
110
+
111
+ func failed_process_result(message) {
112
+ return {
113
+ "exitcode": 127,
114
+ "stdout": "",
115
+ "stderr": message,
116
+ "success": false,
117
+ "timed_out": false,
118
+ "stdout_truncated": false,
119
+ "stderr_truncated": false
120
+ }
121
+ }
122
+
123
+ func safe_spawn(argv) {
124
+ try {
125
+ return spawn_process(argv)
126
+ } except err {
127
+ return failed_process_result(safe_to_string(err))
128
+ }
129
+ }
130
+
131
+ func safe_spawn_with_options(argv, options) {
132
+ try {
133
+ return spawn_process(argv, options)
134
+ } except err {
135
+ return failed_process_result(safe_to_string(err))
136
+ }
137
+ }
138
+
139
+ func proc_success(proc) {
140
+ if type(proc) == "dict" {
141
+ if dict_has_key(proc, "success") {
142
+ return is_truthy(proc["success"])
143
+ }
144
+ return false
145
+ }
146
+ try {
147
+ return is_truthy(proc.success)
148
+ } except err {
149
+ return false
150
+ }
151
+ }
152
+
153
+ func proc_exitcode(proc) {
154
+ if type(proc) == "dict" {
155
+ if dict_has_key(proc, "exitcode") {
156
+ return parse_int_value(proc["exitcode"], 127)
157
+ }
158
+ return 127
159
+ }
160
+ try {
161
+ return parse_int_value(proc.exitcode, 127)
162
+ } except err {
163
+ return 127
164
+ }
165
+ }
166
+
167
+ func proc_stdout(proc) {
168
+ if type(proc) == "dict" {
169
+ if dict_has_key(proc, "stdout") {
170
+ return safe_to_string(proc["stdout"])
171
+ }
172
+ return ""
173
+ }
174
+ try {
175
+ return safe_to_string(proc.stdout)
176
+ } except err {
177
+ return ""
178
+ }
179
+ }
180
+
181
+ func proc_stderr(proc) {
182
+ if type(proc) == "dict" {
183
+ if dict_has_key(proc, "stderr") {
184
+ return safe_to_string(proc["stderr"])
185
+ }
186
+ return ""
187
+ }
188
+ try {
189
+ return safe_to_string(proc.stderr)
190
+ } except err {
191
+ return ""
192
+ }
193
+ }
194
+
195
+ func proc_stdout_truncated(proc) {
196
+ if type(proc) == "dict" {
197
+ if dict_has_key(proc, "stdout_truncated") {
198
+ return is_truthy(proc["stdout_truncated"])
199
+ }
200
+ return false
201
+ }
202
+ try {
203
+ return is_truthy(proc.stdout_truncated)
204
+ } except err {
205
+ return false
206
+ }
207
+ }
208
+
209
+ func proc_stderr_truncated(proc) {
210
+ if type(proc) == "dict" {
211
+ if dict_has_key(proc, "stderr_truncated") {
212
+ return is_truthy(proc["stderr_truncated"])
213
+ }
214
+ return false
215
+ }
216
+ try {
217
+ return is_truthy(proc.stderr_truncated)
218
+ } except err {
219
+ return false
220
+ }
221
+ }
222
+
223
+ func proc_timed_out(proc) {
224
+ if type(proc) == "dict" {
225
+ if dict_has_key(proc, "timed_out") {
226
+ return is_truthy(proc["timed_out"])
227
+ }
228
+ return false
229
+ }
230
+ try {
231
+ return is_truthy(proc.timed_out)
232
+ } except err {
233
+ return false
234
+ }
235
+ }
236
+
237
+ func clone_array(values) {
238
+ copy := []
239
+ for value in values {
240
+ copy = push(copy, value)
241
+ }
242
+ return copy
243
+ }
244
+
245
+ func default_config() {
246
+ return {
247
+ "output_dir": ".casefile",
248
+ "default_format": "markdown",
249
+ "max_log_bytes": 250000,
250
+ "redact": true,
251
+ "git": {
252
+ "include_status": true,
253
+ "include_diff_stat": true,
254
+ "include_recent_commits": 5,
255
+ "include_untracked": true
256
+ },
257
+ "environment": {
258
+ "include_os": true,
259
+ "include_runtime_versions": true,
260
+ "include_package_manager": true,
261
+ "include_ci_context": true
262
+ },
263
+ "redaction": {
264
+ "patterns": clone_array(DEFAULT_REDACTION_PATTERNS)
265
+ },
266
+ "paths": {
267
+ "include_globs": [],
268
+ "exclude_globs": [
269
+ ".git/**",
270
+ "node_modules/**",
271
+ "vendor/**",
272
+ "dist/**",
273
+ "build/**",
274
+ "coverage/**",
275
+ ".env",
276
+ ".env.*"
277
+ ]
278
+ }
279
+ }
280
+ }
281
+
282
+ func default_config_toml() {
283
+ return "# CaseFile Configuration\n"
284
+ + "output_dir = \".casefile\"\n"
285
+ + "default_format = \"markdown\"\n"
286
+ + "max_log_bytes = 250000\n"
287
+ + "redact = true\n\n"
288
+ + "[git]\n"
289
+ + "include_status = true\n"
290
+ + "include_diff_stat = true\n"
291
+ + "include_recent_commits = 5\n"
292
+ + "include_untracked = true\n\n"
293
+ + "[environment]\n"
294
+ + "include_os = true\n"
295
+ + "include_runtime_versions = true\n"
296
+ + "include_package_manager = true\n"
297
+ + "include_ci_context = true\n\n"
298
+ + "[redaction]\n"
299
+ + "patterns = [\n"
300
+ + " \"api[_-]?key\",\n"
301
+ + " \"secret\",\n"
302
+ + " \"token\",\n"
303
+ + " \"password\",\n"
304
+ + " \"private[_-]?key\",\n"
305
+ + " \"authorization\"\n"
306
+ + "]\n\n"
307
+ + "[paths]\n"
308
+ + "include_globs = []\n"
309
+ + "exclude_globs = [\".git/**\", \"node_modules/**\", \"vendor/**\", \"dist/**\", \"build/**\", \"coverage/**\", \".env\", \".env.*\"]\n"
310
+ }
311
+
312
+ func merge_section(base, incoming) {
313
+ if type(base) != "dict" || type(incoming) != "dict" {
314
+ return base
315
+ }
316
+ for key in keys(incoming) {
317
+ base[key] = incoming[key]
318
+ }
319
+ return base
320
+ }
321
+
322
+ func merge_config(defaults, parsed) {
323
+ if type(parsed) != "dict" {
324
+ return defaults
325
+ }
326
+
327
+ if dict_has_key(parsed, "output_dir") {
328
+ defaults["output_dir"] = parsed["output_dir"]
329
+ }
330
+ if dict_has_key(parsed, "default_format") {
331
+ defaults["default_format"] = parsed["default_format"]
332
+ }
333
+ if dict_has_key(parsed, "max_log_bytes") {
334
+ defaults["max_log_bytes"] = parsed["max_log_bytes"]
335
+ }
336
+ if dict_has_key(parsed, "redact") {
337
+ defaults["redact"] = parsed["redact"]
338
+ }
339
+ if dict_has_key(parsed, "git") {
340
+ git_cfg := defaults["git"]
341
+ defaults["git"] = merge_section(git_cfg, parsed["git"])
342
+ }
343
+ if dict_has_key(parsed, "environment") {
344
+ env_cfg := defaults["environment"]
345
+ defaults["environment"] = merge_section(env_cfg, parsed["environment"])
346
+ }
347
+ if dict_has_key(parsed, "redaction") {
348
+ redaction_cfg := defaults["redaction"]
349
+ defaults["redaction"] = merge_section(redaction_cfg, parsed["redaction"])
350
+ }
351
+ if dict_has_key(parsed, "paths") {
352
+ paths_cfg := defaults["paths"]
353
+ defaults["paths"] = merge_section(paths_cfg, parsed["paths"])
354
+ }
355
+
356
+ return defaults
357
+ }
358
+
359
+ func load_config(config_path) {
360
+ cfg := default_config()
361
+ if !is_truthy(file_exists(config_path)) {
362
+ return {"ok": true, "config": cfg}
363
+ }
364
+
365
+ try {
366
+ raw := read_file(config_path)
367
+ parsed := parse_toml(raw)
368
+ cfg = merge_config(cfg, parsed)
369
+ return {"ok": true, "config": cfg}
370
+ } except err {
371
+ return {"ok": false, "error": "Invalid casefile.toml: " + safe_to_string(err)}
372
+ }
373
+ }
374
+
375
+ func get_repo_root() {
376
+ probe := safe_spawn(["git", "rev-parse", "--show-toplevel"])
377
+ if proc_success(probe) && proc_exitcode(probe) == 0 {
378
+ return trim(proc_stdout(probe))
379
+ }
380
+ try {
381
+ return os_getcwd()
382
+ } except err {
383
+ return "."
384
+ }
385
+ }
386
+
387
+ func is_absolute_path(path_value) {
388
+ if starts_with(path_value, "/") {
389
+ return true
390
+ }
391
+ if regex_match(path_value, "^[A-Za-z]:[\\\\/]") {
392
+ return true
393
+ }
394
+ return false
395
+ }
396
+
397
+ func normalize_path_components(path_value) {
398
+ normalized := replace(safe_to_string(path_value), "\\", "/")
399
+ parts := split(normalized, "/")
400
+ components := []
401
+ i := 0
402
+ while i < len(parts) {
403
+ part := trim(parts[i])
404
+ if len(part) == 0 || part == "." {
405
+ i = i + 1
406
+ continue
407
+ }
408
+ if part == ".." {
409
+ return {"ok": false, "error": "path contains unsafe '..' segments"}
410
+ }
411
+ components = push(components, part)
412
+ i = i + 1
413
+ }
414
+ return {"ok": true, "components": components}
415
+ }
416
+
417
+ func path_within_root(candidate_path, root_path, label) {
418
+ candidate_result := normalize_path_components(candidate_path)
419
+ if !candidate_result["ok"] {
420
+ return candidate_result
421
+ }
422
+ root_result := normalize_path_components(root_path)
423
+ if !root_result["ok"] {
424
+ return root_result
425
+ }
426
+
427
+ candidate_components := candidate_result["components"]
428
+ root_components := root_result["components"]
429
+ if len(root_components) == 0 {
430
+ return {"ok": false, "error": label + " resolves outside repository root"}
431
+ }
432
+ if len(root_components) > len(candidate_components) {
433
+ return {"ok": false, "error": label + " resolves outside repository root"}
434
+ }
435
+
436
+ i := 0
437
+ while i < len(root_components) {
438
+ if candidate_components[i] != root_components[i] {
439
+ return {"ok": false, "error": label + " resolves outside repository root"}
440
+ }
441
+ i = i + 1
442
+ }
443
+
444
+ return {"ok": true, "path": candidate_path}
445
+ }
446
+
447
+ func resolve_output_dir(repo_root, output_dir) {
448
+ normalized_output := normalize_path_components(output_dir)
449
+ if !normalized_output["ok"] {
450
+ return normalized_output
451
+ }
452
+
453
+ resolved := output_dir
454
+ if !is_absolute_path(output_dir) {
455
+ resolved = path_join(repo_root, output_dir)
456
+ }
457
+
458
+ outside_check := path_within_root(resolved, repo_root, "output_dir")
459
+ if !outside_check["ok"] {
460
+ return outside_check
461
+ }
462
+
463
+ return {"ok": true, "path": resolved}
464
+ }
465
+
466
+ func ensure_directory(path_value) {
467
+ result := safe_spawn(["mkdir", "-p", path_value])
468
+ if !(proc_success(result) && proc_exitcode(result) == 0) {
469
+ return {"ok": false, "error": "Failed to create directory: " + safe_to_string(proc_stderr(result))}
470
+ }
471
+ return {"ok": true}
472
+ }
473
+
474
+ func safe_write(path_value, content) {
475
+ try {
476
+ write_file(path_value, content, true)
477
+ return {"ok": true}
478
+ } except err {
479
+ return {"ok": false, "error": "write failed for " + path_value + ": " + safe_to_string(err)}
480
+ }
481
+ }
482
+
483
+ func require_writes(file_pairs) {
484
+ for pair in file_pairs {
485
+ write_result := safe_write(pair[0], pair[1])
486
+ if !write_result["ok"] {
487
+ return write_result
488
+ }
489
+ }
490
+ return {"ok": true}
491
+ }
492
+
493
+ func now_case_stamp() {
494
+ probe := safe_spawn(["date", "+%Y-%m-%d-%H%M%S"])
495
+ if proc_success(probe) && proc_exitcode(probe) == 0 {
496
+ return trim(proc_stdout(probe))
497
+ }
498
+ fallback := current_timestamp()
499
+ fallback = replace(fallback, "T", "-")
500
+ fallback = replace(fallback, ":", "")
501
+ fallback = replace(fallback, "+", "-")
502
+ fallback = replace(fallback, "Z", "")
503
+ if len(fallback) >= 17 {
504
+ return substring(fallback, 0, 17)
505
+ }
506
+ return "1970-01-01-000000"
507
+ }
508
+
509
+ func sanitize_case_name(name_value) {
510
+ if name_value == null || len(trim(name_value)) == 0 {
511
+ return "manual-case"
512
+ }
513
+ slug := slugify(name_value)
514
+ if len(trim(slug)) == 0 {
515
+ return "manual-case"
516
+ }
517
+ return slug
518
+ }
519
+
520
+ func create_case_dir(repo_root, cfg, case_name, force) {
521
+ out_result := resolve_output_dir(repo_root, safe_to_string(cfg["output_dir"]))
522
+ if !out_result["ok"] {
523
+ return out_result
524
+ }
525
+ output_root := out_result["path"]
526
+ mk_root := ensure_directory(output_root)
527
+ if !mk_root["ok"] {
528
+ return mk_root
529
+ }
530
+
531
+ case_id := now_case_stamp() + "-" + sanitize_case_name(case_name)
532
+ case_path := path_join(output_root, case_id)
533
+
534
+ if is_truthy(path_exists(case_path)) && !force {
535
+ return {"ok": false, "error": "case directory already exists: " + case_path}
536
+ }
537
+
538
+ mk_case := ensure_directory(case_path)
539
+ if !mk_case["ok"] {
540
+ return mk_case
541
+ }
542
+
543
+ return {
544
+ "ok": true,
545
+ "case_id": case_id,
546
+ "case_path": case_path,
547
+ "output_root": output_root
548
+ }
549
+ }
550
+
551
+ func split_command(command_value) {
552
+ parts := split(trim(command_value), " ")
553
+ argv := []
554
+ for part in parts {
555
+ if len(trim(part)) > 0 {
556
+ argv = push(argv, trim(part))
557
+ }
558
+ }
559
+ return argv
560
+ }
561
+
562
+ func parse_int_value(raw_value, fallback) {
563
+ try {
564
+ return to_int(raw_value)
565
+ } except err {
566
+ return fallback
567
+ }
568
+ }
569
+
570
+ func parse_capture_options(subargs, cfg) {
571
+ options := {
572
+ "name": null,
573
+ "output_dir": null,
574
+ "format": "human",
575
+ "from_log": null,
576
+ "notes": "",
577
+ "include": [],
578
+ "exclude": [],
579
+ "max_log_bytes": parse_int_value(cfg["max_log_bytes"], 250000),
580
+ "mirror_exit_code": false,
581
+ "no_redact": false,
582
+ "force": false,
583
+ "manual": false,
584
+ "command_str": null,
585
+ "raw_argv": [],
586
+ "mode": "manual"
587
+ }
588
+
589
+ separator_index := -1
590
+ for i in range(0, len(subargs)) {
591
+ if subargs[i] == "--" {
592
+ separator_index = i
593
+ break
594
+ }
595
+ }
596
+
597
+ flag_args := subargs
598
+ if separator_index >= 0 {
599
+ flag_args = slice(subargs, 0, separator_index)
600
+ cmd_args := slice(subargs, separator_index + 1, len(subargs))
601
+ for value in cmd_args {
602
+ raw_argv := options["raw_argv"]
603
+ raw_argv = push(raw_argv, value)
604
+ options["raw_argv"] = raw_argv
605
+ }
606
+ if len(options["raw_argv"]) > 0 {
607
+ options["mode"] = "command"
608
+ }
609
+ }
610
+
611
+ i := 0
612
+ while i < len(flag_args) {
613
+ arg := flag_args[i]
614
+ if arg == "--name" {
615
+ i = i + 1
616
+ if i >= len(flag_args) {
617
+ return {"ok": false, "code": 2, "error": "missing value for --name"}
618
+ }
619
+ options["name"] = flag_args[i]
620
+ } else if arg == "--output-dir" {
621
+ i = i + 1
622
+ if i >= len(flag_args) {
623
+ return {"ok": false, "code": 2, "error": "missing value for --output-dir"}
624
+ }
625
+ options["output_dir"] = flag_args[i]
626
+ } else if arg == "--format" {
627
+ i = i + 1
628
+ if i >= len(flag_args) {
629
+ return {"ok": false, "code": 2, "error": "missing value for --format"}
630
+ }
631
+ fmt := to_lower(flag_args[i])
632
+ if fmt != "human" && fmt != "markdown" && fmt != "json" {
633
+ return {"ok": false, "code": 2, "error": "invalid format: " + fmt}
634
+ }
635
+ options["format"] = fmt
636
+ } else if arg == "--from-log" {
637
+ i = i + 1
638
+ if i >= len(flag_args) {
639
+ return {"ok": false, "code": 2, "error": "missing value for --from-log"}
640
+ }
641
+ options["from_log"] = flag_args[i]
642
+ options["mode"] = "log"
643
+ } else if arg == "--notes" {
644
+ i = i + 1
645
+ if i >= len(flag_args) {
646
+ return {"ok": false, "code": 2, "error": "missing value for --notes"}
647
+ }
648
+ options["notes"] = flag_args[i]
649
+ } else if arg == "--include" {
650
+ i = i + 1
651
+ if i >= len(flag_args) {
652
+ return {"ok": false, "code": 2, "error": "missing value for --include"}
653
+ }
654
+ includes := options["include"]
655
+ includes = push(includes, flag_args[i])
656
+ options["include"] = includes
657
+ } else if arg == "--exclude" {
658
+ i = i + 1
659
+ if i >= len(flag_args) {
660
+ return {"ok": false, "code": 2, "error": "missing value for --exclude"}
661
+ }
662
+ excludes := options["exclude"]
663
+ excludes = push(excludes, flag_args[i])
664
+ options["exclude"] = excludes
665
+ } else if arg == "--max-log-bytes" {
666
+ i = i + 1
667
+ if i >= len(flag_args) {
668
+ return {"ok": false, "code": 2, "error": "missing value for --max-log-bytes"}
669
+ }
670
+ bytes_value := parse_int_value(flag_args[i], -1)
671
+ if bytes_value < 0 {
672
+ return {"ok": false, "code": 2, "error": "invalid --max-log-bytes value"}
673
+ }
674
+ options["max_log_bytes"] = bytes_value
675
+ } else if arg == "--mirror-exit-code" {
676
+ options["mirror_exit_code"] = true
677
+ } else if arg == "--no-redact" {
678
+ options["no_redact"] = true
679
+ } else if arg == "--force" {
680
+ options["force"] = true
681
+ } else if arg == "--manual" {
682
+ options["manual"] = true
683
+ options["mode"] = "manual"
684
+ } else if arg == "--command" {
685
+ i = i + 1
686
+ if i >= len(flag_args) {
687
+ return {"ok": false, "code": 2, "error": "missing value for --command"}
688
+ }
689
+ options["command_str"] = flag_args[i]
690
+ } else {
691
+ return {"ok": false, "code": 2, "error": "unknown capture flag: " + arg}
692
+ }
693
+ i = i + 1
694
+ }
695
+
696
+ if options["from_log"] != null && !options["manual"] {
697
+ options["mode"] = "log"
698
+ }
699
+
700
+ if !options["manual"] && len(options["raw_argv"]) == 0 && options["command_str"] != null {
701
+ argv := split_command(options["command_str"])
702
+ if len(argv) == 0 {
703
+ return {"ok": false, "code": 2, "error": "--command value was empty"}
704
+ }
705
+ options["raw_argv"] = argv
706
+ options["mode"] = "command"
707
+ }
708
+
709
+ if !options["manual"] && len(options["raw_argv"]) > 0 && options["mode"] == "manual" {
710
+ options["mode"] = "command"
711
+ }
712
+
713
+ return {"ok": true, "options": options}
714
+ }
715
+
716
+ func detect_command_version(argv) {
717
+ result := safe_spawn(argv)
718
+ if proc_success(result) && proc_exitcode(result) == 0 {
719
+ output := trim(proc_stdout(result))
720
+ if len(output) == 0 {
721
+ return null
722
+ }
723
+ lines_out := split(output, "\n")
724
+ return trim(lines_out[0])
725
+ }
726
+ if proc_exitcode(result) == 127 {
727
+ return null
728
+ }
729
+ output_err := trim(proc_stderr(result))
730
+ if len(output_err) > 0 {
731
+ lines_err := split(output_err, "\n")
732
+ return trim(lines_err[0])
733
+ }
734
+ return null
735
+ }
736
+
737
+ func detect_environment(repo_root) {
738
+ env_data := {
739
+ "os": "unknown",
740
+ "arch": "unknown",
741
+ "shell": "unknown",
742
+ "cwd": repo_root,
743
+ "ci": null,
744
+ "node": null,
745
+ "npm": null,
746
+ "pnpm": null,
747
+ "yarn": null,
748
+ "python": null,
749
+ "pytest": null,
750
+ "php": null,
751
+ "composer": null,
752
+ "go": null,
753
+ "package_manager": null,
754
+ "project_hints": []
755
+ }
756
+
757
+ uname_s := safe_spawn(["uname", "-s"])
758
+ if proc_success(uname_s) && proc_exitcode(uname_s) == 0 {
759
+ env_data["os"] = trim(proc_stdout(uname_s))
760
+ }
761
+ uname_m := safe_spawn(["uname", "-m"])
762
+ if proc_success(uname_m) && proc_exitcode(uname_m) == 0 {
763
+ env_data["arch"] = trim(proc_stdout(uname_m))
764
+ }
765
+
766
+ shell_value := env_or("SHELL", "unknown")
767
+ if len(trim(shell_value)) > 0 {
768
+ env_data["shell"] = basename(shell_value)
769
+ }
770
+
771
+ ci_value := env_or("CI", "")
772
+ if len(trim(ci_value)) > 0 {
773
+ env_data["ci"] = ci_value
774
+ }
775
+
776
+ env_data["node"] = detect_command_version(["node", "--version"])
777
+ env_data["npm"] = detect_command_version(["npm", "--version"])
778
+ env_data["pnpm"] = detect_command_version(["pnpm", "--version"])
779
+ env_data["yarn"] = detect_command_version(["yarn", "--version"])
780
+ env_data["python"] = detect_command_version(["python3", "--version"])
781
+ env_data["pytest"] = detect_command_version(["pytest", "--version"])
782
+ env_data["php"] = detect_command_version(["php", "--version"])
783
+ env_data["composer"] = detect_command_version(["composer", "--version"])
784
+ env_data["go"] = detect_command_version(["go", "version"])
785
+
786
+ if is_truthy(file_exists(path_join(repo_root, "pnpm-lock.yaml"))) {
787
+ env_data["package_manager"] = "pnpm"
788
+ }
789
+ if is_truthy(file_exists(path_join(repo_root, "yarn.lock"))) {
790
+ env_data["package_manager"] = "yarn"
791
+ }
792
+ if is_truthy(file_exists(path_join(repo_root, "package-lock.json"))) {
793
+ env_data["package_manager"] = "npm"
794
+ }
795
+ if is_truthy(file_exists(path_join(repo_root, "composer.json"))) {
796
+ env_data["package_manager"] = "composer"
797
+ }
798
+
799
+ for hint_file in PROJECT_HINT_FILES {
800
+ if is_truthy(file_exists(path_join(repo_root, hint_file))) {
801
+ hints := env_data["project_hints"]
802
+ hints = push(hints, hint_file)
803
+ env_data["project_hints"] = hints
804
+ }
805
+ }
806
+
807
+ return env_data
808
+ }
809
+
810
+ func parse_git_changed_files(status_text) {
811
+ files := []
812
+ lines_text := split(status_text, "\n")
813
+ for line in lines_text {
814
+ if len(trim(line)) < 3 {
815
+ continue
816
+ }
817
+ status_code := trim(substring(line, 0, 2))
818
+ path_value := trim(substring(line, 3, len(line)))
819
+ if len(path_value) == 0 {
820
+ continue
821
+ }
822
+ files = push(files, {"path": path_value, "status": status_code})
823
+ }
824
+ return files
825
+ }
826
+
827
+ func collect_git_context(include_recent_commits) {
828
+ ctx := {
829
+ "is_git_repo": false,
830
+ "root": null,
831
+ "branch": null,
832
+ "head": null,
833
+ "status_summary": "",
834
+ "diff_stat": "",
835
+ "recent_commits": [],
836
+ "changed_files": []
837
+ }
838
+
839
+ inside := safe_spawn(["git", "rev-parse", "--is-inside-work-tree"])
840
+ if !(proc_success(inside) && proc_exitcode(inside) == 0 && trim(proc_stdout(inside)) == "true") {
841
+ return ctx
842
+ }
843
+
844
+ ctx["is_git_repo"] = true
845
+
846
+ root_result := safe_spawn(["git", "rev-parse", "--show-toplevel"])
847
+ if proc_success(root_result) && proc_exitcode(root_result) == 0 {
848
+ ctx["root"] = trim(proc_stdout(root_result))
849
+ }
850
+
851
+ branch_result := safe_spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"])
852
+ if proc_success(branch_result) && proc_exitcode(branch_result) == 0 {
853
+ ctx["branch"] = trim(proc_stdout(branch_result))
854
+ }
855
+
856
+ head_result := safe_spawn(["git", "rev-parse", "HEAD"])
857
+ if proc_success(head_result) && proc_exitcode(head_result) == 0 {
858
+ ctx["head"] = trim(proc_stdout(head_result))
859
+ }
860
+
861
+ status_result := safe_spawn(["git", "status", "--porcelain"])
862
+ if proc_success(status_result) && proc_exitcode(status_result) == 0 {
863
+ ctx["status_summary"] = proc_stdout(status_result)
864
+ ctx["changed_files"] = parse_git_changed_files(proc_stdout(status_result))
865
+ }
866
+
867
+ diff_result := safe_spawn(["git", "diff", "--stat"])
868
+ if proc_success(diff_result) && proc_exitcode(diff_result) == 0 {
869
+ ctx["diff_stat"] = proc_stdout(diff_result)
870
+ }
871
+
872
+ recent_count := to_string(include_recent_commits)
873
+ commits_result := safe_spawn(["git", "log", "-n", recent_count, "--oneline"])
874
+ if proc_success(commits_result) && proc_exitcode(commits_result) == 0 {
875
+ lines_commits := split(trim(proc_stdout(commits_result)), "\n")
876
+ for commit_line in lines_commits {
877
+ if len(trim(commit_line)) > 0 {
878
+ recent := ctx["recent_commits"]
879
+ recent = push(recent, commit_line)
880
+ ctx["recent_commits"] = recent
881
+ }
882
+ }
883
+ }
884
+
885
+ return ctx
886
+ }
887
+
888
+ func write_git_files(case_path, git_ctx) {
889
+ if !git_ctx["is_git_repo"] {
890
+ return
891
+ }
892
+ if len(trim(git_ctx["status_summary"])) > 0 {
893
+ safe_write(path_join(case_path, "git-status.txt"), git_ctx["status_summary"])
894
+ }
895
+ if len(trim(git_ctx["diff_stat"])) > 0 {
896
+ safe_write(path_join(case_path, "git-diff-stat.txt"), git_ctx["diff_stat"])
897
+ }
898
+ if len(git_ctx["recent_commits"]) > 0 {
899
+ safe_write(path_join(case_path, "git-recent-commits.txt"), join(git_ctx["recent_commits"], "\n"))
900
+ }
901
+ }
902
+
903
+ func get_redaction_patterns(cfg) {
904
+ patterns := clone_array(DEFAULT_REDACTION_PATTERNS)
905
+ if dict_has_key(cfg, "redaction") {
906
+ redaction_cfg := cfg["redaction"]
907
+ if dict_has_key(redaction_cfg, "patterns") {
908
+ for pattern in redaction_cfg["patterns"] {
909
+ if len(trim(safe_to_string(pattern))) > 0 {
910
+ patterns = push(patterns, safe_to_string(pattern))
911
+ }
912
+ }
913
+ }
914
+ }
915
+ return patterns
916
+ }
917
+
918
+ func redact_text(input_text, enabled, patterns) {
919
+ if !enabled {
920
+ return {"text": input_text, "count": 0}
921
+ }
922
+
923
+ result := input_text
924
+ redacted_count := 0
925
+
926
+ for pattern in patterns {
927
+ before := result
928
+ try {
929
+ result = regex_replace(result, pattern, "[REDACTED]")
930
+ } except err {
931
+ # Ignore invalid pattern and continue with deterministic defaults.
932
+ }
933
+ if before != result {
934
+ redacted_count = redacted_count + 1
935
+ }
936
+ }
937
+
938
+ return {"text": result, "count": redacted_count}
939
+ }
940
+
941
+ func is_sensitive_arg_name(value) {
942
+ arg := to_lower(trim(safe_to_string(value)))
943
+ while starts_with(arg, "-") {
944
+ arg = substring(arg, 1, len(arg))
945
+ }
946
+ arg = replace(arg, "_", "-")
947
+ if arg == "p" || arg == "password" || arg == "passwd" || arg == "pwd" {
948
+ return true
949
+ }
950
+ if arg == "token" || arg == "access-token" || arg == "refresh-token" || arg == "bearer-token" {
951
+ return true
952
+ }
953
+ if arg == "secret" || arg == "client-secret" || arg == "api-secret" {
954
+ return true
955
+ }
956
+ if arg == "api-key" || arg == "apikey" || arg == "key" {
957
+ return true
958
+ }
959
+ if arg == "authorization" || arg == "auth-token" {
960
+ return true
961
+ }
962
+ return false
963
+ }
964
+
965
+ func redact_embedded_arg_secrets(value, enabled) {
966
+ if !enabled {
967
+ return {"text": safe_to_string(value), "count": 0}
968
+ }
969
+ result := safe_to_string(value)
970
+ redacted_count := 0
971
+ patterns := [
972
+ "api[_-]?key\\s*[:=]\\s*[^\\s'\";]+",
973
+ "apikey\\s*[:=]\\s*[^\\s'\";]+",
974
+ "secret\\s*[:=]\\s*[^\\s'\";]+",
975
+ "token\\s*[:=]\\s*[^\\s'\";]+",
976
+ "password\\s*[:=]\\s*[^\\s'\";]+",
977
+ "passwd\\s*[:=]\\s*[^\\s'\";]+",
978
+ "pwd\\s*[:=]\\s*[^\\s'\";]+",
979
+ "private[_-]?key\\s*[:=]\\s*[^\\s'\";]+",
980
+ "Authorization\\s*:\\s*(Bearer\\s+)?[^\\s'\";]+",
981
+ "authorization\\s*:\\s*(Bearer\\s+)?[^\\s'\";]+"
982
+ ]
983
+ for pattern in patterns {
984
+ before := result
985
+ try {
986
+ result = regex_replace(result, pattern, "[REDACTED]")
987
+ } except err {
988
+ }
989
+ if before != result {
990
+ redacted_count = redacted_count + 1
991
+ }
992
+ }
993
+ return {"text": result, "count": redacted_count}
994
+ }
995
+
996
+ func redact_argv(argv, enabled, patterns) {
997
+ redacted := []
998
+ redaction_count := 0
999
+ redact_next := false
1000
+
1001
+ for arg_value in argv {
1002
+ arg := safe_to_string(arg_value)
1003
+ rendered := arg
1004
+
1005
+ if enabled && redact_next {
1006
+ rendered = "[REDACTED]"
1007
+ redaction_count = redaction_count + 1
1008
+ redact_next = false
1009
+ redacted = push(redacted, rendered)
1010
+ continue
1011
+ }
1012
+
1013
+ if enabled {
1014
+ if is_sensitive_arg_name(arg) && index_of(arg, "=") < 0 {
1015
+ redact_next = true
1016
+ redacted = push(redacted, rendered)
1017
+ continue
1018
+ }
1019
+
1020
+ eq_index := index_of(arg, "=")
1021
+ if eq_index > 0 {
1022
+ key_part := substring(arg, 0, eq_index)
1023
+ if is_sensitive_arg_name(key_part) {
1024
+ rendered = key_part + "=[REDACTED]"
1025
+ redaction_count = redaction_count + 1
1026
+ }
1027
+ }
1028
+
1029
+ if rendered == arg {
1030
+ embedded_redacted := redact_embedded_arg_secrets(arg, enabled)
1031
+ rendered = embedded_redacted["text"]
1032
+ redaction_count = redaction_count + embedded_redacted["count"]
1033
+ }
1034
+
1035
+ if rendered == arg {
1036
+ text_redacted := redact_text(arg, enabled, patterns)
1037
+ rendered = text_redacted["text"]
1038
+ redaction_count = redaction_count + text_redacted["count"]
1039
+ }
1040
+ }
1041
+
1042
+ redacted = push(redacted, rendered)
1043
+ }
1044
+
1045
+ return {"argv": redacted, "display": join(redacted, " "), "count": redaction_count}
1046
+ }
1047
+
1048
+ func detect_failure_keywords(log_text) {
1049
+ lowered := to_lower(log_text)
1050
+ found := []
1051
+ for keyword in FAILURE_KEYWORDS {
1052
+ if index_of(lowered, keyword) >= 0 {
1053
+ found = push(found, keyword)
1054
+ }
1055
+ }
1056
+ return found
1057
+ }
1058
+
1059
+ func capture_command(case_path, options, redact_enabled, patterns) {
1060
+ argv := options["raw_argv"]
1061
+ if len(argv) == 0 {
1062
+ return {"ok": false, "error": "no command provided for command capture"}
1063
+ }
1064
+
1065
+ start := performance_now()
1066
+ proc := safe_spawn_with_options(argv, {"max_output_bytes": options["max_log_bytes"]})
1067
+ duration_ms := to_int(performance_now() - start)
1068
+ command_redacted := redact_argv(argv, redact_enabled, patterns)
1069
+ stored_argv := []
1070
+ command_extra_redactions := 0
1071
+ for stored_arg in command_redacted["argv"] {
1072
+ arg_redacted := redact_embedded_arg_secrets(stored_arg, redact_enabled)
1073
+ stored_argv = push(stored_argv, arg_redacted["text"])
1074
+ command_extra_redactions = command_extra_redactions + arg_redacted["count"]
1075
+ }
1076
+ stored_display_result := redact_embedded_arg_secrets(join(stored_argv, " "), redact_enabled)
1077
+ stored_display := stored_display_result["text"]
1078
+ command_extra_redactions = command_extra_redactions + stored_display_result["count"]
1079
+
1080
+ raw_stdout := safe_to_string(proc_stdout(proc))
1081
+ raw_stderr := safe_to_string(proc_stderr(proc))
1082
+ raw_combined := raw_stdout
1083
+ if len(raw_combined) > 0 && len(raw_stderr) > 0 {
1084
+ raw_combined = raw_combined + "\n"
1085
+ }
1086
+ raw_combined = raw_combined + raw_stderr
1087
+
1088
+ stdout_redacted := redact_text(raw_stdout, redact_enabled, patterns)
1089
+ stderr_redacted := redact_text(raw_stderr, redact_enabled, patterns)
1090
+ combined_redacted := redact_text(raw_combined, redact_enabled, patterns)
1091
+ was_truncated := is_truthy(proc_stdout_truncated(proc)) || is_truthy(proc_stderr_truncated(proc))
1092
+
1093
+ write_result := require_writes([
1094
+ [path_join(case_path, "stdout.log"), stdout_redacted["text"]],
1095
+ [path_join(case_path, "stderr.log"), stderr_redacted["text"]],
1096
+ [path_join(case_path, "combined.log"), combined_redacted["text"]],
1097
+ [path_join(case_path, "command.txt"), stored_display]
1098
+ ])
1099
+ if !write_result["ok"] {
1100
+ return write_result
1101
+ }
1102
+
1103
+ return {
1104
+ "ok": true,
1105
+ "mode": "command",
1106
+ "argv": stored_argv,
1107
+ "display": stored_display,
1108
+ "exit_code": proc_exitcode(proc),
1109
+ "duration_ms": duration_ms,
1110
+ "stdout": stdout_redacted["text"],
1111
+ "stderr": stderr_redacted["text"],
1112
+ "combined": combined_redacted["text"],
1113
+ "truncated": was_truncated,
1114
+ "timed_out": is_truthy(proc_timed_out(proc)),
1115
+ "success": proc_success(proc),
1116
+ "redaction_enabled": redact_enabled,
1117
+ "redaction_count": stdout_redacted["count"] + stderr_redacted["count"] + combined_redacted["count"] + command_redacted["count"] + command_extra_redactions
1118
+ }
1119
+ }
1120
+
1121
+ func capture_from_log(case_path, log_path, redact_enabled, patterns) {
1122
+ if !is_truthy(file_exists(log_path)) {
1123
+ return {"ok": false, "error": "log file not found: " + log_path}
1124
+ }
1125
+
1126
+ try {
1127
+ raw_log := read_file(log_path)
1128
+ redacted := redact_text(raw_log, redact_enabled, patterns)
1129
+ display_redacted := redact_text("<from-log> " + log_path, redact_enabled, patterns)
1130
+ write_result := require_writes([
1131
+ [path_join(case_path, "combined.log"), redacted["text"]],
1132
+ [path_join(case_path, "command.txt"), display_redacted["text"]]
1133
+ ])
1134
+ if !write_result["ok"] {
1135
+ return write_result
1136
+ }
1137
+ return {
1138
+ "ok": true,
1139
+ "mode": "log",
1140
+ "argv": [],
1141
+ "display": display_redacted["text"],
1142
+ "exit_code": null,
1143
+ "duration_ms": 0,
1144
+ "stdout": "",
1145
+ "stderr": "",
1146
+ "combined": redacted["text"],
1147
+ "truncated": false,
1148
+ "timed_out": false,
1149
+ "success": true,
1150
+ "redaction_enabled": redact_enabled,
1151
+ "redaction_count": redacted["count"] + display_redacted["count"]
1152
+ }
1153
+ } except err {
1154
+ return {"ok": false, "error": "failed to read log file: " + safe_to_string(err)}
1155
+ }
1156
+ }
1157
+
1158
+ func create_manual_case(case_path) {
1159
+ write_result := safe_write(path_join(case_path, "command.txt"), "<manual>")
1160
+ if !write_result["ok"] {
1161
+ return write_result
1162
+ }
1163
+ return {
1164
+ "ok": true,
1165
+ "mode": "manual",
1166
+ "argv": [],
1167
+ "display": "<manual>",
1168
+ "exit_code": null,
1169
+ "duration_ms": 0,
1170
+ "stdout": "",
1171
+ "stderr": "",
1172
+ "combined": "",
1173
+ "truncated": false,
1174
+ "timed_out": false,
1175
+ "success": true,
1176
+ "redaction_enabled": false,
1177
+ "redaction_count": 0
1178
+ }
1179
+ }
1180
+
1181
+ func classify_case(capture_data, git_ctx, env_data) {
1182
+ signals := []
1183
+ severity_score := 0
1184
+ status := "unknown"
1185
+
1186
+ if capture_data["mode"] == "manual" {
1187
+ status = "manual"
1188
+ } else if capture_data["exit_code"] == null {
1189
+ status = "unknown"
1190
+ } else if capture_data["exit_code"] == 0 {
1191
+ status = "passed"
1192
+ } else {
1193
+ status = "failed"
1194
+ signals = concat(signals, ["command-exit-nonzero"])
1195
+ severity_score = severity_score + 2
1196
+ }
1197
+
1198
+ keywords := detect_failure_keywords(capture_data["combined"])
1199
+ if len(keywords) > 0 {
1200
+ signals = concat(signals, ["failure-keywords-detected"])
1201
+ severity_score = severity_score + 1
1202
+ }
1203
+
1204
+ if git_ctx["is_git_repo"] {
1205
+ changed_count := len(git_ctx["changed_files"])
1206
+ if changed_count > 0 {
1207
+ signals = concat(signals, ["source-files-changed"])
1208
+ severity_score = severity_score + 1
1209
+ }
1210
+ if changed_count >= 10 {
1211
+ signals = concat(signals, ["many-files-changed"])
1212
+ severity_score = severity_score + 2
1213
+ }
1214
+ for file_entry in git_ctx["changed_files"] {
1215
+ path_value := to_lower(file_entry["path"])
1216
+ if index_of(path_value, "lock") >= 0 || index_of(path_value, "lockfile") >= 0 {
1217
+ signals = concat(signals, ["lockfile-or-deps-changed"])
1218
+ severity_score = severity_score + 1
1219
+ }
1220
+ if index_of(path_value, "migration") >= 0 || index_of(path_value, "schema") >= 0 || index_of(path_value, "auth") >= 0 || index_of(path_value, "security") >= 0 {
1221
+ signals = concat(signals, ["sensitive-files-changed"])
1222
+ severity_score = severity_score + 2
1223
+ }
1224
+ if index_of(path_value, ".env") >= 0 {
1225
+ signals = concat(signals, ["environment-files-changed"])
1226
+ severity_score = severity_score + 2
1227
+ }
1228
+ if starts_with(path_value, ".github/") || starts_with(path_value, ".gitlab") || index_of(path_value, "ci") >= 0 {
1229
+ signals = concat(signals, ["ci-config-changed"])
1230
+ severity_score = severity_score + 1
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ if capture_data["mode"] == "log" && capture_data["display"] != "" {
1236
+ signals = concat(signals, ["from-log-only"])
1237
+ severity_score = severity_score + 2
1238
+ }
1239
+
1240
+ if capture_data["redaction_count"] > 0 {
1241
+ signals = concat(signals, ["redaction-triggered"])
1242
+ severity_score = severity_score + 2
1243
+ }
1244
+
1245
+ if capture_data["truncated"] {
1246
+ signals = concat(signals, ["logs-truncated"])
1247
+ severity_score = severity_score + 1
1248
+ }
1249
+
1250
+ if capture_data["mode"] == "manual" {
1251
+ severity_score = 0
1252
+ }
1253
+
1254
+ severity := "low"
1255
+ if severity_score >= 6 {
1256
+ severity = "high"
1257
+ } else if severity_score >= 3 {
1258
+ severity = "medium"
1259
+ }
1260
+
1261
+ if status == "failed" && severity == "low" {
1262
+ severity = "medium"
1263
+ }
1264
+
1265
+ return {
1266
+ "status": status,
1267
+ "severity": severity,
1268
+ "signals": unique(signals),
1269
+ "keywords": keywords
1270
+ }
1271
+ }
1272
+
1273
+ func build_recommendations(capture_data, git_ctx, env_data) {
1274
+ next_checks := []
1275
+ if capture_data["mode"] == "command" {
1276
+ next_checks = push(next_checks, "Re-run the captured command: " + safe_to_string(capture_data["display"]))
1277
+ } else {
1278
+ next_checks = push(next_checks, "Review the captured logs in combined.log")
1279
+ }
1280
+
1281
+ if len(git_ctx["changed_files"]) > 0 {
1282
+ next_checks = push(next_checks, "Review changed files listed in git-diff-stat.txt and case.md")
1283
+ }
1284
+
1285
+ if is_truthy(file_exists("package.json")) {
1286
+ next_checks = push(next_checks, "Run npm test (or your project test script)")
1287
+ }
1288
+ if is_truthy(file_exists("pyproject.toml")) || is_truthy(file_exists("requirements.txt")) {
1289
+ next_checks = push(next_checks, "Run pytest for Python test coverage")
1290
+ }
1291
+ if is_truthy(file_exists("composer.json")) {
1292
+ next_checks = push(next_checks, "Run composer test or composer validate")
1293
+ }
1294
+ if is_truthy(file_exists("go.mod")) {
1295
+ next_checks = push(next_checks, "Run go test ./... for Go packages")
1296
+ }
1297
+
1298
+ return {"next_checks": unique(next_checks)}
1299
+ }
1300
+
1301
+ func render_reproduction(capture_data) {
1302
+ if capture_data["mode"] != "command" {
1303
+ return "# Reproduction Steps\n\nThis case was not captured from a direct command.\nReview combined.log and case.json for details.\n"
1304
+ }
1305
+ return "# Reproduction Steps\n\n"
1306
+ + "## Command\n\n"
1307
+ + "```bash\n" + safe_to_string(capture_data["display"]) + "\n```\n\n"
1308
+ + "## Steps\n\n"
1309
+ + "1. Navigate to the repository root.\n"
1310
+ + "2. Run the command above.\n"
1311
+ + "3. Compare output with combined.log and case.json.\n"
1312
+ + "4. Validate only the likely failure source before broader changes.\n"
1313
+ }
1314
+
1315
+ func render_handoff(capture_data, classification, git_ctx) {
1316
+ changed_files := []
1317
+ for entry in git_ctx["changed_files"] {
1318
+ changed_files = push(changed_files, entry["path"])
1319
+ }
1320
+ if len(changed_files) > 8 {
1321
+ changed_files = slice(changed_files, 0, 8)
1322
+ }
1323
+
1324
+ changed_line := ""
1325
+ if len(changed_files) > 0 {
1326
+ changed_line = "- Changed files: " + join(changed_files, ", ") + "\n"
1327
+ }
1328
+
1329
+ return "You are debugging a local failure captured by CaseFile.\n\n"
1330
+ + "Failure summary:\n"
1331
+ + "- Command: " + safe_to_string(capture_data["display"]) + "\n"
1332
+ + "- Exit code: " + safe_to_string(capture_data["exit_code"]) + "\n"
1333
+ + "- Main failing area: review failure keywords and git context\n"
1334
+ + changed_line
1335
+ + "\nStart by:\n"
1336
+ + "1. Reading case.json.\n"
1337
+ + "2. Reviewing combined.log.\n"
1338
+ + "3. Checking files listed in git-diff-stat.txt.\n"
1339
+ + "4. Re-running the reproduction command when available.\n"
1340
+ + "5. Fixing only the likely failure source unless evidence points broader.\n\n"
1341
+ + "Do not assume the previous agent completed the work correctly.\n"
1342
+ }
1343
+
1344
+ func render_case_markdown(case_id, case_path, options, capture_data, git_ctx, env_data, classification, recommendations) {
1345
+ exit_value := "N/A"
1346
+ if capture_data["exit_code"] != null {
1347
+ exit_value = safe_to_string(capture_data["exit_code"])
1348
+ }
1349
+
1350
+ git_block := "Not inside a Git repository."
1351
+ if git_ctx["is_git_repo"] {
1352
+ git_block = "- Branch: " + safe_to_string(git_ctx["branch"]) + "\n"
1353
+ + "- HEAD: " + safe_to_string(git_ctx["head"]) + "\n"
1354
+ + "- Changed files: " + to_string(len(git_ctx["changed_files"]))
1355
+ }
1356
+
1357
+ relevant_lines := []
1358
+ for file_entry in git_ctx["changed_files"] {
1359
+ relevant_lines = push(relevant_lines, "- " + safe_to_string(file_entry["path"]) + " (" + safe_to_string(file_entry["status"]) + ")")
1360
+ }
1361
+ if len(relevant_lines) == 0 {
1362
+ relevant_lines = push(relevant_lines, "No changed files detected.")
1363
+ }
1364
+
1365
+ signal_lines := []
1366
+ for signal in classification["signals"] {
1367
+ signal_lines = push(signal_lines, "- " + safe_to_string(signal))
1368
+ }
1369
+ if len(signal_lines) == 0 {
1370
+ signal_lines = push(signal_lines, "- No automatic signals detected")
1371
+ }
1372
+
1373
+ reco_lines := []
1374
+ idx := 1
1375
+ for recommendation in recommendations["next_checks"] {
1376
+ reco_lines = push(reco_lines, to_string(idx) + ". " + safe_to_string(recommendation))
1377
+ idx = idx + 1
1378
+ }
1379
+
1380
+ notes_section := ""
1381
+ if len(trim(options["notes"])) > 0 {
1382
+ notes_section = safe_to_string(options["notes"]) + "\n\n"
1383
+ }
1384
+
1385
+ return "# CaseFile: " + safe_to_string(case_id) + "\n\n"
1386
+ + "## Summary\n\n"
1387
+ + notes_section
1388
+ + "Case captured in **" + safe_to_string(capture_data["mode"]) + "** mode with status **" + safe_to_string(classification["status"]) + "** and severity **" + safe_to_string(classification["severity"]) + "**.\n\n"
1389
+ + "## Command\n\n"
1390
+ + "```\n" + safe_to_string(capture_data["display"]) + "\n```\n\n"
1391
+ + "## Result\n\n"
1392
+ + "- Exit code: " + exit_value + "\n"
1393
+ + "- Duration: " + to_string(capture_data["duration_ms"]) + "ms\n"
1394
+ + "- Mode: " + safe_to_string(capture_data["mode"]) + "\n"
1395
+ + "- Truncated: " + safe_to_string(capture_data["truncated"]) + "\n\n"
1396
+ + "## Reproduction Steps\n\n"
1397
+ + "1. Open reproduction.md and follow the command.\n"
1398
+ + "2. Review combined.log.\n"
1399
+ + "3. Compare with current local state.\n\n"
1400
+ + "## Git Context\n\n"
1401
+ + git_block + "\n\n"
1402
+ + "## Environment\n\n"
1403
+ + "- OS: " + safe_to_string(env_data["os"]) + "\n"
1404
+ + "- Architecture: " + safe_to_string(env_data["arch"]) + "\n"
1405
+ + "- Shell: " + safe_to_string(env_data["shell"]) + "\n"
1406
+ + "- CWD: " + safe_to_string(env_data["cwd"]) + "\n\n"
1407
+ + "## Relevant Files\n\n"
1408
+ + join(relevant_lines, "\n") + "\n\n"
1409
+ + "## Logs\n\n"
1410
+ + "- stdout.log\n"
1411
+ + "- stderr.log\n"
1412
+ + "- combined.log\n\n"
1413
+ + "## Initial Diagnosis Hints\n\n"
1414
+ + join(signal_lines, "\n") + "\n\n"
1415
+ + "## Recommended Next Checks\n\n"
1416
+ + join(reco_lines, "\n") + "\n\n"
1417
+ + "## AI / Human Handoff\n\n"
1418
+ + render_handoff(capture_data, classification, git_ctx)
1419
+ }
1420
+
1421
+ func build_case_json(version, case_id, case_path, options, capture_data, git_ctx, env_data, classification, recommendations) {
1422
+ return {
1423
+ "version": version,
1424
+ "case": {
1425
+ "id": case_id,
1426
+ "name": sanitize_case_name(options["name"]),
1427
+ "created_at": current_timestamp(),
1428
+ "mode": capture_data["mode"],
1429
+ "path": case_path
1430
+ },
1431
+ "command": {
1432
+ "argv": capture_data["argv"],
1433
+ "display": capture_data["display"],
1434
+ "exit_code": capture_data["exit_code"],
1435
+ "duration_ms": capture_data["duration_ms"]
1436
+ },
1437
+ "repository": {
1438
+ "is_git_repo": git_ctx["is_git_repo"],
1439
+ "root": git_ctx["root"],
1440
+ "branch": git_ctx["branch"],
1441
+ "head": git_ctx["head"],
1442
+ "changed_files": git_ctx["changed_files"]
1443
+ },
1444
+ "environment": {
1445
+ "os": env_data["os"],
1446
+ "arch": env_data["arch"],
1447
+ "shell": env_data["shell"],
1448
+ "cwd": env_data["cwd"],
1449
+ "ci": env_data["ci"],
1450
+ "node": env_data["node"],
1451
+ "npm": env_data["npm"],
1452
+ "pnpm": env_data["pnpm"],
1453
+ "yarn": env_data["yarn"],
1454
+ "python": env_data["python"],
1455
+ "pytest": env_data["pytest"],
1456
+ "php": env_data["php"],
1457
+ "composer": env_data["composer"],
1458
+ "go": env_data["go"],
1459
+ "package_manager": env_data["package_manager"]
1460
+ },
1461
+ "logs": {
1462
+ "stdout": "stdout.log",
1463
+ "stderr": "stderr.log",
1464
+ "combined": "combined.log",
1465
+ "truncated": capture_data["truncated"]
1466
+ },
1467
+ "security": {
1468
+ "redaction_enabled": capture_data["redaction_enabled"],
1469
+ "redaction_count": capture_data["redaction_count"]
1470
+ },
1471
+ "classification": classification,
1472
+ "recommendations": recommendations
1473
+ }
1474
+ }
1475
+
1476
+ func output_capture_summary(case_id, case_path, capture_data, classification) {
1477
+ print_lines([
1478
+ "CaseFile captured failure",
1479
+ ""
1480
+ ])
1481
+ print_kv_lines("", [
1482
+ ["Case", case_id],
1483
+ ["Path", case_path],
1484
+ ["Command", capture_data["display"]],
1485
+ ["Exit code", capture_data["exit_code"]],
1486
+ ["Severity", classification["severity"]]
1487
+ ])
1488
+ print_lines([
1489
+ "",
1490
+ "Artifacts:"
1491
+ ])
1492
+ for artifact in ["case.md", "case.json", "combined.log", "reproduction.md", "handoff.md"] {
1493
+ print("- " + artifact)
1494
+ }
1495
+ print_lines([
1496
+ "",
1497
+ "Next:"
1498
+ ])
1499
+ steps := [
1500
+ "Open " + path_join(safe_to_string(case_path), "case.md"),
1501
+ "Re-run: " + safe_to_string(capture_data["display"]),
1502
+ "Send handoff.md to the next reviewer or agent"
1503
+ ]
1504
+ step_no := 1
1505
+ for step in steps {
1506
+ print(to_string(step_no) + ". " + step)
1507
+ step_no = step_no + 1
1508
+ }
1509
+ }
1510
+
1511
+ func usage() {
1512
+ print_lines([
1513
+ "CaseFile " + VERSION,
1514
+ "",
1515
+ "Usage:",
1516
+ " kujo run casefile.kujo -- <command> [options]",
1517
+ "",
1518
+ "Commands:",
1519
+ " capture Capture a command failure, log file, or manual case",
1520
+ " init Create default casefile.toml",
1521
+ " validate Validate casefile.toml",
1522
+ " doctor Print diagnostics",
1523
+ " list List existing cases",
1524
+ " show Show case summary",
1525
+ " clean Clean old cases"
1526
+ ])
1527
+ }
1528
+
1529
+ func capture_command_handler(subargs) {
1530
+ config_result := load_config("casefile.toml")
1531
+ if !config_result["ok"] {
1532
+ print(config_result["error"])
1533
+ exit(2)
1534
+ }
1535
+ cfg := config_result["config"]
1536
+
1537
+ parsed_result := parse_capture_options(subargs, cfg)
1538
+ if !parsed_result["ok"] {
1539
+ print_capture_error(parsed_result["error"])
1540
+ exit(parsed_result["code"])
1541
+ }
1542
+ options := parsed_result["options"]
1543
+
1544
+ if options["output_dir"] != null {
1545
+ cfg["output_dir"] = options["output_dir"]
1546
+ }
1547
+ cfg["max_log_bytes"] = options["max_log_bytes"]
1548
+
1549
+ repo_root := get_repo_root()
1550
+ case_name := options["name"]
1551
+ if case_name == null {
1552
+ case_name = "case"
1553
+ }
1554
+
1555
+ case_result := create_case_dir(repo_root, cfg, case_name, options["force"])
1556
+ if !case_result["ok"] {
1557
+ print_capture_error(case_result["error"])
1558
+ exit(4)
1559
+ }
1560
+ case_id := case_result["case_id"]
1561
+ case_path := case_result["case_path"]
1562
+
1563
+ git_cfg := cfg["git"]
1564
+ git_ctx := collect_git_context(parse_int_value(git_cfg["include_recent_commits"], 5))
1565
+ write_git_files(case_path, git_ctx)
1566
+
1567
+ env_data := detect_environment(repo_root)
1568
+ env_write := safe_write(path_join(case_path, "environment.json"), to_json_pretty(env_data))
1569
+ if !env_write["ok"] {
1570
+ print_capture_error(env_write["error"])
1571
+ exit(4)
1572
+ }
1573
+
1574
+ patterns := get_redaction_patterns(cfg)
1575
+ redact_enabled := is_truthy(cfg["redact"]) && !options["no_redact"]
1576
+ notes_redacted := redact_text(options["notes"], redact_enabled, patterns)
1577
+ options["notes"] = notes_redacted["text"]
1578
+
1579
+ capture_result := null
1580
+ if options["mode"] == "command" {
1581
+ capture_result = capture_command(case_path, options, redact_enabled, patterns)
1582
+ } else if options["mode"] == "log" {
1583
+ capture_result = capture_from_log(case_path, options["from_log"], redact_enabled, patterns)
1584
+ } else {
1585
+ capture_result = create_manual_case(case_path)
1586
+ }
1587
+
1588
+ if !capture_result["ok"] {
1589
+ print_capture_error(capture_result["error"])
1590
+ exit(4)
1591
+ }
1592
+ capture_result["redaction_enabled"] = redact_enabled
1593
+ capture_result["redaction_count"] = capture_result["redaction_count"] + notes_redacted["count"]
1594
+
1595
+ classification := classify_case(capture_result, git_ctx, env_data)
1596
+ recommendations := build_recommendations(capture_result, git_ctx, env_data)
1597
+ case_json_obj := build_case_json(VERSION, case_id, case_path, options, capture_result, git_ctx, env_data, classification, recommendations)
1598
+
1599
+ case_markdown := render_case_markdown(case_id, case_path, options, capture_result, git_ctx, env_data, classification, recommendations)
1600
+ reproduction_markdown := render_reproduction(capture_result)
1601
+ handoff_markdown := render_handoff(capture_result, classification, git_ctx)
1602
+
1603
+ artifact_write := require_writes([
1604
+ [path_join(case_path, "case.md"), case_markdown],
1605
+ [path_join(case_path, "case.json"), to_json_pretty(case_json_obj)],
1606
+ [path_join(case_path, "reproduction.md"), reproduction_markdown],
1607
+ [path_join(case_path, "handoff.md"), handoff_markdown]
1608
+ ])
1609
+ if !artifact_write["ok"] {
1610
+ print_capture_error(artifact_write["error"])
1611
+ exit(4)
1612
+ }
1613
+
1614
+ if options["format"] == "json" {
1615
+ print(to_json_pretty(case_json_obj))
1616
+ } else if options["format"] == "markdown" {
1617
+ print(case_markdown)
1618
+ } else {
1619
+ output_capture_summary(case_id, case_path, capture_result, classification)
1620
+ }
1621
+
1622
+ if options["mirror_exit_code"] && capture_result["exit_code"] != null {
1623
+ exit(capture_result["exit_code"])
1624
+ }
1625
+
1626
+ exit(0)
1627
+ }
1628
+
1629
+ func init_command_handler() {
1630
+ if is_truthy(file_exists("casefile.toml")) {
1631
+ print("casefile.toml already exists. Skipping.")
1632
+ exit(0)
1633
+ }
1634
+ result := safe_write("casefile.toml", default_config_toml())
1635
+ if !result["ok"] {
1636
+ print(result["error"])
1637
+ exit(4)
1638
+ }
1639
+ print("Created casefile.toml")
1640
+ exit(0)
1641
+ }
1642
+
1643
+ func validate_command_handler() {
1644
+ load_result := load_config("casefile.toml")
1645
+ if !load_result["ok"] {
1646
+ print(load_result["error"])
1647
+ exit(2)
1648
+ }
1649
+ cfg := load_result["config"]
1650
+ repo_root := get_repo_root()
1651
+ output_check := resolve_output_dir(repo_root, safe_to_string(cfg["output_dir"]))
1652
+ if !output_check["ok"] {
1653
+ print_validation_error(output_check["error"])
1654
+ exit(2)
1655
+ }
1656
+ if parse_int_value(cfg["max_log_bytes"], -1) < 0 {
1657
+ print_validation_error("max_log_bytes must be non-negative")
1658
+ exit(2)
1659
+ }
1660
+ fmt := to_lower(safe_to_string(cfg["default_format"]))
1661
+ if fmt != "human" && fmt != "markdown" && fmt != "json" {
1662
+ print_validation_error("default_format must be human|markdown|json")
1663
+ exit(2)
1664
+ }
1665
+ print_lines(["Config validation passed"])
1666
+ print_kv_lines("- ", [
1667
+ ["Repo root", repo_root],
1668
+ ["Output dir", output_check["path"]]
1669
+ ])
1670
+ exit(0)
1671
+ }
1672
+
1673
+ func doctor_command_handler() {
1674
+ load_result := load_config("casefile.toml")
1675
+ cfg_ok := load_result["ok"]
1676
+ cfg := default_config()
1677
+ if cfg_ok {
1678
+ cfg = load_result["config"]
1679
+ }
1680
+
1681
+ repo_root := get_repo_root()
1682
+ git_ctx := collect_git_context(5)
1683
+ env_data := detect_environment(repo_root)
1684
+ output_check := resolve_output_dir(repo_root, safe_to_string(cfg["output_dir"]))
1685
+ writable := false
1686
+ if output_check["ok"] {
1687
+ mk := ensure_directory(output_check["path"])
1688
+ writable = mk["ok"]
1689
+ }
1690
+
1691
+ print_lines([
1692
+ "CaseFile doctor",
1693
+ ""
1694
+ ])
1695
+ print_kv_lines("- ", [
1696
+ ["Version", VERSION],
1697
+ ["CWD", env_data["cwd"]],
1698
+ ["Repo root", repo_root],
1699
+ ["Inside git repo", git_ctx["is_git_repo"]],
1700
+ ["Git branch", git_ctx["branch"]],
1701
+ ["Config detected", is_truthy(file_exists("casefile.toml"))],
1702
+ ["Config parse ok", cfg_ok],
1703
+ ["Output directory", output_check["path"]],
1704
+ ["Output writable", writable],
1705
+ ["Package manager", env_data["package_manager"]],
1706
+ ["Node", env_data["node"]],
1707
+ ["npm", env_data["npm"]],
1708
+ ["pnpm", env_data["pnpm"]],
1709
+ ["yarn", env_data["yarn"]],
1710
+ ["Python", env_data["python"]],
1711
+ ["pytest", env_data["pytest"]],
1712
+ ["PHP", env_data["php"]],
1713
+ ["composer", env_data["composer"]],
1714
+ ["Go", env_data["go"]]
1715
+ ])
1716
+
1717
+ if !cfg_ok {
1718
+ print("Warning: configuration file exists but could not be parsed")
1719
+ }
1720
+ exit(0)
1721
+ }
1722
+
1723
+ func sort_case_ids(values) {
1724
+ return sort(clone_array(values))
1725
+ }
1726
+
1727
+ func list_case_dirs(output_root) {
1728
+ if !is_truthy(path_exists(output_root)) || !is_truthy(path_is_dir(output_root)) {
1729
+ return []
1730
+ }
1731
+ entries := list_dir(output_root)
1732
+ dirs := []
1733
+ for entry in entries {
1734
+ full_path := path_join(output_root, entry)
1735
+ if is_truthy(path_is_dir(full_path)) {
1736
+ dirs = push(dirs, entry)
1737
+ }
1738
+ }
1739
+ return sort_case_ids(dirs)
1740
+ }
1741
+
1742
+ func read_case_json(case_dir) {
1743
+ json_path := path_join(case_dir, "case.json")
1744
+ if !is_truthy(file_exists(json_path)) {
1745
+ return null
1746
+ }
1747
+ try {
1748
+ raw := read_file(json_path)
1749
+ return parse_json(raw)
1750
+ } except err {
1751
+ return null
1752
+ }
1753
+ }
1754
+
1755
+ func list_command_handler() {
1756
+ load_result := load_config("casefile.toml")
1757
+ if !load_result["ok"] {
1758
+ print(load_result["error"])
1759
+ exit(2)
1760
+ }
1761
+ cfg := load_result["config"]
1762
+ repo_root := get_repo_root()
1763
+ out_result := resolve_output_dir(repo_root, safe_to_string(cfg["output_dir"]))
1764
+ if !out_result["ok"] {
1765
+ print(out_result["error"])
1766
+ exit(2)
1767
+ }
1768
+ output_root := out_result["path"]
1769
+ case_dirs := list_case_dirs(output_root)
1770
+
1771
+ if len(case_dirs) == 0 {
1772
+ print("No cases found in " + safe_to_string(output_root))
1773
+ exit(0)
1774
+ }
1775
+
1776
+ print_lines([
1777
+ "Case ID | Name | Exit | Severity | Path",
1778
+ "----------------------------------------"
1779
+ ])
1780
+ for case_id in case_dirs {
1781
+ case_path := path_join(output_root, case_id)
1782
+ case_json := read_case_json(case_path)
1783
+ name := case_id
1784
+ exit_code := "N/A"
1785
+ severity := "unknown"
1786
+ if case_json != null {
1787
+ if dict_has_key(case_json, "case") && dict_has_key(case_json["case"], "name") {
1788
+ name = safe_to_string(case_json["case"]["name"])
1789
+ }
1790
+ if dict_has_key(case_json, "command") && dict_has_key(case_json["command"], "exit_code") {
1791
+ exit_code = safe_to_string(case_json["command"]["exit_code"])
1792
+ }
1793
+ if dict_has_key(case_json, "classification") && dict_has_key(case_json["classification"], "severity") {
1794
+ severity = safe_to_string(case_json["classification"]["severity"])
1795
+ }
1796
+ }
1797
+ line := safe_to_string(case_id) + " | " + safe_to_string(name)
1798
+ line = line + " | " + safe_to_string(exit_code) + " | " + safe_to_string(severity)
1799
+ line = line + " | " + safe_to_string(case_path)
1800
+ print(line)
1801
+ }
1802
+ exit(0)
1803
+ }
1804
+
1805
+ func resolve_case_id(requested_case_id, output_root) {
1806
+ if requested_case_id != "latest" {
1807
+ return requested_case_id
1808
+ }
1809
+ case_dirs := list_case_dirs(output_root)
1810
+ if len(case_dirs) == 0 {
1811
+ return null
1812
+ }
1813
+ return case_dirs[len(case_dirs) - 1]
1814
+ }
1815
+
1816
+ func parse_show_options(subargs) {
1817
+ if len(subargs) == 0 {
1818
+ return {"ok": false, "error": "show requires <case-id|latest>"}
1819
+ }
1820
+ options := {"case_id": subargs[0], "format": "markdown"}
1821
+ i := 1
1822
+ while i < len(subargs) {
1823
+ arg := subargs[i]
1824
+ if arg == "--format" {
1825
+ i = i + 1
1826
+ if i >= len(subargs) {
1827
+ return {"ok": false, "error": "missing value for --format"}
1828
+ }
1829
+ fmt := to_lower(subargs[i])
1830
+ if fmt != "json" && fmt != "markdown" {
1831
+ return {"ok": false, "error": "invalid --format for show"}
1832
+ }
1833
+ options["format"] = fmt
1834
+ } else {
1835
+ return {"ok": false, "error": "unknown show flag: " + arg}
1836
+ }
1837
+ i = i + 1
1838
+ }
1839
+ return {"ok": true, "options": options}
1840
+ }
1841
+
1842
+ func show_command_handler(subargs) {
1843
+ parsed := parse_show_options(subargs)
1844
+ if !parsed["ok"] {
1845
+ print(parsed["error"])
1846
+ exit(2)
1847
+ }
1848
+ options := parsed["options"]
1849
+
1850
+ load_result := load_config("casefile.toml")
1851
+ if !load_result["ok"] {
1852
+ print(load_result["error"])
1853
+ exit(2)
1854
+ }
1855
+ cfg := load_result["config"]
1856
+ repo_root := get_repo_root()
1857
+ out_result := resolve_output_dir(repo_root, safe_to_string(cfg["output_dir"]))
1858
+ if !out_result["ok"] {
1859
+ print(out_result["error"])
1860
+ exit(2)
1861
+ }
1862
+ output_root := out_result["path"]
1863
+ case_id := resolve_case_id(options["case_id"], output_root)
1864
+ if case_id == null {
1865
+ print("No cases available")
1866
+ exit(2)
1867
+ }
1868
+ case_path := path_join(output_root, case_id)
1869
+ if !is_truthy(path_exists(case_path)) {
1870
+ print("Case not found: " + case_id)
1871
+ exit(2)
1872
+ }
1873
+
1874
+ if options["format"] == "json" {
1875
+ json_path := path_join(case_path, "case.json")
1876
+ if !is_truthy(file_exists(json_path)) {
1877
+ print("case.json not found for case: " + case_id)
1878
+ exit(2)
1879
+ }
1880
+ print(read_file(json_path))
1881
+ exit(0)
1882
+ }
1883
+
1884
+ md_path := path_join(case_path, "case.md")
1885
+ if !is_truthy(file_exists(md_path)) {
1886
+ print("case.md not found for case: " + case_id)
1887
+ exit(2)
1888
+ }
1889
+ print(read_file(md_path))
1890
+ exit(0)
1891
+ }
1892
+
1893
+ func parse_days(value) {
1894
+ raw := trim(value)
1895
+ if ends_with(raw, "d") {
1896
+ raw = substring(raw, 0, len(raw) - 1)
1897
+ }
1898
+ return parse_int_value(raw, -1)
1899
+ }
1900
+
1901
+ func cutoff_stamp_for_days(days_value) {
1902
+ cmd_mac := "-v-" + to_string(days_value) + "d"
1903
+ mac := safe_spawn(["date", cmd_mac, "+%Y-%m-%d-%H%M%S"])
1904
+ if proc_success(mac) && proc_exitcode(mac) == 0 {
1905
+ return trim(proc_stdout(mac))
1906
+ }
1907
+ linux := safe_spawn(["date", "-d", to_string(days_value) + " days ago", "+%Y-%m-%d-%H%M%S"])
1908
+ if proc_success(linux) && proc_exitcode(linux) == 0 {
1909
+ return trim(proc_stdout(linux))
1910
+ }
1911
+ return null
1912
+ }
1913
+
1914
+ func parse_clean_options(subargs) {
1915
+ options := {"older_than": null, "keep": null, "dry_run": false}
1916
+ i := 0
1917
+ while i < len(subargs) {
1918
+ arg := subargs[i]
1919
+ if arg == "--older-than" {
1920
+ i = i + 1
1921
+ if i >= len(subargs) {
1922
+ return {"ok": false, "error": "missing value for --older-than"}
1923
+ }
1924
+ options["older_than"] = subargs[i]
1925
+ } else if arg == "--keep" {
1926
+ i = i + 1
1927
+ if i >= len(subargs) {
1928
+ return {"ok": false, "error": "missing value for --keep"}
1929
+ }
1930
+ options["keep"] = parse_int_value(subargs[i], -1)
1931
+ if options["keep"] < 0 {
1932
+ return {"ok": false, "error": "--keep must be a non-negative integer"}
1933
+ }
1934
+ } else if arg == "--dry-run" {
1935
+ options["dry_run"] = true
1936
+ } else {
1937
+ return {"ok": false, "error": "unknown clean flag: " + arg}
1938
+ }
1939
+ i = i + 1
1940
+ }
1941
+ return {"ok": true, "options": options}
1942
+ }
1943
+
1944
+ func clean_command_handler(subargs) {
1945
+ parsed := parse_clean_options(subargs)
1946
+ if !parsed["ok"] {
1947
+ print(parsed["error"])
1948
+ exit(2)
1949
+ }
1950
+ options := parsed["options"]
1951
+
1952
+ load_result := load_config("casefile.toml")
1953
+ if !load_result["ok"] {
1954
+ print(load_result["error"])
1955
+ exit(2)
1956
+ }
1957
+ cfg := load_result["config"]
1958
+ repo_root := get_repo_root()
1959
+ out_result := resolve_output_dir(repo_root, safe_to_string(cfg["output_dir"]))
1960
+ if !out_result["ok"] {
1961
+ print(out_result["error"])
1962
+ exit(2)
1963
+ }
1964
+ output_root := out_result["path"]
1965
+ case_dirs := list_case_dirs(output_root)
1966
+ if len(case_dirs) == 0 {
1967
+ print("No cases to clean")
1968
+ exit(0)
1969
+ }
1970
+
1971
+ to_delete := []
1972
+ keep_limit := options["keep"]
1973
+ if keep_limit != null {
1974
+ if keep_limit < len(case_dirs) {
1975
+ for i in range(0, len(case_dirs) - keep_limit) {
1976
+ to_delete = push(to_delete, case_dirs[i])
1977
+ }
1978
+ }
1979
+ }
1980
+
1981
+ if options["older_than"] != null {
1982
+ days := parse_days(options["older_than"])
1983
+ if days < 0 {
1984
+ print("--older-than must be like 30d")
1985
+ exit(2)
1986
+ }
1987
+ cutoff := cutoff_stamp_for_days(days)
1988
+ if cutoff != null {
1989
+ for case_id in case_dirs {
1990
+ if len(case_id) >= 17 {
1991
+ stamp := substring(case_id, 0, 17)
1992
+ if stamp < cutoff {
1993
+ to_delete = push(to_delete, case_id)
1994
+ }
1995
+ }
1996
+ }
1997
+ }
1998
+ }
1999
+
2000
+ to_delete = unique(to_delete)
2001
+ if len(to_delete) == 0 {
2002
+ print("Nothing to delete")
2003
+ exit(0)
2004
+ }
2005
+
2006
+ for case_id in to_delete {
2007
+ target := path_join(output_root, case_id)
2008
+ if !path_within_root(target, output_root, "cleanup target")["ok"] {
2009
+ print("Skipping unsafe path: " + target)
2010
+ continue
2011
+ }
2012
+ if options["dry_run"] {
2013
+ print("[dry-run] would delete " + target)
2014
+ } else {
2015
+ rm := safe_spawn(["rm", "-rf", target])
2016
+ if proc_success(rm) && proc_exitcode(rm) == 0 {
2017
+ print("Deleted " + target)
2018
+ } else {
2019
+ print("Failed to delete " + target + ": " + safe_to_string(proc_stderr(rm)))
2020
+ }
2021
+ }
2022
+ }
2023
+
2024
+ exit(0)
2025
+ }
2026
+
2027
+ argv := args()
2028
+
2029
+ if len(argv) == 0 {
2030
+ usage()
2031
+ exit(2)
2032
+ }
2033
+
2034
+ command := argv[0]
2035
+ subargs := []
2036
+ if len(argv) > 1 {
2037
+ subargs = slice(argv, 1, len(argv))
2038
+ }
2039
+
2040
+ if command == "capture" {
2041
+ capture_command_handler(subargs)
2042
+ } else if command == "init" {
2043
+ init_command_handler()
2044
+ } else if command == "validate" {
2045
+ validate_command_handler()
2046
+ } else if command == "doctor" {
2047
+ doctor_command_handler()
2048
+ } else if command == "list" {
2049
+ list_command_handler()
2050
+ } else if command == "show" {
2051
+ show_command_handler(subargs)
2052
+ } else if command == "clean" {
2053
+ clean_command_handler(subargs)
2054
+ } else if command == "--help" || command == "-h" || command == "help" {
2055
+ usage()
2056
+ exit(0)
2057
+ } else {
2058
+ print("Unknown command: " + command)
2059
+ usage()
2060
+ exit(2)
2061
+ }