@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.
- package/LICENSE +201 -0
- package/README.md +275 -0
- package/assets/kkt-readme-modern.png +0 -0
- package/bin/kkt-install.mjs +25 -0
- package/cmd/kkt/main.go +15 -0
- package/go.mod +3 -0
- package/internal/workflow/cli.go +349 -0
- package/internal/workflow/cli_test.go +1266 -0
- package/internal/workflow/guardrails.go +900 -0
- package/internal/workflow/init.go +164 -0
- package/internal/workflow/init_test.go +113 -0
- package/internal/workflow/operations.go +1855 -0
- package/internal/workflow/validation_commands.go +291 -0
- package/internal/workflow/workspace.go +802 -0
- package/internal/workflow/workspace_test.go +285 -0
- package/package.json +45 -0
- package/scripts/install-cli.sh +210 -0
- package/scripts/install.sh +644 -0
- package/skills/kkt/SKILL.md +74 -0
- package/skills/kkt/references/discovery-tooling.md +53 -0
- package/skills/kkt/references/feature-optimization-model.md +120 -0
- package/skills/kkt/references/kkt-kernel.md +58 -0
- package/skills/kkt/references/layered-modeling-methods.md +101 -0
- package/skills/kkt/references/plan-assimilation.md +46 -0
- package/skills/kkt/references/schemas.md +231 -0
- package/skills/kkt/references/state-contract.md +76 -0
- package/skills/kkt-loop/SKILL.md +99 -0
- package/skills/kkt-loop/references/discovery-tooling.md +53 -0
- package/skills/kkt-loop/references/feature-optimization-model.md +120 -0
- package/skills/kkt-loop/references/kkt-kernel.md +58 -0
- package/skills/kkt-loop/references/layered-modeling-methods.md +101 -0
- package/skills/kkt-loop/references/plan-assimilation.md +46 -0
- package/skills/kkt-loop/references/schemas.md +231 -0
- package/skills/kkt-loop/references/state-contract.md +76 -0
- package/skills/kkt-model/SKILL.md +76 -0
- package/skills/kkt-model/references/discovery-tooling.md +53 -0
- package/skills/kkt-model/references/feature-optimization-model.md +120 -0
- package/skills/kkt-model/references/kkt-kernel.md +58 -0
- package/skills/kkt-model/references/layered-modeling-methods.md +101 -0
- package/skills/kkt-model/references/plan-assimilation.md +46 -0
- package/skills/kkt-model/references/schemas.md +231 -0
- package/skills/kkt-model/references/state-contract.md +76 -0
- package/skills/kkt-run/SKILL.md +62 -0
- package/skills/kkt-run/references/discovery-tooling.md +53 -0
- package/skills/kkt-run/references/feature-optimization-model.md +120 -0
- package/skills/kkt-run/references/kkt-kernel.md +58 -0
- package/skills/kkt-run/references/layered-modeling-methods.md +101 -0
- package/skills/kkt-run/references/plan-assimilation.md +46 -0
- package/skills/kkt-run/references/schemas.md +231 -0
- 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
|
+
}
|