@kynetic-ai/spec 0.4.0 → 0.6.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/dist/cli/commands/guard.d.ts +43 -0
- package/dist/cli/commands/guard.d.ts.map +1 -0
- package/dist/cli/commands/guard.js +200 -0
- package/dist/cli/commands/guard.js.map +1 -0
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/item.d.ts.map +1 -1
- package/dist/cli/commands/item.js +60 -23
- package/dist/cli/commands/item.js.map +1 -1
- package/dist/cli/commands/plan-import.js +51 -12
- package/dist/cli/commands/plan-import.js.map +1 -1
- package/dist/cli/commands/ralph.d.ts.map +1 -1
- package/dist/cli/commands/ralph.js +144 -329
- package/dist/cli/commands/ralph.js.map +1 -1
- package/dist/cli/commands/session/checkpoint.d.ts +19 -0
- package/dist/cli/commands/session/checkpoint.d.ts.map +1 -0
- package/dist/cli/commands/session/checkpoint.js +161 -0
- package/dist/cli/commands/session/checkpoint.js.map +1 -0
- package/dist/cli/commands/session/commands.d.ts +18 -0
- package/dist/cli/commands/session/commands.d.ts.map +1 -0
- package/dist/cli/commands/session/commands.js +259 -0
- package/dist/cli/commands/session/commands.js.map +1 -0
- package/dist/cli/commands/session/context.d.ts +17 -0
- package/dist/cli/commands/session/context.d.ts.map +1 -0
- package/dist/cli/commands/session/context.js +493 -0
- package/dist/cli/commands/session/context.js.map +1 -0
- package/dist/cli/commands/session/create.d.ts +29 -0
- package/dist/cli/commands/session/create.d.ts.map +1 -0
- package/dist/cli/commands/session/create.js +147 -0
- package/dist/cli/commands/session/create.js.map +1 -0
- package/dist/cli/commands/session/format.d.ts +27 -0
- package/dist/cli/commands/session/format.d.ts.map +1 -0
- package/dist/cli/commands/session/format.js +401 -0
- package/dist/cli/commands/session/format.js.map +1 -0
- package/dist/cli/commands/session/index.d.ts +13 -0
- package/dist/cli/commands/session/index.d.ts.map +1 -0
- package/dist/cli/commands/session/index.js +17 -0
- package/dist/cli/commands/session/index.js.map +1 -0
- package/dist/cli/commands/session/log.d.ts +52 -0
- package/dist/cli/commands/session/log.d.ts.map +1 -0
- package/dist/cli/commands/session/log.js +570 -0
- package/dist/cli/commands/session/log.js.map +1 -0
- package/dist/cli/commands/session/types.d.ts +230 -0
- package/dist/cli/commands/session/types.d.ts.map +1 -0
- package/dist/cli/commands/session/types.js +7 -0
- package/dist/cli/commands/session/types.js.map +1 -0
- package/dist/cli/commands/session.d.ts +4 -179
- package/dist/cli/commands/session.d.ts.map +1 -1
- package/dist/cli/commands/session.js +6 -1424
- package/dist/cli/commands/session.js.map +1 -1
- package/dist/cli/commands/setup.d.ts.map +1 -1
- package/dist/cli/commands/setup.js +69 -223
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/task.d.ts.map +1 -1
- package/dist/cli/commands/task.js +95 -37
- package/dist/cli/commands/task.js.map +1 -1
- package/dist/cli/commands/validate.d.ts.map +1 -1
- package/dist/cli/commands/validate.js +23 -7
- package/dist/cli/commands/validate.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +14 -2
- package/dist/cli/output.js.map +1 -1
- package/dist/parser/file-lock.d.ts +14 -0
- package/dist/parser/file-lock.d.ts.map +1 -0
- package/dist/parser/file-lock.js +124 -0
- package/dist/parser/file-lock.js.map +1 -0
- package/dist/parser/index.d.ts +1 -0
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +1 -0
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/plan-document.d.ts +36 -0
- package/dist/parser/plan-document.d.ts.map +1 -1
- package/dist/parser/plan-document.js +75 -8
- package/dist/parser/plan-document.js.map +1 -1
- package/dist/parser/plans.d.ts.map +1 -1
- package/dist/parser/plans.js +28 -102
- package/dist/parser/plans.js.map +1 -1
- package/dist/parser/shadow.d.ts +5 -1
- package/dist/parser/shadow.d.ts.map +1 -1
- package/dist/parser/shadow.js +29 -17
- package/dist/parser/shadow.js.map +1 -1
- package/dist/parser/validate.d.ts +4 -1
- package/dist/parser/validate.d.ts.map +1 -1
- package/dist/parser/validate.js +50 -35
- package/dist/parser/validate.js.map +1 -1
- package/dist/parser/yaml.d.ts.map +1 -1
- package/dist/parser/yaml.js +322 -297
- package/dist/parser/yaml.js.map +1 -1
- package/dist/schema/task.d.ts +22 -0
- package/dist/schema/task.d.ts.map +1 -1
- package/dist/schema/task.js +7 -0
- package/dist/schema/task.js.map +1 -1
- package/dist/sessions/store.d.ts +254 -1
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +621 -1
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/types.d.ts +51 -2
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/sessions/types.js +25 -0
- package/dist/sessions/types.js.map +1 -1
- package/dist/strings/labels.d.ts +2 -0
- package/dist/strings/labels.d.ts.map +1 -1
- package/dist/strings/labels.js +2 -0
- package/dist/strings/labels.js.map +1 -1
- package/dist/utils/git.d.ts +2 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +21 -5
- package/dist/utils/git.js.map +1 -1
- package/package.json +4 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/kspec/skills/review/SKILL.md +37 -0
- package/plugin/plugins/kspec/skills/task-work/SKILL.md +16 -0
- package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
- package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +14 -0
- package/templates/agents-sections/05-commit-convention.md +14 -0
- package/templates/skills/review/SKILL.md +37 -0
- package/templates/skills/task-work/SKILL.md +16 -0
- package/templates/skills/triage-inbox/SKILL.md +1 -1
- package/templates/skills/writing-specs/SKILL.md +14 -0
package/dist/parser/yaml.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { ulid } from "ulid";
|
|
5
5
|
import * as YAML from "yaml";
|
|
6
|
+
import { withFileLock } from "./file-lock.js";
|
|
6
7
|
import { InboxFileSchema, InboxItemSchema, ManifestSchema, SpecItemSchema, TaskSchema, TasksFileSchema, TriageFileSchema, TriageRecordSchema, } from "../schema/index.js";
|
|
7
8
|
import { errors } from "../strings/index.js";
|
|
8
9
|
import { ItemIndex } from "./items.js";
|
|
@@ -482,73 +483,76 @@ function stripRuntimeMetadata(task) {
|
|
|
482
483
|
export async function saveTask(ctx, task) {
|
|
483
484
|
// Determine target file: use _sourceFile if present, otherwise default
|
|
484
485
|
const taskFilePath = task._sourceFile || getDefaultTaskFilePath(ctx);
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
catch {
|
|
501
|
-
// File doesn't exist, start fresh
|
|
502
|
-
}
|
|
503
|
-
// Parse existing tasks from file
|
|
504
|
-
let fileTasks = [];
|
|
505
|
-
if (existingRaw) {
|
|
506
|
-
if (Array.isArray(existingRaw)) {
|
|
507
|
-
for (const t of existingRaw) {
|
|
508
|
-
const result = TaskSchema.safeParse(t);
|
|
509
|
-
if (result.success) {
|
|
510
|
-
fileTasks.push(result.data);
|
|
511
|
-
}
|
|
486
|
+
// Lock the file to prevent concurrent read-modify-write races
|
|
487
|
+
await withFileLock(taskFilePath, async () => {
|
|
488
|
+
// Ensure directory exists
|
|
489
|
+
const dir = path.dirname(taskFilePath);
|
|
490
|
+
await fs.mkdir(dir, { recursive: true });
|
|
491
|
+
// Load existing tasks from the target file
|
|
492
|
+
let existingRaw = null;
|
|
493
|
+
let useTasksWrapper = false;
|
|
494
|
+
try {
|
|
495
|
+
existingRaw = await readYamlFile(taskFilePath);
|
|
496
|
+
// Detect if file uses { tasks: [...] } format
|
|
497
|
+
if (existingRaw &&
|
|
498
|
+
typeof existingRaw === "object" &&
|
|
499
|
+
"tasks" in existingRaw) {
|
|
500
|
+
useTasksWrapper = true;
|
|
512
501
|
}
|
|
513
502
|
}
|
|
514
|
-
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
503
|
+
catch {
|
|
504
|
+
// File doesn't exist, start fresh
|
|
505
|
+
}
|
|
506
|
+
// Parse existing tasks from file
|
|
507
|
+
let fileTasks = [];
|
|
508
|
+
if (existingRaw) {
|
|
509
|
+
if (Array.isArray(existingRaw)) {
|
|
510
|
+
for (const t of existingRaw) {
|
|
511
|
+
const result = TaskSchema.safeParse(t);
|
|
512
|
+
if (result.success) {
|
|
513
|
+
fileTasks.push(result.data);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
519
516
|
}
|
|
520
|
-
else {
|
|
521
|
-
//
|
|
522
|
-
const
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
517
|
+
else if (useTasksWrapper) {
|
|
518
|
+
// Try TasksFileSchema first (has kynetic_tasks version)
|
|
519
|
+
const parsed = TasksFileSchema.safeParse(existingRaw);
|
|
520
|
+
if (parsed.success) {
|
|
521
|
+
fileTasks = parsed.data.tasks;
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
// Fall back to raw tasks array (common format without version field)
|
|
525
|
+
const rawTasks = existingRaw.tasks;
|
|
526
|
+
if (Array.isArray(rawTasks)) {
|
|
527
|
+
for (const t of rawTasks) {
|
|
528
|
+
const result = TaskSchema.safeParse(t);
|
|
529
|
+
if (result.success) {
|
|
530
|
+
fileTasks.push(result.data);
|
|
531
|
+
}
|
|
528
532
|
}
|
|
529
533
|
}
|
|
530
534
|
}
|
|
531
535
|
}
|
|
532
536
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
537
|
+
// Strip runtime metadata before saving
|
|
538
|
+
const cleanTask = stripRuntimeMetadata(task);
|
|
539
|
+
// Update existing or add new
|
|
540
|
+
const existingIndex = fileTasks.findIndex((t) => t._ulid === task._ulid);
|
|
541
|
+
if (existingIndex >= 0) {
|
|
542
|
+
fileTasks[existingIndex] = cleanTask;
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
fileTasks.push(cleanTask);
|
|
546
|
+
}
|
|
547
|
+
// Save in the same format as original (or tasks: wrapper for new files)
|
|
548
|
+
// Use format-preserving write to maintain formatting and comments
|
|
549
|
+
if (useTasksWrapper) {
|
|
550
|
+
await writeYamlFilePreserveFormat(taskFilePath, { tasks: fileTasks });
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
await writeYamlFilePreserveFormat(taskFilePath, fileTasks);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
552
556
|
}
|
|
553
557
|
/**
|
|
554
558
|
* Delete a task from its source file.
|
|
@@ -559,62 +563,65 @@ export async function deleteTask(_ctx, task) {
|
|
|
559
563
|
throw new Error("Cannot delete task without _sourceFile metadata");
|
|
560
564
|
}
|
|
561
565
|
const taskFilePath = task._sourceFile;
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
catch {
|
|
574
|
-
throw new Error(`Task file not found: ${taskFilePath}`);
|
|
575
|
-
}
|
|
576
|
-
// Parse existing tasks
|
|
577
|
-
let fileTasks = [];
|
|
578
|
-
if (existingRaw) {
|
|
579
|
-
if (Array.isArray(existingRaw)) {
|
|
580
|
-
for (const t of existingRaw) {
|
|
581
|
-
const result = TaskSchema.safeParse(t);
|
|
582
|
-
if (result.success) {
|
|
583
|
-
fileTasks.push(result.data);
|
|
584
|
-
}
|
|
566
|
+
// Lock the file to prevent concurrent read-modify-write races
|
|
567
|
+
await withFileLock(taskFilePath, async () => {
|
|
568
|
+
// Load existing file
|
|
569
|
+
let existingRaw = null;
|
|
570
|
+
let useTasksWrapper = false;
|
|
571
|
+
try {
|
|
572
|
+
existingRaw = await readYamlFile(taskFilePath);
|
|
573
|
+
if (existingRaw &&
|
|
574
|
+
typeof existingRaw === "object" &&
|
|
575
|
+
"tasks" in existingRaw) {
|
|
576
|
+
useTasksWrapper = true;
|
|
585
577
|
}
|
|
586
578
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
579
|
+
catch {
|
|
580
|
+
throw new Error(`Task file not found: ${taskFilePath}`);
|
|
581
|
+
}
|
|
582
|
+
// Parse existing tasks
|
|
583
|
+
let fileTasks = [];
|
|
584
|
+
if (existingRaw) {
|
|
585
|
+
if (Array.isArray(existingRaw)) {
|
|
586
|
+
for (const t of existingRaw) {
|
|
587
|
+
const result = TaskSchema.safeParse(t);
|
|
588
|
+
if (result.success) {
|
|
589
|
+
fileTasks.push(result.data);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
591
592
|
}
|
|
592
|
-
else {
|
|
593
|
-
const
|
|
594
|
-
if (
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
593
|
+
else if (useTasksWrapper) {
|
|
594
|
+
const parsed = TasksFileSchema.safeParse(existingRaw);
|
|
595
|
+
if (parsed.success) {
|
|
596
|
+
fileTasks = parsed.data.tasks;
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
const rawTasks = existingRaw.tasks;
|
|
600
|
+
if (Array.isArray(rawTasks)) {
|
|
601
|
+
for (const t of rawTasks) {
|
|
602
|
+
const result = TaskSchema.safeParse(t);
|
|
603
|
+
if (result.success) {
|
|
604
|
+
fileTasks.push(result.data);
|
|
605
|
+
}
|
|
599
606
|
}
|
|
600
607
|
}
|
|
601
608
|
}
|
|
602
609
|
}
|
|
603
610
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
611
|
+
// Remove the task
|
|
612
|
+
const originalCount = fileTasks.length;
|
|
613
|
+
fileTasks = fileTasks.filter((t) => t._ulid !== task._ulid);
|
|
614
|
+
if (fileTasks.length === originalCount) {
|
|
615
|
+
throw new Error(`Task not found in file: ${task._ulid}`);
|
|
616
|
+
}
|
|
617
|
+
// Save the modified file with format preservation
|
|
618
|
+
if (useTasksWrapper) {
|
|
619
|
+
await writeYamlFilePreserveFormat(taskFilePath, { tasks: fileTasks });
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
await writeYamlFilePreserveFormat(taskFilePath, fileTasks);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
618
625
|
}
|
|
619
626
|
/**
|
|
620
627
|
* Create a new task with auto-generated fields
|
|
@@ -1172,40 +1179,43 @@ export async function addChildItem(_ctx, parent, child, childField) {
|
|
|
1172
1179
|
throw new Error("Parent item has no source file");
|
|
1173
1180
|
}
|
|
1174
1181
|
const field = childField || TYPE_TO_CHILD_FIELD[child.type || "feature"] || "features";
|
|
1175
|
-
//
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
if (
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1182
|
+
// Lock the file to prevent concurrent read-modify-write races
|
|
1183
|
+
return withFileLock(parent._sourceFile, async () => {
|
|
1184
|
+
// Load the raw YAML
|
|
1185
|
+
const raw = await readYamlFile(parent._sourceFile);
|
|
1186
|
+
// Find the parent in the structure
|
|
1187
|
+
let parentObj;
|
|
1188
|
+
let parentPath;
|
|
1189
|
+
if (parent._path) {
|
|
1190
|
+
const nav = navigateToPath(raw, parent._path);
|
|
1191
|
+
if (!nav) {
|
|
1192
|
+
throw new Error(`Could not navigate to parent path: ${parent._path}`);
|
|
1193
|
+
}
|
|
1194
|
+
parentObj = nav.array[nav.index];
|
|
1195
|
+
parentPath = parent._path;
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
// Parent is the root item
|
|
1199
|
+
parentObj = raw;
|
|
1200
|
+
parentPath = "";
|
|
1201
|
+
}
|
|
1202
|
+
// Ensure the child field array exists
|
|
1203
|
+
if (!Array.isArray(parentObj[field])) {
|
|
1204
|
+
parentObj[field] = [];
|
|
1205
|
+
}
|
|
1206
|
+
// Add the child
|
|
1207
|
+
const childArray = parentObj[field];
|
|
1208
|
+
const cleanChild = stripSpecItemMetadata(child);
|
|
1209
|
+
childArray.push(cleanChild);
|
|
1210
|
+
// Calculate the new child's path
|
|
1211
|
+
const childIndex = childArray.length - 1;
|
|
1212
|
+
const childPath = parentPath
|
|
1213
|
+
? `${parentPath}.${field}[${childIndex}]`
|
|
1214
|
+
: `${field}[${childIndex}]`;
|
|
1215
|
+
// Write back with format preservation
|
|
1216
|
+
await writeYamlFilePreserveFormat(parent._sourceFile, raw);
|
|
1217
|
+
return { item: cleanChild, path: childPath };
|
|
1218
|
+
});
|
|
1209
1219
|
}
|
|
1210
1220
|
/**
|
|
1211
1221
|
* Update a spec item in place within its source file.
|
|
@@ -1215,39 +1225,42 @@ export async function updateSpecItem(_ctx, item, updates) {
|
|
|
1215
1225
|
if (!item._sourceFile) {
|
|
1216
1226
|
throw new Error("Item has no source file");
|
|
1217
1227
|
}
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
if (
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
// Item might be the root, or we need to find it
|
|
1231
|
-
const found = findItemInStructure(raw, item._ulid);
|
|
1232
|
-
if (found) {
|
|
1233
|
-
targetObj = found.item;
|
|
1234
|
-
}
|
|
1235
|
-
else if (raw._ulid === item._ulid) {
|
|
1236
|
-
targetObj = raw;
|
|
1228
|
+
// Lock the file to prevent concurrent read-modify-write races
|
|
1229
|
+
return withFileLock(item._sourceFile, async () => {
|
|
1230
|
+
// Load the raw YAML
|
|
1231
|
+
const raw = await readYamlFile(item._sourceFile);
|
|
1232
|
+
// Find the item in the structure (use stored path or search by ULID)
|
|
1233
|
+
let targetObj;
|
|
1234
|
+
if (item._path) {
|
|
1235
|
+
const nav = navigateToPath(raw, item._path);
|
|
1236
|
+
if (!nav) {
|
|
1237
|
+
throw new Error(`Could not navigate to path: ${item._path}`);
|
|
1238
|
+
}
|
|
1239
|
+
targetObj = nav.array[nav.index];
|
|
1237
1240
|
}
|
|
1238
1241
|
else {
|
|
1239
|
-
|
|
1242
|
+
// Item might be the root, or we need to find it
|
|
1243
|
+
const found = findItemInStructure(raw, item._ulid);
|
|
1244
|
+
if (found) {
|
|
1245
|
+
targetObj = found.item;
|
|
1246
|
+
}
|
|
1247
|
+
else if (raw._ulid === item._ulid) {
|
|
1248
|
+
targetObj = raw;
|
|
1249
|
+
}
|
|
1250
|
+
else {
|
|
1251
|
+
throw new Error(`Could not find item ${item._ulid} in structure`);
|
|
1252
|
+
}
|
|
1240
1253
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1254
|
+
// Apply updates (but never change _ulid)
|
|
1255
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
1256
|
+
if (key !== "_ulid" && key !== "_sourceFile" && key !== "_path") {
|
|
1257
|
+
targetObj[key] = value;
|
|
1258
|
+
}
|
|
1246
1259
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1260
|
+
// Write back with format preservation
|
|
1261
|
+
await writeYamlFilePreserveFormat(item._sourceFile, raw);
|
|
1262
|
+
return { ...item, ...updates, _ulid: item._ulid };
|
|
1263
|
+
});
|
|
1251
1264
|
}
|
|
1252
1265
|
/**
|
|
1253
1266
|
* Check if an item is a trait with implementors.
|
|
@@ -1274,45 +1287,48 @@ export async function deleteSpecItem(_ctx, item) {
|
|
|
1274
1287
|
if (!item._sourceFile) {
|
|
1275
1288
|
return false;
|
|
1276
1289
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
if (
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
return true;
|
|
1289
|
-
}
|
|
1290
|
-
// No path - try to find it by ULID
|
|
1291
|
-
const found = findItemInStructure(raw, item._ulid);
|
|
1292
|
-
if (found?.path) {
|
|
1293
|
-
const nav = navigateToPath(raw, found.path);
|
|
1294
|
-
if (nav) {
|
|
1290
|
+
// Lock the file to prevent concurrent read-modify-write races
|
|
1291
|
+
return withFileLock(item._sourceFile, async () => {
|
|
1292
|
+
try {
|
|
1293
|
+
const raw = await readYamlFile(item._sourceFile);
|
|
1294
|
+
// If item has a path, navigate to it and remove from parent array
|
|
1295
|
+
if (item._path) {
|
|
1296
|
+
const nav = navigateToPath(raw, item._path);
|
|
1297
|
+
if (!nav) {
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1300
|
+
// Remove the item from the array
|
|
1295
1301
|
nav.array.splice(nav.index, 1);
|
|
1296
1302
|
await writeYamlFilePreserveFormat(item._sourceFile, raw);
|
|
1297
1303
|
return true;
|
|
1298
1304
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
return true;
|
|
1305
|
+
// No path - try to find it by ULID
|
|
1306
|
+
const found = findItemInStructure(raw, item._ulid);
|
|
1307
|
+
if (found?.path) {
|
|
1308
|
+
const nav = navigateToPath(raw, found.path);
|
|
1309
|
+
if (nav) {
|
|
1310
|
+
nav.array.splice(nav.index, 1);
|
|
1311
|
+
await writeYamlFilePreserveFormat(item._sourceFile, raw);
|
|
1312
|
+
return true;
|
|
1313
|
+
}
|
|
1309
1314
|
}
|
|
1315
|
+
// Maybe it's a root-level array item
|
|
1316
|
+
if (Array.isArray(raw)) {
|
|
1317
|
+
const index = raw.findIndex((i) => typeof i === "object" &&
|
|
1318
|
+
i !== null &&
|
|
1319
|
+
i._ulid === item._ulid);
|
|
1320
|
+
if (index >= 0) {
|
|
1321
|
+
raw.splice(index, 1);
|
|
1322
|
+
await writeYamlFilePreserveFormat(item._sourceFile, raw);
|
|
1323
|
+
return true;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return false;
|
|
1310
1327
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
}
|
|
1328
|
+
catch {
|
|
1329
|
+
return false;
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1316
1332
|
}
|
|
1317
1333
|
/**
|
|
1318
1334
|
* Save a spec item - either updates existing or adds to parent.
|
|
@@ -1400,68 +1416,74 @@ function stripInboxMetadata(item) {
|
|
|
1400
1416
|
*/
|
|
1401
1417
|
export async function saveInboxItem(ctx, item) {
|
|
1402
1418
|
const inboxPath = getInboxFilePath(ctx);
|
|
1403
|
-
//
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
const
|
|
1412
|
-
if (
|
|
1413
|
-
|
|
1419
|
+
// Lock the file to prevent concurrent read-modify-write races
|
|
1420
|
+
await withFileLock(inboxPath, async () => {
|
|
1421
|
+
// Ensure directory exists
|
|
1422
|
+
const dir = path.dirname(inboxPath);
|
|
1423
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1424
|
+
// Load existing items
|
|
1425
|
+
let existingItems = [];
|
|
1426
|
+
try {
|
|
1427
|
+
const raw = await readYamlFile(inboxPath);
|
|
1428
|
+
if (raw && typeof raw === "object" && "inbox" in raw) {
|
|
1429
|
+
const parsed = InboxFileSchema.safeParse(raw);
|
|
1430
|
+
if (parsed.success) {
|
|
1431
|
+
existingItems = parsed.data.inbox;
|
|
1432
|
+
}
|
|
1414
1433
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1434
|
+
else if (Array.isArray(raw)) {
|
|
1435
|
+
for (const i of raw) {
|
|
1436
|
+
const result = InboxItemSchema.safeParse(i);
|
|
1437
|
+
if (result.success) {
|
|
1438
|
+
existingItems.push(result.data);
|
|
1439
|
+
}
|
|
1421
1440
|
}
|
|
1422
1441
|
}
|
|
1423
1442
|
}
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1443
|
+
catch {
|
|
1444
|
+
// File doesn't exist, start fresh
|
|
1445
|
+
}
|
|
1446
|
+
const cleanItem = stripInboxMetadata(item);
|
|
1447
|
+
// Update existing or add new
|
|
1448
|
+
const existingIndex = existingItems.findIndex((i) => i._ulid === item._ulid);
|
|
1449
|
+
if (existingIndex >= 0) {
|
|
1450
|
+
existingItems[existingIndex] = cleanItem;
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
existingItems.push(cleanItem);
|
|
1454
|
+
}
|
|
1455
|
+
// Save with { inbox: [...] } format and format preservation
|
|
1456
|
+
await writeYamlFilePreserveFormat(inboxPath, { inbox: existingItems });
|
|
1457
|
+
});
|
|
1439
1458
|
}
|
|
1440
1459
|
/**
|
|
1441
1460
|
* Delete an inbox item by ULID.
|
|
1442
1461
|
*/
|
|
1443
1462
|
export async function deleteInboxItem(ctx, ulid) {
|
|
1444
1463
|
const inboxPath = getInboxFilePath(ctx);
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
if (
|
|
1451
|
-
|
|
1464
|
+
// Lock the file to prevent concurrent read-modify-write races
|
|
1465
|
+
return withFileLock(inboxPath, async () => {
|
|
1466
|
+
try {
|
|
1467
|
+
const raw = await readYamlFile(inboxPath);
|
|
1468
|
+
let existingItems = [];
|
|
1469
|
+
if (raw && typeof raw === "object" && "inbox" in raw) {
|
|
1470
|
+
const parsed = InboxFileSchema.safeParse(raw);
|
|
1471
|
+
if (parsed.success) {
|
|
1472
|
+
existingItems = parsed.data.inbox;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
const index = existingItems.findIndex((i) => i._ulid === ulid);
|
|
1476
|
+
if (index < 0) {
|
|
1477
|
+
return false;
|
|
1452
1478
|
}
|
|
1479
|
+
existingItems.splice(index, 1);
|
|
1480
|
+
await writeYamlFilePreserveFormat(inboxPath, { inbox: existingItems });
|
|
1481
|
+
return true;
|
|
1453
1482
|
}
|
|
1454
|
-
|
|
1455
|
-
if (index < 0) {
|
|
1483
|
+
catch {
|
|
1456
1484
|
return false;
|
|
1457
1485
|
}
|
|
1458
|
-
|
|
1459
|
-
await writeYamlFilePreserveFormat(inboxPath, { inbox: existingItems });
|
|
1460
|
-
return true;
|
|
1461
|
-
}
|
|
1462
|
-
catch {
|
|
1463
|
-
return false;
|
|
1464
|
-
}
|
|
1486
|
+
});
|
|
1465
1487
|
}
|
|
1466
1488
|
/**
|
|
1467
1489
|
* Find an inbox item by reference (ULID or short ULID).
|
|
@@ -1538,59 +1560,62 @@ function stripTriageMetadata(record) {
|
|
|
1538
1560
|
*/
|
|
1539
1561
|
export async function saveTriageRecord(ctx, record) {
|
|
1540
1562
|
const triagePath = getTriageFilePath(ctx);
|
|
1541
|
-
//
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
const
|
|
1550
|
-
if (
|
|
1551
|
-
|
|
1563
|
+
// Lock the file to prevent concurrent read-modify-write races
|
|
1564
|
+
await withFileLock(triagePath, async () => {
|
|
1565
|
+
// Ensure directory exists
|
|
1566
|
+
const dir = path.dirname(triagePath);
|
|
1567
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1568
|
+
// Load existing records
|
|
1569
|
+
let existingRecords = [];
|
|
1570
|
+
try {
|
|
1571
|
+
const raw = await readYamlFile(triagePath);
|
|
1572
|
+
if (raw && typeof raw === "object" && "triage" in raw) {
|
|
1573
|
+
const parsed = TriageFileSchema.safeParse(raw);
|
|
1574
|
+
if (parsed.success) {
|
|
1575
|
+
existingRecords = parsed.data.triage;
|
|
1576
|
+
}
|
|
1552
1577
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1578
|
+
else if (Array.isArray(raw)) {
|
|
1579
|
+
for (const item of raw) {
|
|
1580
|
+
const result = TriageRecordSchema.safeParse(item);
|
|
1581
|
+
if (result.success) {
|
|
1582
|
+
existingRecords.push(result.data);
|
|
1583
|
+
}
|
|
1559
1584
|
}
|
|
1560
1585
|
}
|
|
1561
1586
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
existingRecords[existingByUlid] = cleanRecord;
|
|
1573
|
-
}
|
|
1574
|
-
else {
|
|
1575
|
-
// Check for existing record with same inbox_ref (uniqueness constraint)
|
|
1576
|
-
// Preserve existing identity (_ulid, created_at) when upserting by inbox_ref
|
|
1577
|
-
const existingByInboxRef = existingRecords.findIndex((r) => r.inbox_ref === record.inbox_ref);
|
|
1578
|
-
if (existingByInboxRef >= 0) {
|
|
1579
|
-
const existing = existingRecords[existingByInboxRef];
|
|
1580
|
-
existingRecords[existingByInboxRef] = {
|
|
1581
|
-
...cleanRecord,
|
|
1582
|
-
_ulid: existing._ulid,
|
|
1583
|
-
created_at: existing.created_at,
|
|
1584
|
-
};
|
|
1587
|
+
catch {
|
|
1588
|
+
// File doesn't exist, start fresh
|
|
1589
|
+
}
|
|
1590
|
+
const cleanRecord = stripTriageMetadata(record);
|
|
1591
|
+
// AC: ac-9 — set updated_at on every mutation
|
|
1592
|
+
cleanRecord.updated_at = new Date().toISOString();
|
|
1593
|
+
// AC: ac-8 — upsert: check for existing record by ULID first, then by inbox_ref
|
|
1594
|
+
const existingByUlid = existingRecords.findIndex((r) => r._ulid === record._ulid);
|
|
1595
|
+
if (existingByUlid >= 0) {
|
|
1596
|
+
existingRecords[existingByUlid] = cleanRecord;
|
|
1585
1597
|
}
|
|
1586
1598
|
else {
|
|
1587
|
-
|
|
1599
|
+
// Check for existing record with same inbox_ref (uniqueness constraint)
|
|
1600
|
+
// Preserve existing identity (_ulid, created_at) when upserting by inbox_ref
|
|
1601
|
+
const existingByInboxRef = existingRecords.findIndex((r) => r.inbox_ref === record.inbox_ref);
|
|
1602
|
+
if (existingByInboxRef >= 0) {
|
|
1603
|
+
const existing = existingRecords[existingByInboxRef];
|
|
1604
|
+
existingRecords[existingByInboxRef] = {
|
|
1605
|
+
...cleanRecord,
|
|
1606
|
+
_ulid: existing._ulid,
|
|
1607
|
+
created_at: existing.created_at,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
else {
|
|
1611
|
+
existingRecords.push(cleanRecord);
|
|
1612
|
+
}
|
|
1588
1613
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1614
|
+
// Save with { kynetic_triage: "1.0", triage: [...] } format
|
|
1615
|
+
await writeYamlFilePreserveFormat(triagePath, {
|
|
1616
|
+
kynetic_triage: "1.0",
|
|
1617
|
+
triage: existingRecords,
|
|
1618
|
+
});
|
|
1594
1619
|
});
|
|
1595
1620
|
}
|
|
1596
1621
|
/**
|