@dannylee1020/kkt 0.5.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 (50) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +275 -0
  3. package/assets/kkt-readme-modern.png +0 -0
  4. package/bin/kkt-install.mjs +25 -0
  5. package/cmd/kkt/main.go +15 -0
  6. package/go.mod +3 -0
  7. package/internal/workflow/cli.go +349 -0
  8. package/internal/workflow/cli_test.go +1266 -0
  9. package/internal/workflow/guardrails.go +900 -0
  10. package/internal/workflow/init.go +164 -0
  11. package/internal/workflow/init_test.go +113 -0
  12. package/internal/workflow/operations.go +1855 -0
  13. package/internal/workflow/validation_commands.go +291 -0
  14. package/internal/workflow/workspace.go +802 -0
  15. package/internal/workflow/workspace_test.go +285 -0
  16. package/package.json +45 -0
  17. package/scripts/install-cli.sh +210 -0
  18. package/scripts/install.sh +644 -0
  19. package/skills/kkt/SKILL.md +74 -0
  20. package/skills/kkt/references/discovery-tooling.md +53 -0
  21. package/skills/kkt/references/feature-optimization-model.md +120 -0
  22. package/skills/kkt/references/kkt-kernel.md +58 -0
  23. package/skills/kkt/references/layered-modeling-methods.md +101 -0
  24. package/skills/kkt/references/plan-assimilation.md +46 -0
  25. package/skills/kkt/references/schemas.md +231 -0
  26. package/skills/kkt/references/state-contract.md +76 -0
  27. package/skills/kkt-loop/SKILL.md +99 -0
  28. package/skills/kkt-loop/references/discovery-tooling.md +53 -0
  29. package/skills/kkt-loop/references/feature-optimization-model.md +120 -0
  30. package/skills/kkt-loop/references/kkt-kernel.md +58 -0
  31. package/skills/kkt-loop/references/layered-modeling-methods.md +101 -0
  32. package/skills/kkt-loop/references/plan-assimilation.md +46 -0
  33. package/skills/kkt-loop/references/schemas.md +231 -0
  34. package/skills/kkt-loop/references/state-contract.md +76 -0
  35. package/skills/kkt-model/SKILL.md +76 -0
  36. package/skills/kkt-model/references/discovery-tooling.md +53 -0
  37. package/skills/kkt-model/references/feature-optimization-model.md +120 -0
  38. package/skills/kkt-model/references/kkt-kernel.md +58 -0
  39. package/skills/kkt-model/references/layered-modeling-methods.md +101 -0
  40. package/skills/kkt-model/references/plan-assimilation.md +46 -0
  41. package/skills/kkt-model/references/schemas.md +231 -0
  42. package/skills/kkt-model/references/state-contract.md +76 -0
  43. package/skills/kkt-run/SKILL.md +62 -0
  44. package/skills/kkt-run/references/discovery-tooling.md +53 -0
  45. package/skills/kkt-run/references/feature-optimization-model.md +120 -0
  46. package/skills/kkt-run/references/kkt-kernel.md +58 -0
  47. package/skills/kkt-run/references/layered-modeling-methods.md +101 -0
  48. package/skills/kkt-run/references/plan-assimilation.md +46 -0
  49. package/skills/kkt-run/references/schemas.md +231 -0
  50. package/skills/kkt-run/references/state-contract.md +76 -0
@@ -0,0 +1,1855 @@
1
+ package workflow
2
+
3
+ import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "os"
10
+ "path/filepath"
11
+ "strconv"
12
+ "strings"
13
+ "time"
14
+ )
15
+
16
+ type LoopTask struct {
17
+ ID string
18
+ Title string
19
+ Status string
20
+ }
21
+
22
+ type LoopCriterion struct {
23
+ ID string
24
+ Text string
25
+ Status string
26
+ }
27
+
28
+ type LoopEvidence struct {
29
+ ID string
30
+ Summary string
31
+ Status string
32
+ Criteria []string
33
+ Command string
34
+ }
35
+
36
+ type LoopStopCondition struct {
37
+ ID string
38
+ Text string
39
+ Status string
40
+ }
41
+
42
+ type LoopState struct {
43
+ CurrentTask string
44
+ Tasks []LoopTask
45
+ AcceptanceCriteria []LoopCriterion
46
+ Evidence []LoopEvidence
47
+ StopConditions []LoopStopCondition
48
+ }
49
+
50
+ type NextAction struct {
51
+ SchemaVersion int `json:"schema_version"`
52
+ Action string `json:"action"`
53
+ Reason string `json:"reason"`
54
+ TaskID string `json:"task_id,omitempty"`
55
+ CriterionID string `json:"criterion_id,omitempty"`
56
+ StopCondition string `json:"stop_condition,omitempty"`
57
+ Blocked bool `json:"blocked"`
58
+ Requires []string `json:"requires,omitempty"`
59
+ EvidenceRequired []string `json:"evidence_required,omitempty"`
60
+ Instruction string `json:"instruction"`
61
+ }
62
+
63
+ type EventEntry struct {
64
+ SchemaVersion int `json:"schema_version"`
65
+ Time string `json:"time"`
66
+ Type string `json:"type"`
67
+ WorkspaceStatus string `json:"workspace_status,omitempty"`
68
+ ActiveLayer string `json:"active_layer,omitempty"`
69
+ Actor string `json:"actor"`
70
+ Data map[string]any `json:"data,omitempty"`
71
+ }
72
+
73
+ type EvidenceOptions struct {
74
+ Criteria []string
75
+ Command string
76
+ Content string
77
+ }
78
+
79
+ type ArtifactOptions struct {
80
+ Method string
81
+ Evidence EvidenceOptions
82
+ Content string
83
+ }
84
+
85
+ func runShow(args []string, stdout io.Writer) error {
86
+ if len(args) > 1 {
87
+ return errors.New("show accepts at most one artifact")
88
+ }
89
+ workspace, err := ResolveWorkspace(".", "")
90
+ if err != nil {
91
+ return err
92
+ }
93
+ artifact := "state"
94
+ if len(args) == 1 {
95
+ artifact = args[0]
96
+ }
97
+ state, err := ReadState(workspace)
98
+ if err != nil {
99
+ return err
100
+ }
101
+ if state.WorkspaceType == "plan" && isWorkflowArtifact(artifact) {
102
+ artifact = "state"
103
+ }
104
+ path, err := artifactPath(workspace, artifact)
105
+ if err != nil {
106
+ return err
107
+ }
108
+ content, err := os.ReadFile(path)
109
+ if err != nil {
110
+ return err
111
+ }
112
+ _, err = stdout.Write(content)
113
+ return err
114
+ }
115
+
116
+ func runArtifact(artifact string, args []string, stdout io.Writer) error {
117
+ workspace, err := ResolveWorkspace(".", "")
118
+ if err != nil {
119
+ return err
120
+ }
121
+ state, err := ReadState(workspace)
122
+ if err != nil {
123
+ return err
124
+ }
125
+ options, err := parseArtifactArgs(artifact, args)
126
+ if err != nil {
127
+ return err
128
+ }
129
+ contentArgs := []string{options.Content}
130
+ if options.Content == "" {
131
+ contentArgs = nil
132
+ }
133
+ content, err := commandContent(contentArgs)
134
+ if err != nil {
135
+ return err
136
+ }
137
+ if state.WorkspaceType == "plan" {
138
+ return runPlanArtifact(workspace, artifact, content, options.Method, stdout)
139
+ }
140
+ path, err := artifactPath(workspace, artifact)
141
+ if err != nil {
142
+ return err
143
+ }
144
+ if strings.TrimSpace(content) == "" {
145
+ fileContent, readErr := os.ReadFile(path)
146
+ if readErr != nil {
147
+ return readErr
148
+ }
149
+ _, writeErr := stdout.Write(fileContent)
150
+ return writeErr
151
+ }
152
+ if err := appendArtifact(path, artifact, content); err != nil {
153
+ return err
154
+ }
155
+ if err := markArtifactRecorded(path, artifact); err != nil {
156
+ return err
157
+ }
158
+ if artifact == "evidence" {
159
+ options.Evidence.Content = content
160
+ }
161
+ if err := updateStateForArtifact(workspace, artifact, content, options.Method, options.Evidence); err != nil {
162
+ return err
163
+ }
164
+ fmt.Fprintf(stdout, "recorded: %s\n", artifact)
165
+ return nil
166
+ }
167
+
168
+ func runPlanArtifact(workspace, artifact, content, method string, stdout io.Writer) error {
169
+ path := filepath.Join(workspace, "kkt.yaml")
170
+ if strings.TrimSpace(content) == "" {
171
+ fileContent, err := os.ReadFile(path)
172
+ if err != nil {
173
+ return err
174
+ }
175
+ _, writeErr := stdout.Write(fileContent)
176
+ return writeErr
177
+ }
178
+ if artifact == "model" {
179
+ missing := missingPlanModelFields(content)
180
+ if len(missing) > 0 {
181
+ return fmt.Errorf("plan model missing required fields: %s", strings.Join(missing, ", "))
182
+ }
183
+ }
184
+ if err := appendPlanStateEntry(workspace, artifact, content); err != nil {
185
+ return err
186
+ }
187
+ if artifact == "model" {
188
+ if err := markPlanContractComplete(workspace); err != nil {
189
+ return err
190
+ }
191
+ }
192
+ if err := updateStateForArtifact(workspace, artifact, content, method, EvidenceOptions{}); err != nil {
193
+ return err
194
+ }
195
+ fmt.Fprintf(stdout, "recorded: %s\n", artifact)
196
+ return nil
197
+ }
198
+
199
+ func missingPlanModelFields(content string) []string {
200
+ text := strings.ToLower(content)
201
+ var missing []string
202
+ for _, field := range requiredPlanContractFields() {
203
+ if field == "planning_contract" {
204
+ continue
205
+ }
206
+ if !strings.Contains(text, field) {
207
+ missing = append(missing, field)
208
+ }
209
+ }
210
+ return missing
211
+ }
212
+
213
+ func runApprove(args []string, stdout io.Writer) error {
214
+ workspace, err := ResolveWorkspace(".", "")
215
+ if err != nil {
216
+ return err
217
+ }
218
+ scope := strings.TrimSpace(strings.Join(args, " "))
219
+ if scope == "" {
220
+ scope = "Approved selected KKT model."
221
+ }
222
+ if err := updateApproval(workspace, "approved", scope); err != nil {
223
+ return err
224
+ }
225
+ if err := writeApprovalBaseline(workspace); err != nil {
226
+ return err
227
+ }
228
+ if err := updateTopLevelState(workspace, "status", "approved"); err != nil {
229
+ return err
230
+ }
231
+ if err := appendEvent(workspace, "approval_granted", map[string]any{"scope": scope}); err != nil {
232
+ return err
233
+ }
234
+ fmt.Fprintf(stdout, "approved: %s\n", scope)
235
+ return nil
236
+ }
237
+
238
+ func runBlock(args []string, stdout io.Writer) error {
239
+ workspace, err := ResolveWorkspace(".", "")
240
+ if err != nil {
241
+ return err
242
+ }
243
+ reason, err := commandContent(args)
244
+ if err != nil {
245
+ return err
246
+ }
247
+ reason = strings.TrimSpace(reason)
248
+ if reason == "" {
249
+ return errors.New("block requires a reason")
250
+ }
251
+ if err := updateTopLevelState(workspace, "status", "blocked"); err != nil {
252
+ return err
253
+ }
254
+ loop, err := readLoopState(workspace)
255
+ if err == nil {
256
+ loop.StopConditions = append(loop.StopConditions, LoopStopCondition{
257
+ ID: uniqueID("block-" + slugify(reason)),
258
+ Text: reason,
259
+ Status: "active",
260
+ })
261
+ if writeErr := writeLoopState(workspace, loop); writeErr != nil {
262
+ return writeErr
263
+ }
264
+ }
265
+ if err := appendEvent(workspace, "blocked", map[string]any{"reason": reason}); err != nil {
266
+ return err
267
+ }
268
+ fmt.Fprintf(stdout, "blocked: %s\n", reason)
269
+ return nil
270
+ }
271
+
272
+ func runResume(args []string, stdout io.Writer) error {
273
+ if len(args) > 1 {
274
+ return errors.New("resume accepts at most one workspace path")
275
+ }
276
+ workspace, err := ResolveWorkspace(".", firstArg(args))
277
+ if err != nil {
278
+ return err
279
+ }
280
+ state, err := ReadState(workspace)
281
+ if err != nil {
282
+ return err
283
+ }
284
+ fmt.Fprintf(stdout, "workspace: %s\n", workspace)
285
+ fmt.Fprintf(stdout, "status: %s\n", state.Status)
286
+ fmt.Fprintf(stdout, "active_layer: %s\n", state.ActiveLayer)
287
+ fmt.Fprintf(stdout, "approval: %s\n", state.ApprovalStatus)
288
+ if loop, loopErr := readLoopState(workspace); loopErr == nil {
289
+ printResumeLoop(stdout, loop)
290
+ }
291
+ result, validationErr := ValidateWorkspace(workspace)
292
+ if validationErr == nil {
293
+ if result.OK {
294
+ fmt.Fprintln(stdout, "validation: valid")
295
+ } else {
296
+ fmt.Fprintln(stdout, "validation: invalid")
297
+ for _, issue := range result.Issues {
298
+ fmt.Fprintf(stdout, "- %s\n", issue)
299
+ }
300
+ }
301
+ }
302
+ events, eventsErr := readEvents(workspace, 5)
303
+ if eventsErr == nil && len(events) > 0 {
304
+ fmt.Fprintln(stdout, "recent_events:")
305
+ for _, event := range events {
306
+ fmt.Fprintf(stdout, "- %s %s %s\n", event.Time, event.Type, eventDataSummary(event.Data))
307
+ }
308
+ }
309
+ fmt.Fprintln(stdout, nextInstructionForWorkspace(workspace, state))
310
+ return nil
311
+ }
312
+
313
+ func runDone(args []string, stdout io.Writer) error {
314
+ workspace, err := ResolveWorkspace(".", "")
315
+ if err != nil {
316
+ return err
317
+ }
318
+ result, err := ValidateWorkspace(workspace)
319
+ if err != nil {
320
+ return err
321
+ }
322
+ if !result.OK {
323
+ for _, issue := range result.Issues {
324
+ fmt.Fprintf(stdout, "- %s\n", issue)
325
+ }
326
+ return errors.New("workspace validation failed")
327
+ }
328
+ state, err := ReadState(workspace)
329
+ if err != nil {
330
+ return err
331
+ }
332
+ if issues := completeLayerIssues(workspace, state.WorkspaceType); len(issues) > 0 {
333
+ for _, issue := range issues {
334
+ fmt.Fprintf(stdout, "- %s\n", issue)
335
+ }
336
+ return errors.New("workspace validation failed")
337
+ }
338
+ summary := strings.TrimSpace(strings.Join(args, " "))
339
+ if summary == "" {
340
+ summary = "KKT workflow complete."
341
+ }
342
+ if err := updateTopLevelState(workspace, "status", "complete"); err != nil {
343
+ return err
344
+ }
345
+ if err := updateTopLevelState(workspace, "active_layer", "validation"); err != nil {
346
+ return err
347
+ }
348
+ if err := appendEvent(workspace, "done", map[string]any{"summary": summary}); err != nil {
349
+ return err
350
+ }
351
+ fmt.Fprintf(stdout, "complete: %s\n", workspace)
352
+ return nil
353
+ }
354
+
355
+ func runTask(args []string, stdout io.Writer) error {
356
+ workspace, err := ResolveWorkspace(".", "")
357
+ if err != nil {
358
+ return err
359
+ }
360
+ loop, err := readLoopState(workspace)
361
+ if err != nil {
362
+ return err
363
+ }
364
+ if len(args) == 0 {
365
+ return printTasks(stdout, loop)
366
+ }
367
+ action := args[0]
368
+ switch action {
369
+ case "add":
370
+ title := strings.TrimSpace(strings.Join(args[1:], " "))
371
+ if title == "" {
372
+ return errors.New("task add requires a title")
373
+ }
374
+ task := LoopTask{ID: uniqueTaskID(loop, slugify(title)), Title: title, Status: "pending"}
375
+ loop.Tasks = append(loop.Tasks, task)
376
+ if err := writeLoopState(workspace, loop); err != nil {
377
+ return err
378
+ }
379
+ if err := appendEvent(workspace, "task_added", map[string]any{"task": task.ID, "title": title}); err != nil {
380
+ return err
381
+ }
382
+ fmt.Fprintf(stdout, "task added: %s\n", task.ID)
383
+ return nil
384
+ case "start", "done", "skip", "block":
385
+ id := strings.TrimSpace(strings.Join(args[1:], " "))
386
+ if id == "" {
387
+ id = loop.CurrentTask
388
+ }
389
+ if id == "" {
390
+ return fmt.Errorf("task %s requires a task id", action)
391
+ }
392
+ nextStatus := map[string]string{
393
+ "start": "active",
394
+ "done": "done",
395
+ "skip": "skipped",
396
+ "block": "blocked",
397
+ }[action]
398
+ if err := setTaskStatus(&loop, id, nextStatus); err != nil {
399
+ return err
400
+ }
401
+ if nextStatus == "active" {
402
+ loop.CurrentTask = id
403
+ }
404
+ if loop.CurrentTask == id && (nextStatus == "done" || nextStatus == "skipped" || nextStatus == "blocked") {
405
+ loop.CurrentTask = ""
406
+ }
407
+ if nextStatus == "blocked" {
408
+ if err := updateTopLevelState(workspace, "status", "blocked"); err != nil {
409
+ return err
410
+ }
411
+ }
412
+ if err := writeLoopState(workspace, loop); err != nil {
413
+ return err
414
+ }
415
+ if err := appendEvent(workspace, "task_"+action, map[string]any{"task": id}); err != nil {
416
+ return err
417
+ }
418
+ fmt.Fprintf(stdout, "task %s: %s\n", action, id)
419
+ return nil
420
+ default:
421
+ return fmt.Errorf("unsupported task action %q", action)
422
+ }
423
+ }
424
+
425
+ func runCriteria(args []string, stdout io.Writer) error {
426
+ workspace, err := ResolveWorkspace(".", "")
427
+ if err != nil {
428
+ return err
429
+ }
430
+ loop, err := readLoopState(workspace)
431
+ if err != nil {
432
+ return err
433
+ }
434
+ if len(args) == 0 {
435
+ if len(loop.AcceptanceCriteria) == 0 {
436
+ fmt.Fprintln(stdout, "criteria: none")
437
+ return nil
438
+ }
439
+ for _, criterion := range loop.AcceptanceCriteria {
440
+ fmt.Fprintf(stdout, "- %s [%s] %s\n", criterion.ID, criterion.Status, criterion.Text)
441
+ }
442
+ return nil
443
+ }
444
+ action := args[0]
445
+ switch action {
446
+ case "add":
447
+ text := strings.TrimSpace(strings.Join(args[1:], " "))
448
+ if text == "" {
449
+ return errors.New("criteria add requires text")
450
+ }
451
+ criterion := LoopCriterion{ID: uniqueCriterionID(loop, slugify(text)), Text: text, Status: "pending"}
452
+ loop.AcceptanceCriteria = append(loop.AcceptanceCriteria, criterion)
453
+ if err := writeLoopState(workspace, loop); err != nil {
454
+ return err
455
+ }
456
+ if err := appendEvent(workspace, "criterion_added", map[string]any{"criterion": criterion.ID, "text": text}); err != nil {
457
+ return err
458
+ }
459
+ fmt.Fprintf(stdout, "criterion added: %s\n", criterion.ID)
460
+ return nil
461
+ case "satisfy", "block":
462
+ id := strings.TrimSpace(strings.Join(args[1:], " "))
463
+ if id == "" {
464
+ return fmt.Errorf("criteria %s requires a criterion id", action)
465
+ }
466
+ status := "satisfied"
467
+ if action == "block" {
468
+ status = "blocked"
469
+ }
470
+ if err := setCriterionStatus(&loop, id, status); err != nil {
471
+ return err
472
+ }
473
+ if status == "blocked" {
474
+ if err := updateTopLevelState(workspace, "status", "blocked"); err != nil {
475
+ return err
476
+ }
477
+ }
478
+ if err := writeLoopState(workspace, loop); err != nil {
479
+ return err
480
+ }
481
+ if err := appendEvent(workspace, "criterion_"+action, map[string]any{"criterion": id}); err != nil {
482
+ return err
483
+ }
484
+ fmt.Fprintf(stdout, "criterion %s: %s\n", action, id)
485
+ return nil
486
+ default:
487
+ return fmt.Errorf("unsupported criteria action %q", action)
488
+ }
489
+ }
490
+
491
+ func commandContent(args []string) (string, error) {
492
+ if len(args) > 0 {
493
+ return strings.Join(args, " "), nil
494
+ }
495
+ stdin, err := os.Stdin.Stat()
496
+ if err != nil {
497
+ return "", err
498
+ }
499
+ if stdin.Mode()&os.ModeCharDevice == 0 {
500
+ var builder strings.Builder
501
+ scanner := bufio.NewScanner(os.Stdin)
502
+ for scanner.Scan() {
503
+ builder.WriteString(scanner.Text())
504
+ builder.WriteByte('\n')
505
+ }
506
+ if err := scanner.Err(); err != nil {
507
+ return "", err
508
+ }
509
+ return strings.TrimRight(builder.String(), "\n"), nil
510
+ }
511
+ return strings.Join(args, " "), nil
512
+ }
513
+
514
+ func parseArtifactArgs(artifact string, args []string) (ArtifactOptions, error) {
515
+ options := ArtifactOptions{}
516
+ content := []string{}
517
+ for i := 0; i < len(args); i++ {
518
+ arg := args[i]
519
+ switch arg {
520
+ case "--method":
521
+ i++
522
+ if i >= len(args) || strings.TrimSpace(args[i]) == "" {
523
+ return ArtifactOptions{}, fmt.Errorf("%s --method requires a method", artifact)
524
+ }
525
+ if !artifactAcceptsMethod(artifact) {
526
+ return ArtifactOptions{}, fmt.Errorf("%s does not accept flag: --method", artifact)
527
+ }
528
+ method := strings.TrimSpace(args[i])
529
+ if !validLayerMethod(artifact, method) {
530
+ return ArtifactOptions{}, fmt.Errorf("unsupported %s method: %s", artifact, method)
531
+ }
532
+ options.Method = method
533
+ case "--for":
534
+ if artifact != "evidence" {
535
+ return ArtifactOptions{}, fmt.Errorf("%s does not accept flag: --for", artifact)
536
+ }
537
+ i++
538
+ if i >= len(args) || strings.TrimSpace(args[i]) == "" {
539
+ return ArtifactOptions{}, errors.New("evidence --for requires a criterion id")
540
+ }
541
+ options.Evidence.Criteria = append(options.Evidence.Criteria, splitCSV(args[i])...)
542
+ case "--command":
543
+ if artifact != "evidence" {
544
+ return ArtifactOptions{}, fmt.Errorf("%s does not accept flag: --command", artifact)
545
+ }
546
+ i++
547
+ if i >= len(args) || strings.TrimSpace(args[i]) == "" {
548
+ return ArtifactOptions{}, errors.New("evidence --command requires a command")
549
+ }
550
+ options.Evidence.Command = strings.TrimSpace(args[i])
551
+ default:
552
+ if strings.HasPrefix(arg, "-") {
553
+ return ArtifactOptions{}, fmt.Errorf("%s does not accept flag: %s", artifact, arg)
554
+ }
555
+ content = append(content, arg)
556
+ }
557
+ }
558
+ options.Evidence.Criteria = uniqueStrings(options.Evidence.Criteria)
559
+ options.Content = strings.TrimSpace(strings.Join(content, " "))
560
+ return options, nil
561
+ }
562
+
563
+ func artifactAcceptsMethod(artifact string) bool {
564
+ switch artifact {
565
+ case "intent", "discovery", "model":
566
+ return true
567
+ default:
568
+ return false
569
+ }
570
+ }
571
+
572
+ func validLayerMethod(artifact, method string) bool {
573
+ allowed := map[string][]string{
574
+ "intent": {"goal_anti_goal", "why_how", "obstacle_questions", "pairwise_questions"},
575
+ "discovery": {"naive", "traceability_matrix", "coupling_map", "dsm_lite"},
576
+ "model": {"lexicographic", "decision_tree", "shortest_path", "ordinal_mcda", "pairwise_ahp", "outranking"},
577
+ }
578
+ for _, candidate := range allowed[artifact] {
579
+ if method == candidate {
580
+ return true
581
+ }
582
+ }
583
+ return false
584
+ }
585
+
586
+ func artifactPath(workspace, artifact string) (string, error) {
587
+ switch artifact {
588
+ case "state", "yaml", "kkt":
589
+ return filepath.Join(workspace, "kkt.yaml"), nil
590
+ case "guardrails":
591
+ return filepath.Join(workspace, "guardrails.json"), nil
592
+ case "intent", "discovery", "model", "plan", "progress", "evidence", "notes":
593
+ return filepath.Join(workspace, artifact+".md"), nil
594
+ case "events", "log":
595
+ return filepath.Join(workspace, "events.jsonl"), nil
596
+ default:
597
+ return "", fmt.Errorf("unsupported artifact %q", artifact)
598
+ }
599
+ }
600
+
601
+ func isWorkflowArtifact(artifact string) bool {
602
+ switch artifact {
603
+ case "intent", "discovery", "model", "plan", "progress", "evidence", "notes":
604
+ return true
605
+ default:
606
+ return false
607
+ }
608
+ }
609
+
610
+ func appendArtifact(path, artifact, content string) error {
611
+ if _, err := os.Stat(path); os.IsNotExist(err) {
612
+ title := artifactTitle(artifact)
613
+ if err := os.WriteFile(path, []byte("# "+title+"\n\n"), 0o644); err != nil {
614
+ return err
615
+ }
616
+ } else if err != nil {
617
+ return err
618
+ }
619
+ entry := fmt.Sprintf("\n## %s\n\n%s\n", time.Now().UTC().Format(time.RFC3339), strings.TrimSpace(content))
620
+ file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
621
+ if err != nil {
622
+ return err
623
+ }
624
+ defer file.Close()
625
+ _, err = file.WriteString(entry)
626
+ return err
627
+ }
628
+
629
+ func markArtifactRecorded(path, artifact string) error {
630
+ if artifact == "notes" {
631
+ return nil
632
+ }
633
+ content, err := os.ReadFile(path)
634
+ if err != nil {
635
+ return err
636
+ }
637
+ next := strings.Replace(string(content), "Status: pending", "Status: recorded", 1)
638
+ if next == string(content) {
639
+ return nil
640
+ }
641
+ return os.WriteFile(path, []byte(next), 0o644)
642
+ }
643
+
644
+ func updateStateForArtifact(workspace, artifact, content, method string, evidenceOptions EvidenceOptions) error {
645
+ state, err := ReadState(workspace)
646
+ if err != nil {
647
+ return err
648
+ }
649
+ layerForArtifact := map[string]string{
650
+ "intent": "intent",
651
+ "discovery": "discovery",
652
+ "model": "modeling",
653
+ "plan": "execution",
654
+ "evidence": "validation",
655
+ }
656
+ if layer, ok := layerForArtifact[artifact]; ok {
657
+ if err := updateLayerState(workspace, layer, "complete", method, firstLine(content)); err != nil {
658
+ return err
659
+ }
660
+ if method != "" {
661
+ if err := appendMethodInvocation(workspace, layer, method, content); err != nil {
662
+ return err
663
+ }
664
+ }
665
+ if err := updateTopLevelState(workspace, "active_layer", nextActiveLayer(state, artifact)); err != nil {
666
+ return err
667
+ }
668
+ }
669
+ if artifact == "evidence" {
670
+ loop, err := readLoopState(workspace)
671
+ if err == nil {
672
+ summary := firstLine(content)
673
+ if summary == "" {
674
+ summary = "Evidence recorded."
675
+ }
676
+ loop.Evidence = append(loop.Evidence, LoopEvidence{
677
+ ID: uniqueID("evidence"),
678
+ Summary: summary,
679
+ Status: "recorded",
680
+ Criteria: evidenceOptions.Criteria,
681
+ Command: evidenceOptions.Command,
682
+ })
683
+ if writeErr := writeLoopState(workspace, loop); writeErr != nil {
684
+ return writeErr
685
+ }
686
+ }
687
+ }
688
+ data := map[string]any{"summary": firstLine(content)}
689
+ if method != "" {
690
+ data["method"] = method
691
+ }
692
+ if artifact == "evidence" {
693
+ if len(evidenceOptions.Criteria) > 0 {
694
+ data["criteria"] = evidenceOptions.Criteria
695
+ }
696
+ if evidenceOptions.Command != "" {
697
+ data["command"] = evidenceOptions.Command
698
+ }
699
+ }
700
+ return appendEvent(workspace, artifact+"_recorded", data)
701
+ }
702
+
703
+ func nextActiveLayer(state State, artifact string) string {
704
+ switch artifact {
705
+ case "intent":
706
+ return "discovery"
707
+ case "discovery":
708
+ return "modeling"
709
+ case "model":
710
+ if state.WorkspaceType == "model" {
711
+ return "validation"
712
+ }
713
+ if state.WorkspaceType == "loop" || state.WorkspaceType == "run" {
714
+ return "execution"
715
+ }
716
+ return "modeling"
717
+ case "plan":
718
+ return "execution"
719
+ case "evidence":
720
+ return "validation"
721
+ default:
722
+ return state.ActiveLayer
723
+ }
724
+ }
725
+
726
+ func updateLayerState(workspace, layer, status, method, summary string) error {
727
+ path := filepath.Join(workspace, "kkt.yaml")
728
+ content, err := os.ReadFile(path)
729
+ if err != nil {
730
+ return err
731
+ }
732
+ lines := strings.Split(string(content), "\n")
733
+ layerLine := " " + layer + ":"
734
+ start := -1
735
+ end := len(lines)
736
+ for i, line := range lines {
737
+ if line == layerLine {
738
+ start = i
739
+ continue
740
+ }
741
+ if start >= 0 && i > start {
742
+ if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") {
743
+ end = i
744
+ break
745
+ }
746
+ if line != "" && !strings.HasPrefix(line, " ") {
747
+ end = i
748
+ break
749
+ }
750
+ }
751
+ }
752
+ if start < 0 {
753
+ return nil
754
+ }
755
+ if summary == "" {
756
+ summary = "Recorded " + layer + "."
757
+ }
758
+ fields := map[string]string{
759
+ "status": status,
760
+ "summary": summary,
761
+ }
762
+ if method != "" {
763
+ fields["method"] = method
764
+ }
765
+ seen := map[string]bool{}
766
+ for i := start + 1; i < end; i++ {
767
+ trimmed := strings.TrimSpace(lines[i])
768
+ key, _, ok := strings.Cut(trimmed, ":")
769
+ if !ok {
770
+ continue
771
+ }
772
+ value, exists := fields[key]
773
+ if !exists {
774
+ continue
775
+ }
776
+ lines[i] = " " + key + ": " + quoteYAML(value)
777
+ seen[key] = true
778
+ }
779
+ insert := []string{}
780
+ for _, key := range []string{"status", "method", "summary"} {
781
+ value, exists := fields[key]
782
+ if !exists || seen[key] {
783
+ continue
784
+ }
785
+ insert = append(insert, " "+key+": "+quoteYAML(value))
786
+ }
787
+ if len(insert) > 0 {
788
+ next := append([]string{}, lines[:end]...)
789
+ next = append(next, insert...)
790
+ next = append(next, lines[end:]...)
791
+ lines = next
792
+ }
793
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
794
+ }
795
+
796
+ func markValidationLayerComplete(workspace string) error {
797
+ if err := updateLayerState(workspace, "validation", "complete", "validated", "Workspace validation passed."); err != nil {
798
+ return err
799
+ }
800
+ return updateTopLevelState(workspace, "active_layer", "validation")
801
+ }
802
+
803
+ func appendMethodInvocation(workspace, layer, method, content string) error {
804
+ path := filepath.Join(workspace, "kkt.yaml")
805
+ fileContent, err := os.ReadFile(path)
806
+ if err != nil {
807
+ return err
808
+ }
809
+ summary := firstLine(content)
810
+ if summary == "" {
811
+ summary = "Recorded " + layer + "."
812
+ }
813
+ entry := []string{
814
+ " - layer: " + quoteYAML(layer),
815
+ " method: " + quoteYAML(method),
816
+ " reason: " + quoteYAML(summary),
817
+ " inputs: " + quoteYAML("current workspace state"),
818
+ " outputs: " + quoteYAML(layerArtifact(layer)),
819
+ }
820
+ lines := strings.Split(string(fileContent), "\n")
821
+ for i, line := range lines {
822
+ if strings.TrimSpace(line) == "method_invocations: []" {
823
+ next := append([]string{}, lines[:i]...)
824
+ next = append(next, "method_invocations:")
825
+ next = append(next, entry...)
826
+ next = append(next, lines[i+1:]...)
827
+ return os.WriteFile(path, []byte(strings.Join(next, "\n")), 0o644)
828
+ }
829
+ if strings.TrimSpace(line) == "method_invocations:" {
830
+ end := len(lines)
831
+ for j := i + 1; j < len(lines); j++ {
832
+ if lines[j] != "" && !strings.HasPrefix(lines[j], " ") {
833
+ end = j
834
+ break
835
+ }
836
+ }
837
+ next := append([]string{}, lines[:end]...)
838
+ next = append(next, entry...)
839
+ next = append(next, lines[end:]...)
840
+ return os.WriteFile(path, []byte(strings.Join(next, "\n")), 0o644)
841
+ }
842
+ }
843
+ lines = append(lines, "method_invocations:")
844
+ lines = append(lines, entry...)
845
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
846
+ }
847
+
848
+ func layerArtifact(layer string) string {
849
+ switch layer {
850
+ case "modeling":
851
+ return "model.md"
852
+ case "execution":
853
+ return "plan.md"
854
+ case "validation":
855
+ return "evidence.md"
856
+ default:
857
+ return layer + ".md"
858
+ }
859
+ }
860
+
861
+ func updateTopLevelState(workspace, key, value string) error {
862
+ path := filepath.Join(workspace, "kkt.yaml")
863
+ content, err := os.ReadFile(path)
864
+ if err != nil {
865
+ return err
866
+ }
867
+ lines := strings.Split(string(content), "\n")
868
+ found := false
869
+ for i, line := range lines {
870
+ trimmed := strings.TrimSpace(line)
871
+ if strings.HasPrefix(line, " ") || strings.HasPrefix(trimmed, "-") {
872
+ continue
873
+ }
874
+ name, _, ok := strings.Cut(trimmed, ":")
875
+ if ok && name == key {
876
+ lines[i] = fmt.Sprintf("%s: %s", key, quoteYAML(value))
877
+ found = true
878
+ break
879
+ }
880
+ }
881
+ if !found {
882
+ lines = append(lines, fmt.Sprintf("%s: %s", key, quoteYAML(value)))
883
+ }
884
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
885
+ }
886
+
887
+ func updateApproval(workspace, status, scope string) error {
888
+ path := filepath.Join(workspace, "kkt.yaml")
889
+ content, err := os.ReadFile(path)
890
+ if err != nil {
891
+ return err
892
+ }
893
+ lines := strings.Split(string(content), "\n")
894
+ inApproval := false
895
+ statusSet := false
896
+ scopeSet := false
897
+ for i, line := range lines {
898
+ trimmed := strings.TrimSpace(line)
899
+ if line == "approval:" {
900
+ inApproval = true
901
+ continue
902
+ }
903
+ if inApproval && line != "" && !strings.HasPrefix(line, " ") {
904
+ inApproval = false
905
+ }
906
+ if !inApproval {
907
+ continue
908
+ }
909
+ if strings.HasPrefix(trimmed, "status:") {
910
+ lines[i] = " status: " + quoteYAML(status)
911
+ statusSet = true
912
+ }
913
+ if strings.HasPrefix(trimmed, "approved_scope:") {
914
+ lines[i] = " approved_scope: " + quoteYAML(scope)
915
+ scopeSet = true
916
+ }
917
+ }
918
+ if !statusSet || !scopeSet {
919
+ lines = append(lines, "approval:")
920
+ if !statusSet {
921
+ lines = append(lines, " status: "+quoteYAML(status))
922
+ }
923
+ if !scopeSet {
924
+ lines = append(lines, " approved_scope: "+quoteYAML(scope))
925
+ }
926
+ }
927
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
928
+ }
929
+
930
+ func appendPlanStateEntry(workspace, artifact, content string) error {
931
+ path := filepath.Join(workspace, "kkt.yaml")
932
+ fileContent, err := os.ReadFile(path)
933
+ if err != nil {
934
+ return err
935
+ }
936
+ summary := firstLine(content)
937
+ if summary == "" {
938
+ summary = strings.TrimSpace(content)
939
+ }
940
+ if summary == "" {
941
+ summary = "Recorded " + artifact + "."
942
+ }
943
+ entry := []string{
944
+ " - time: " + quoteYAML(time.Now().UTC().Format(time.RFC3339)),
945
+ " artifact: " + quoteYAML(artifact),
946
+ " summary: " + quoteYAML(summary),
947
+ }
948
+ lines := strings.Split(string(fileContent), "\n")
949
+ for i, line := range lines {
950
+ if strings.TrimSpace(line) == "decision_log: []" {
951
+ next := append([]string{}, lines[:i]...)
952
+ next = append(next, "decision_log:")
953
+ next = append(next, entry...)
954
+ next = append(next, lines[i+1:]...)
955
+ return os.WriteFile(path, []byte(strings.Join(next, "\n")), 0o644)
956
+ }
957
+ if strings.TrimSpace(line) == "decision_log:" {
958
+ end := len(lines)
959
+ for j := i + 1; j < len(lines); j++ {
960
+ if lines[j] != "" && !strings.HasPrefix(lines[j], " ") {
961
+ end = j
962
+ break
963
+ }
964
+ }
965
+ next := append([]string{}, lines[:end]...)
966
+ next = append(next, entry...)
967
+ next = append(next, lines[end:]...)
968
+ return os.WriteFile(path, []byte(strings.Join(next, "\n")), 0o644)
969
+ }
970
+ }
971
+ lines = append(lines, "decision_log:")
972
+ lines = append(lines, entry...)
973
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
974
+ }
975
+
976
+ func markPlanContractComplete(workspace string) error {
977
+ path := filepath.Join(workspace, "kkt.yaml")
978
+ content, err := os.ReadFile(path)
979
+ if err != nil {
980
+ return err
981
+ }
982
+ fields := map[string]bool{}
983
+ for _, field := range requiredPlanContractFields() {
984
+ if field != "planning_contract" {
985
+ fields[field] = true
986
+ }
987
+ }
988
+ lines := strings.Split(string(content), "\n")
989
+ activeField := ""
990
+ for i, line := range lines {
991
+ trimmed := strings.TrimSpace(line)
992
+ if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") && strings.HasSuffix(trimmed, ":") {
993
+ field := strings.TrimSuffix(trimmed, ":")
994
+ if fields[field] {
995
+ activeField = field
996
+ } else {
997
+ activeField = ""
998
+ }
999
+ continue
1000
+ }
1001
+ if activeField != "" && strings.HasPrefix(strings.TrimSpace(line), "status:") {
1002
+ lines[i] = " status: complete"
1003
+ }
1004
+ }
1005
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
1006
+ }
1007
+
1008
+ func appendEvent(workspace, eventType string, data map[string]any) error {
1009
+ state, err := ReadState(workspace)
1010
+ if err != nil {
1011
+ return err
1012
+ }
1013
+ if state.WorkspaceType != "loop" {
1014
+ return nil
1015
+ }
1016
+ return appendWorkspaceEvent(workspace, state, eventType, data)
1017
+ }
1018
+
1019
+ func appendWorkspaceEvent(workspace string, state State, eventType string, data map[string]any) error {
1020
+ entry := EventEntry{
1021
+ SchemaVersion: 1,
1022
+ Time: time.Now().UTC().Format(time.RFC3339),
1023
+ Type: eventType,
1024
+ WorkspaceStatus: state.Status,
1025
+ ActiveLayer: state.ActiveLayer,
1026
+ Actor: "cli",
1027
+ Data: data,
1028
+ }
1029
+ payload, err := json.Marshal(entry)
1030
+ if err != nil {
1031
+ return err
1032
+ }
1033
+ file, err := os.OpenFile(filepath.Join(workspace, "events.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
1034
+ if err != nil {
1035
+ return err
1036
+ }
1037
+ defer file.Close()
1038
+ if _, err := file.Write(payload); err != nil {
1039
+ return err
1040
+ }
1041
+ _, err = file.WriteString("\n")
1042
+ return err
1043
+ }
1044
+
1045
+ func appendValidationEvent(workspace string, result ValidationResult) error {
1046
+ state, err := ReadState(workspace)
1047
+ if err != nil {
1048
+ return err
1049
+ }
1050
+ eventType := "validation_passed"
1051
+ data := map[string]any{"ok": result.OK}
1052
+ if !result.OK {
1053
+ eventType = "validation_failed"
1054
+ data["issues"] = result.Issues
1055
+ }
1056
+ return appendWorkspaceEvent(workspace, state, eventType, data)
1057
+ }
1058
+
1059
+ func readEvents(workspace string, limit int) ([]EventEntry, error) {
1060
+ file, err := os.Open(filepath.Join(workspace, "events.jsonl"))
1061
+ if err != nil {
1062
+ if os.IsNotExist(err) {
1063
+ return nil, nil
1064
+ }
1065
+ return nil, err
1066
+ }
1067
+ defer file.Close()
1068
+ var events []EventEntry
1069
+ scanner := bufio.NewScanner(file)
1070
+ for scanner.Scan() {
1071
+ line := strings.TrimSpace(scanner.Text())
1072
+ if line == "" {
1073
+ continue
1074
+ }
1075
+ event, parseErr := parseEventLine(line)
1076
+ if parseErr != nil {
1077
+ return nil, parseErr
1078
+ }
1079
+ events = append(events, event)
1080
+ }
1081
+ if err := scanner.Err(); err != nil {
1082
+ return nil, err
1083
+ }
1084
+ if limit > 0 && len(events) > limit {
1085
+ return events[len(events)-limit:], nil
1086
+ }
1087
+ return events, nil
1088
+ }
1089
+
1090
+ func parseEventLine(line string) (EventEntry, error) {
1091
+ raw := map[string]any{}
1092
+ if err := json.Unmarshal([]byte(line), &raw); err != nil {
1093
+ return EventEntry{}, err
1094
+ }
1095
+ event := EventEntry{SchemaVersion: 1, Actor: "cli", Data: map[string]any{}}
1096
+ if value, ok := raw["schema_version"].(float64); ok {
1097
+ event.SchemaVersion = int(value)
1098
+ }
1099
+ if value, ok := raw["time"].(string); ok {
1100
+ event.Time = value
1101
+ }
1102
+ if value, ok := raw["type"].(string); ok {
1103
+ event.Type = value
1104
+ }
1105
+ if value, ok := raw["workspace_status"].(string); ok {
1106
+ event.WorkspaceStatus = value
1107
+ }
1108
+ if value, ok := raw["active_layer"].(string); ok {
1109
+ event.ActiveLayer = value
1110
+ }
1111
+ if value, ok := raw["actor"].(string); ok {
1112
+ event.Actor = value
1113
+ }
1114
+ if data, ok := raw["data"].(map[string]any); ok {
1115
+ event.Data = data
1116
+ } else {
1117
+ for key, value := range raw {
1118
+ switch key {
1119
+ case "schema_version", "time", "type", "workspace_status", "active_layer", "actor":
1120
+ continue
1121
+ default:
1122
+ event.Data[key] = value
1123
+ }
1124
+ }
1125
+ }
1126
+ return event, nil
1127
+ }
1128
+
1129
+ func readLoopState(workspace string) (LoopState, error) {
1130
+ state, err := ReadState(workspace)
1131
+ if err != nil {
1132
+ return LoopState{}, err
1133
+ }
1134
+ if state.WorkspaceType != "loop" {
1135
+ return LoopState{}, errors.New("loop state is only available for loop workspaces")
1136
+ }
1137
+ content, err := os.ReadFile(filepath.Join(workspace, "kkt.yaml"))
1138
+ if err != nil {
1139
+ return LoopState{}, err
1140
+ }
1141
+ loop := LoopState{}
1142
+ lines := strings.Split(string(content), "\n")
1143
+ inLoop := false
1144
+ section := ""
1145
+ var task *LoopTask
1146
+ var criterion *LoopCriterion
1147
+ var evidence *LoopEvidence
1148
+ var stop *LoopStopCondition
1149
+ flush := func() {
1150
+ if task != nil {
1151
+ loop.Tasks = append(loop.Tasks, *task)
1152
+ task = nil
1153
+ }
1154
+ if criterion != nil {
1155
+ loop.AcceptanceCriteria = append(loop.AcceptanceCriteria, *criterion)
1156
+ criterion = nil
1157
+ }
1158
+ if evidence != nil {
1159
+ loop.Evidence = append(loop.Evidence, *evidence)
1160
+ evidence = nil
1161
+ }
1162
+ if stop != nil {
1163
+ loop.StopConditions = append(loop.StopConditions, *stop)
1164
+ stop = nil
1165
+ }
1166
+ }
1167
+ for _, line := range lines {
1168
+ if line == "loop_state:" {
1169
+ inLoop = true
1170
+ continue
1171
+ }
1172
+ if inLoop && line != "" && !strings.HasPrefix(line, " ") {
1173
+ flush()
1174
+ break
1175
+ }
1176
+ if !inLoop {
1177
+ continue
1178
+ }
1179
+ trimmed := strings.TrimSpace(line)
1180
+ switch trimmed {
1181
+ case "tasks:":
1182
+ flush()
1183
+ section = "tasks"
1184
+ continue
1185
+ case "evidence:":
1186
+ flush()
1187
+ section = "evidence"
1188
+ continue
1189
+ case "acceptance_criteria:":
1190
+ flush()
1191
+ section = "acceptance_criteria"
1192
+ continue
1193
+ case "stop_conditions:":
1194
+ flush()
1195
+ section = "stop_conditions"
1196
+ continue
1197
+ }
1198
+ if strings.HasPrefix(trimmed, "current_task:") {
1199
+ loop.CurrentTask = unquoteYAML(strings.TrimSpace(strings.TrimPrefix(trimmed, "current_task:")))
1200
+ continue
1201
+ }
1202
+ if strings.HasPrefix(trimmed, "- id:") {
1203
+ flush()
1204
+ id := unquoteYAML(strings.TrimSpace(strings.TrimPrefix(trimmed, "- id:")))
1205
+ switch section {
1206
+ case "tasks":
1207
+ task = &LoopTask{ID: id}
1208
+ case "acceptance_criteria":
1209
+ criterion = &LoopCriterion{ID: id}
1210
+ case "evidence":
1211
+ evidence = &LoopEvidence{ID: id}
1212
+ case "stop_conditions":
1213
+ stop = &LoopStopCondition{ID: id}
1214
+ }
1215
+ continue
1216
+ }
1217
+ key, value, ok := strings.Cut(trimmed, ":")
1218
+ if !ok {
1219
+ continue
1220
+ }
1221
+ value = unquoteYAML(strings.TrimSpace(value))
1222
+ switch section {
1223
+ case "tasks":
1224
+ if task == nil {
1225
+ continue
1226
+ }
1227
+ if key == "title" {
1228
+ task.Title = value
1229
+ }
1230
+ if key == "status" {
1231
+ task.Status = value
1232
+ }
1233
+ case "acceptance_criteria":
1234
+ if criterion == nil {
1235
+ continue
1236
+ }
1237
+ if key == "text" {
1238
+ criterion.Text = value
1239
+ }
1240
+ if key == "status" {
1241
+ criterion.Status = value
1242
+ }
1243
+ case "evidence":
1244
+ if evidence == nil {
1245
+ continue
1246
+ }
1247
+ if key == "summary" {
1248
+ evidence.Summary = value
1249
+ }
1250
+ if key == "status" {
1251
+ evidence.Status = value
1252
+ }
1253
+ if key == "criteria" {
1254
+ evidence.Criteria = splitCSV(value)
1255
+ }
1256
+ if key == "command" {
1257
+ evidence.Command = value
1258
+ }
1259
+ case "stop_conditions":
1260
+ if stop == nil {
1261
+ continue
1262
+ }
1263
+ if key == "text" {
1264
+ stop.Text = value
1265
+ }
1266
+ if key == "status" {
1267
+ stop.Status = value
1268
+ }
1269
+ }
1270
+ }
1271
+ flush()
1272
+ return loop, nil
1273
+ }
1274
+
1275
+ func writeLoopState(workspace string, loop LoopState) error {
1276
+ path := filepath.Join(workspace, "kkt.yaml")
1277
+ content, err := os.ReadFile(path)
1278
+ if err != nil {
1279
+ return err
1280
+ }
1281
+ lines := strings.Split(string(content), "\n")
1282
+ start := -1
1283
+ end := len(lines)
1284
+ for i, line := range lines {
1285
+ if line == "loop_state:" {
1286
+ start = i
1287
+ continue
1288
+ }
1289
+ if start >= 0 && i > start && line != "" && !strings.HasPrefix(line, " ") {
1290
+ end = i
1291
+ break
1292
+ }
1293
+ }
1294
+ block := renderLoopState(loop)
1295
+ if start < 0 {
1296
+ lines = append(lines, strings.Split(block, "\n")...)
1297
+ } else {
1298
+ next := append([]string{}, lines[:start]...)
1299
+ next = append(next, strings.Split(block, "\n")...)
1300
+ next = append(next, lines[end:]...)
1301
+ lines = next
1302
+ }
1303
+ return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
1304
+ }
1305
+
1306
+ func renderLoopState(loop LoopState) string {
1307
+ lines := []string{
1308
+ "loop_state:",
1309
+ " current_task: " + quoteYAML(loop.CurrentTask),
1310
+ " tasks:",
1311
+ }
1312
+ for _, task := range loop.Tasks {
1313
+ lines = append(lines,
1314
+ " - id: "+quoteYAML(task.ID),
1315
+ " title: "+quoteYAML(task.Title),
1316
+ " status: "+quoteYAML(task.Status),
1317
+ )
1318
+ }
1319
+ lines = append(lines, " acceptance_criteria:")
1320
+ for _, criterion := range loop.AcceptanceCriteria {
1321
+ lines = append(lines,
1322
+ " - id: "+quoteYAML(criterion.ID),
1323
+ " text: "+quoteYAML(criterion.Text),
1324
+ " status: "+quoteYAML(criterion.Status),
1325
+ )
1326
+ }
1327
+ lines = append(lines, " evidence:")
1328
+ for _, evidence := range loop.Evidence {
1329
+ lines = append(lines,
1330
+ " - id: "+quoteYAML(evidence.ID),
1331
+ " summary: "+quoteYAML(evidence.Summary),
1332
+ " status: "+quoteYAML(evidence.Status),
1333
+ )
1334
+ if len(evidence.Criteria) > 0 {
1335
+ lines = append(lines, " criteria: "+quoteYAML(strings.Join(evidence.Criteria, ",")))
1336
+ }
1337
+ if evidence.Command != "" {
1338
+ lines = append(lines, " command: "+quoteYAML(evidence.Command))
1339
+ }
1340
+ }
1341
+ lines = append(lines, " stop_conditions:")
1342
+ for _, stop := range loop.StopConditions {
1343
+ lines = append(lines,
1344
+ " - id: "+quoteYAML(stop.ID),
1345
+ " text: "+quoteYAML(stop.Text),
1346
+ " status: "+quoteYAML(stop.Status),
1347
+ )
1348
+ }
1349
+ return strings.Join(lines, "\n")
1350
+ }
1351
+
1352
+ func setTaskStatus(loop *LoopState, id, status string) error {
1353
+ for i := range loop.Tasks {
1354
+ if loop.Tasks[i].ID == id {
1355
+ loop.Tasks[i].Status = status
1356
+ return nil
1357
+ }
1358
+ }
1359
+ return fmt.Errorf("unknown task %q", id)
1360
+ }
1361
+
1362
+ func setCriterionStatus(loop *LoopState, id, status string) error {
1363
+ for i := range loop.AcceptanceCriteria {
1364
+ if loop.AcceptanceCriteria[i].ID == id {
1365
+ loop.AcceptanceCriteria[i].Status = status
1366
+ return nil
1367
+ }
1368
+ }
1369
+ return fmt.Errorf("unknown criterion %q", id)
1370
+ }
1371
+
1372
+ func printTasks(stdout io.Writer, loop LoopState) error {
1373
+ if loop.CurrentTask != "" {
1374
+ fmt.Fprintf(stdout, "current_task: %s\n", loop.CurrentTask)
1375
+ }
1376
+ if len(loop.Tasks) == 0 {
1377
+ fmt.Fprintln(stdout, "tasks: none")
1378
+ return nil
1379
+ }
1380
+ for _, task := range loop.Tasks {
1381
+ fmt.Fprintf(stdout, "- %s [%s] %s\n", task.ID, task.Status, task.Title)
1382
+ }
1383
+ return nil
1384
+ }
1385
+
1386
+ func printResumeLoop(stdout io.Writer, loop LoopState) {
1387
+ if loop.CurrentTask != "" {
1388
+ fmt.Fprintf(stdout, "current_task: %s\n", loop.CurrentTask)
1389
+ }
1390
+ printTaskGroup(stdout, "pending_tasks", loop.Tasks, "pending")
1391
+ printTaskGroup(stdout, "blocked_tasks", loop.Tasks, "blocked")
1392
+ printCriterionGroup(stdout, "unsatisfied_criteria", loop.AcceptanceCriteria, "pending")
1393
+ printCriterionGroup(stdout, "blocked_criteria", loop.AcceptanceCriteria, "blocked")
1394
+ if len(loop.Evidence) > 0 {
1395
+ fmt.Fprintln(stdout, "latest_evidence:")
1396
+ start := len(loop.Evidence) - 3
1397
+ if start < 0 {
1398
+ start = 0
1399
+ }
1400
+ for _, evidence := range loop.Evidence[start:] {
1401
+ fmt.Fprintf(stdout, "- %s [%s] %s", evidence.ID, evidence.Status, evidence.Summary)
1402
+ if len(evidence.Criteria) > 0 {
1403
+ fmt.Fprintf(stdout, " (criteria: %s)", strings.Join(evidence.Criteria, ","))
1404
+ }
1405
+ if evidence.Command != "" {
1406
+ fmt.Fprintf(stdout, " (command: %s)", evidence.Command)
1407
+ }
1408
+ fmt.Fprintln(stdout)
1409
+ }
1410
+ }
1411
+ }
1412
+
1413
+ func printTaskGroup(stdout io.Writer, title string, tasks []LoopTask, status string) {
1414
+ var matches []LoopTask
1415
+ for _, task := range tasks {
1416
+ if task.Status == status {
1417
+ matches = append(matches, task)
1418
+ }
1419
+ }
1420
+ if len(matches) == 0 {
1421
+ return
1422
+ }
1423
+ fmt.Fprintf(stdout, "%s:\n", title)
1424
+ for _, task := range matches {
1425
+ fmt.Fprintf(stdout, "- %s %s\n", task.ID, task.Title)
1426
+ }
1427
+ }
1428
+
1429
+ func printCriterionGroup(stdout io.Writer, title string, criteria []LoopCriterion, status string) {
1430
+ var matches []LoopCriterion
1431
+ for _, criterion := range criteria {
1432
+ if criterion.Status == status {
1433
+ matches = append(matches, criterion)
1434
+ }
1435
+ }
1436
+ if len(matches) == 0 {
1437
+ return
1438
+ }
1439
+ fmt.Fprintf(stdout, "%s:\n", title)
1440
+ for _, criterion := range matches {
1441
+ fmt.Fprintf(stdout, "- %s %s\n", criterion.ID, criterion.Text)
1442
+ }
1443
+ }
1444
+
1445
+ func nextInstructionForWorkspace(workspace string, state State) string {
1446
+ return nextActionForWorkspace(workspace, state).Instruction
1447
+ }
1448
+
1449
+ func statusReport(workspace string, state State) (StatusReport, error) {
1450
+ validation, err := ValidateWorkspace(workspace)
1451
+ if err != nil {
1452
+ return StatusReport{}, err
1453
+ }
1454
+ report := StatusReport{
1455
+ SchemaVersion: 1,
1456
+ Workspace: workspace,
1457
+ Status: state.Status,
1458
+ ActiveLayer: state.ActiveLayer,
1459
+ Profile: state.Profile,
1460
+ Approval: state.ApprovalStatus,
1461
+ Validation: validation,
1462
+ StaleComplete: state.Status == "complete" && !validation.OK,
1463
+ Next: nextActionForWorkspace(workspace, state),
1464
+ }
1465
+ if loop, loopErr := readLoopState(workspace); loopErr == nil {
1466
+ report.CurrentTask = loop.CurrentTask
1467
+ }
1468
+ return report, nil
1469
+ }
1470
+
1471
+ func continueLayerAction(state State) NextAction {
1472
+ instruction := NextInstruction(state)
1473
+ return NextAction{
1474
+ SchemaVersion: 1,
1475
+ Action: "continue_layer",
1476
+ Reason: "active layer is " + state.ActiveLayer,
1477
+ Blocked: false,
1478
+ Instruction: instruction,
1479
+ }
1480
+ }
1481
+
1482
+ func nextActionForWorkspace(workspace string, state State) NextAction {
1483
+ if state.Status == "complete" {
1484
+ validation, err := ValidateWorkspace(workspace)
1485
+ if err == nil && !validation.OK {
1486
+ instruction := "next: repair stale complete state by satisfying validation, then run kkt judge --checkpoint finalize --json and kkt done"
1487
+ requires := []string{"kkt validate", "kkt judge --checkpoint finalize --json", "kkt done"}
1488
+ if hasRequiredValidationCommands(workspace) {
1489
+ instruction = "next: run kkt validate --run, then kkt judge --checkpoint finalize --json and kkt done"
1490
+ requires[0] = "kkt validate --run"
1491
+ }
1492
+ return NextAction{
1493
+ SchemaVersion: 1,
1494
+ Action: "repair_invalid_completion",
1495
+ Reason: "stored status is complete but validation is invalid",
1496
+ Blocked: true,
1497
+ Requires: requires,
1498
+ Instruction: instruction,
1499
+ }
1500
+ }
1501
+ }
1502
+ if state.ActiveLayer == "validation" && (state.WorkspaceType == "run" || state.WorkspaceType == "loop") && hasRequiredValidationCommands(workspace) {
1503
+ instruction := "next: run kkt validate --run, then kkt judge --checkpoint finalize --json and kkt done"
1504
+ return NextAction{SchemaVersion: 1, Action: "run_required_validation", Reason: "guardrails define required validation commands", Blocked: false, Requires: []string{"kkt validate --run", "kkt judge --checkpoint finalize --json", "kkt done"}, Instruction: instruction}
1505
+ }
1506
+ if state.WorkspaceType == "run" && state.ActiveLayer == "execution" && state.ApprovalStatus != "approved" {
1507
+ instruction := "next: show the selected model and record approval with kkt approve before execution"
1508
+ return NextAction{SchemaVersion: 1, Action: "request_approval", Reason: "approval is " + state.ApprovalStatus, Blocked: true, Requires: []string{"kkt approve"}, Instruction: instruction}
1509
+ }
1510
+ if state.WorkspaceType != "loop" {
1511
+ return continueLayerAction(state)
1512
+ }
1513
+ loop, err := readLoopState(workspace)
1514
+ if err != nil {
1515
+ instruction := NextInstruction(state)
1516
+ return NextAction{
1517
+ SchemaVersion: 1,
1518
+ Action: "inspect_state",
1519
+ Reason: "loop state could not be read",
1520
+ Blocked: false,
1521
+ Instruction: instruction,
1522
+ }
1523
+ }
1524
+ for _, stop := range loop.StopConditions {
1525
+ if stop.Status == "active" {
1526
+ instruction := "next: resolve active stop condition: " + stop.Text
1527
+ return NextAction{SchemaVersion: 1, Action: "resolve_stop_condition", Reason: "stop condition is active", StopCondition: stop.Text, Blocked: true, Requires: []string{"user_or_system_unblock"}, Instruction: instruction}
1528
+ }
1529
+ }
1530
+ if state.ActiveLayer == "execution" && state.ApprovalStatus != "approved" {
1531
+ instruction := "next: show the selected model and record approval with kkt approve before execution"
1532
+ return NextAction{SchemaVersion: 1, Action: "request_approval", Reason: "approval is " + state.ApprovalStatus, Blocked: true, Requires: []string{"kkt approve"}, Instruction: instruction}
1533
+ }
1534
+ if loop.CurrentTask == "" && len(loop.Tasks) == 0 && len(loop.AcceptanceCriteria) == 0 {
1535
+ return continueLayerAction(state)
1536
+ }
1537
+ if state.ApprovalStatus != "approved" {
1538
+ instruction := "next: show the selected model and record approval with kkt approve before execution"
1539
+ return NextAction{SchemaVersion: 1, Action: "request_approval", Reason: "approval is " + state.ApprovalStatus, Blocked: true, Requires: []string{"kkt approve"}, Instruction: instruction}
1540
+ }
1541
+ if loop.CurrentTask != "" {
1542
+ instruction := "next: complete current task " + loop.CurrentTask + ", record progress and evidence, then run kkt validate"
1543
+ return NextAction{SchemaVersion: 1, Action: "complete_current_task", Reason: "current task is active", TaskID: loop.CurrentTask, Blocked: false, Requires: []string{"kkt progress", "kkt evidence", "kkt validate"}, Instruction: instruction}
1544
+ }
1545
+ for _, task := range loop.Tasks {
1546
+ if task.Status == "pending" {
1547
+ instruction := "next: run kkt task start " + task.ID
1548
+ return NextAction{SchemaVersion: 1, Action: "start_task", Reason: "first pending task", TaskID: task.ID, Blocked: false, Requires: []string{"kkt task start " + task.ID}, Instruction: instruction}
1549
+ }
1550
+ if task.Status == "active" {
1551
+ instruction := "next: complete active task " + task.ID + ", record progress and evidence, then run kkt validate"
1552
+ return NextAction{SchemaVersion: 1, Action: "complete_task", Reason: "task is active", TaskID: task.ID, Blocked: false, Requires: []string{"kkt progress", "kkt evidence", "kkt validate"}, Instruction: instruction}
1553
+ }
1554
+ if task.Status == "blocked" {
1555
+ instruction := "next: resolve blocked task " + task.ID
1556
+ return NextAction{SchemaVersion: 1, Action: "resolve_blocked_task", Reason: "task is blocked", TaskID: task.ID, Blocked: true, Requires: []string{"user_or_system_unblock"}, Instruction: instruction}
1557
+ }
1558
+ }
1559
+ for _, criterion := range loop.AcceptanceCriteria {
1560
+ if criterion.Status == "pending" {
1561
+ instruction := "next: satisfy acceptance criterion " + criterion.ID + " with evidence, then run kkt criteria satisfy " + criterion.ID
1562
+ return NextAction{SchemaVersion: 1, Action: "satisfy_criterion", Reason: "acceptance criterion is pending", CriterionID: criterion.ID, Blocked: false, Requires: []string{"kkt evidence --for " + criterion.ID, "kkt criteria satisfy " + criterion.ID}, EvidenceRequired: []string{criterion.ID}, Instruction: instruction}
1563
+ }
1564
+ if criterion.Status == "blocked" {
1565
+ instruction := "next: resolve blocked acceptance criterion " + criterion.ID
1566
+ return NextAction{SchemaVersion: 1, Action: "resolve_blocked_criterion", Reason: "acceptance criterion is blocked", CriterionID: criterion.ID, Blocked: true, Requires: []string{"user_or_system_unblock"}, Instruction: instruction}
1567
+ }
1568
+ }
1569
+ instruction := "next: run kkt validate, then kkt done when acceptance criteria and evidence are complete"
1570
+ requires := []string{"kkt validate", "kkt done"}
1571
+ if hasRequiredValidationCommands(workspace) {
1572
+ instruction = "next: run kkt validate --run, then kkt done when acceptance criteria and evidence are complete"
1573
+ requires[0] = "kkt validate --run"
1574
+ }
1575
+ return NextAction{SchemaVersion: 1, Action: "validate_then_done", Reason: "no open tasks or criteria remain", Blocked: false, Requires: requires, Instruction: instruction}
1576
+ }
1577
+
1578
+ func hasRequiredValidationCommands(workspace string) bool {
1579
+ commands, err := requiredValidationCommands(workspace)
1580
+ return err == nil && len(commands) > 0
1581
+ }
1582
+
1583
+ func uniqueTaskID(loop LoopState, base string) string {
1584
+ if base == "" {
1585
+ base = "task"
1586
+ }
1587
+ id := base
1588
+ n := 2
1589
+ for taskIDExists(loop, id) {
1590
+ id = fmt.Sprintf("%s-%d", base, n)
1591
+ n++
1592
+ }
1593
+ return id
1594
+ }
1595
+
1596
+ func uniqueCriterionID(loop LoopState, base string) string {
1597
+ if base == "" {
1598
+ base = "criterion"
1599
+ }
1600
+ id := base
1601
+ n := 2
1602
+ for criterionIDExists(loop, id) {
1603
+ id = fmt.Sprintf("%s-%d", base, n)
1604
+ n++
1605
+ }
1606
+ return id
1607
+ }
1608
+
1609
+ func criterionIDExists(loop LoopState, id string) bool {
1610
+ for _, criterion := range loop.AcceptanceCriteria {
1611
+ if criterion.ID == id {
1612
+ return true
1613
+ }
1614
+ }
1615
+ return false
1616
+ }
1617
+
1618
+ func taskIDExists(loop LoopState, id string) bool {
1619
+ for _, task := range loop.Tasks {
1620
+ if task.ID == id {
1621
+ return true
1622
+ }
1623
+ }
1624
+ return false
1625
+ }
1626
+
1627
+ func uniqueID(prefix string) string {
1628
+ return fmt.Sprintf("%s-%s", prefix, time.Now().UTC().Format("20060102-150405"))
1629
+ }
1630
+
1631
+ func runReplay(args []string, stdout io.Writer) error {
1632
+ if len(args) == 0 || args[0] != "--check" {
1633
+ return errors.New("replay requires --check")
1634
+ }
1635
+ if len(args) > 2 {
1636
+ return errors.New("replay --check accepts at most one path")
1637
+ }
1638
+ workspace, err := ResolveWorkspace(".", firstArg(args[1:]))
1639
+ if err != nil {
1640
+ return err
1641
+ }
1642
+ result, err := CheckReplay(workspace)
1643
+ if err != nil {
1644
+ return err
1645
+ }
1646
+ if result.OK {
1647
+ fmt.Fprintf(stdout, "replay ok: %s\n", workspace)
1648
+ return nil
1649
+ }
1650
+ fmt.Fprintf(stdout, "replay drift: %s\n", workspace)
1651
+ for _, issue := range result.Issues {
1652
+ fmt.Fprintf(stdout, "- %s\n", issue)
1653
+ }
1654
+ return errors.New("replay check failed")
1655
+ }
1656
+
1657
+ func CheckReplay(workspace string) (ValidationResult, error) {
1658
+ state, err := ReadState(workspace)
1659
+ if err != nil {
1660
+ return ValidationResult{}, err
1661
+ }
1662
+ result := ValidationResult{OK: true}
1663
+ if state.WorkspaceType != "loop" {
1664
+ return result, nil
1665
+ }
1666
+ loop, err := readLoopState(workspace)
1667
+ if err != nil {
1668
+ return ValidationResult{}, err
1669
+ }
1670
+ events, err := readEvents(workspace, 0)
1671
+ if err != nil {
1672
+ result.OK = false
1673
+ result.Issues = append(result.Issues, "events.jsonl parse failed: "+err.Error())
1674
+ return result, nil
1675
+ }
1676
+ taskEvents := map[string]string{}
1677
+ criterionEvents := map[string]string{}
1678
+ for _, event := range events {
1679
+ switch event.Type {
1680
+ case "task_start":
1681
+ if id := eventString(event.Data, "task"); id != "" {
1682
+ taskEvents[id] = "active"
1683
+ }
1684
+ case "task_done":
1685
+ if id := eventString(event.Data, "task"); id != "" {
1686
+ taskEvents[id] = "done"
1687
+ }
1688
+ case "task_skip":
1689
+ if id := eventString(event.Data, "task"); id != "" {
1690
+ taskEvents[id] = "skipped"
1691
+ }
1692
+ case "task_block":
1693
+ if id := eventString(event.Data, "task"); id != "" {
1694
+ taskEvents[id] = "blocked"
1695
+ }
1696
+ case "criterion_satisfy":
1697
+ if id := eventString(event.Data, "criterion"); id != "" {
1698
+ criterionEvents[id] = "satisfied"
1699
+ }
1700
+ case "criterion_block":
1701
+ if id := eventString(event.Data, "criterion"); id != "" {
1702
+ criterionEvents[id] = "blocked"
1703
+ }
1704
+ }
1705
+ }
1706
+ for _, task := range loop.Tasks {
1707
+ if status, ok := taskEvents[task.ID]; ok && status != task.Status {
1708
+ result.OK = false
1709
+ result.Issues = append(result.Issues, fmt.Sprintf("task %s event status %s disagrees with kkt.yaml status %s", task.ID, status, task.Status))
1710
+ }
1711
+ }
1712
+ for _, criterion := range loop.AcceptanceCriteria {
1713
+ if status, ok := criterionEvents[criterion.ID]; ok && status != criterion.Status {
1714
+ result.OK = false
1715
+ result.Issues = append(result.Issues, fmt.Sprintf("criterion %s event status %s disagrees with kkt.yaml status %s", criterion.ID, status, criterion.Status))
1716
+ }
1717
+ }
1718
+ for _, issue := range evidenceMappingIssues(loop) {
1719
+ result.OK = false
1720
+ result.Issues = append(result.Issues, issue)
1721
+ }
1722
+ return result, nil
1723
+ }
1724
+
1725
+ func evidenceMappingIssues(loop LoopState) []string {
1726
+ var issues []string
1727
+ for _, criterion := range loop.AcceptanceCriteria {
1728
+ if criterion.Status != "satisfied" {
1729
+ continue
1730
+ }
1731
+ if !hasRecordedEvidenceForCriterion(loop, criterion.ID) {
1732
+ issues = append(issues, fmt.Sprintf("criterion %s is satisfied without mapped evidence", criterion.ID))
1733
+ }
1734
+ }
1735
+ for _, evidence := range loop.Evidence {
1736
+ if evidence.Status != "recorded" {
1737
+ issues = append(issues, fmt.Sprintf("evidence %s is %s", evidence.ID, evidence.Status))
1738
+ }
1739
+ if strings.TrimSpace(evidence.Summary) == "" {
1740
+ issues = append(issues, fmt.Sprintf("evidence %s has empty summary", evidence.ID))
1741
+ }
1742
+ }
1743
+ return issues
1744
+ }
1745
+
1746
+ func hasRecordedEvidenceForCriterion(loop LoopState, criterionID string) bool {
1747
+ for _, evidence := range loop.Evidence {
1748
+ if evidence.Status != "recorded" {
1749
+ continue
1750
+ }
1751
+ for _, mapped := range evidence.Criteria {
1752
+ if mapped == criterionID {
1753
+ return true
1754
+ }
1755
+ }
1756
+ }
1757
+ return false
1758
+ }
1759
+
1760
+ func splitCSV(value string) []string {
1761
+ var parts []string
1762
+ for _, part := range strings.Split(value, ",") {
1763
+ part = strings.TrimSpace(part)
1764
+ if part != "" {
1765
+ parts = append(parts, part)
1766
+ }
1767
+ }
1768
+ return parts
1769
+ }
1770
+
1771
+ func uniqueStrings(values []string) []string {
1772
+ seen := map[string]bool{}
1773
+ var result []string
1774
+ for _, value := range values {
1775
+ value = strings.TrimSpace(value)
1776
+ if value == "" || seen[value] {
1777
+ continue
1778
+ }
1779
+ seen[value] = true
1780
+ result = append(result, value)
1781
+ }
1782
+ return result
1783
+ }
1784
+
1785
+ func writeJSON(stdout io.Writer, value any) error {
1786
+ encoder := json.NewEncoder(stdout)
1787
+ encoder.SetIndent("", " ")
1788
+ return encoder.Encode(value)
1789
+ }
1790
+
1791
+ func eventString(data map[string]any, key string) string {
1792
+ if data == nil {
1793
+ return ""
1794
+ }
1795
+ if value, ok := data[key].(string); ok {
1796
+ return value
1797
+ }
1798
+ return ""
1799
+ }
1800
+
1801
+ func eventDataSummary(data map[string]any) string {
1802
+ if len(data) == 0 {
1803
+ return ""
1804
+ }
1805
+ if summary := eventString(data, "summary"); summary != "" {
1806
+ return summary
1807
+ }
1808
+ if task := eventString(data, "task"); task != "" {
1809
+ return task
1810
+ }
1811
+ if criterion := eventString(data, "criterion"); criterion != "" {
1812
+ return criterion
1813
+ }
1814
+ if reason := eventString(data, "reason"); reason != "" {
1815
+ return reason
1816
+ }
1817
+ return fmt.Sprintf("%v", data)
1818
+ }
1819
+
1820
+ func artifactTitle(artifact string) string {
1821
+ parts := strings.Fields(strings.ReplaceAll(artifact, "-", " "))
1822
+ for i, part := range parts {
1823
+ if part == "" {
1824
+ continue
1825
+ }
1826
+ parts[i] = strings.ToUpper(part[:1]) + part[1:]
1827
+ }
1828
+ return strings.Join(parts, " ")
1829
+ }
1830
+
1831
+ func quoteYAML(value string) string {
1832
+ return strconv.Quote(value)
1833
+ }
1834
+
1835
+ func unquoteYAML(value string) string {
1836
+ if value == "" {
1837
+ return ""
1838
+ }
1839
+ unquoted, err := strconv.Unquote(value)
1840
+ if err != nil {
1841
+ return value
1842
+ }
1843
+ return unquoted
1844
+ }
1845
+
1846
+ func firstLine(value string) string {
1847
+ scanner := bufio.NewScanner(strings.NewReader(value))
1848
+ for scanner.Scan() {
1849
+ line := strings.TrimSpace(scanner.Text())
1850
+ if line != "" {
1851
+ return line
1852
+ }
1853
+ }
1854
+ return ""
1855
+ }