@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.
Files changed (125) hide show
  1. package/dist/cli/commands/guard.d.ts +43 -0
  2. package/dist/cli/commands/guard.d.ts.map +1 -0
  3. package/dist/cli/commands/guard.js +200 -0
  4. package/dist/cli/commands/guard.js.map +1 -0
  5. package/dist/cli/commands/index.d.ts +1 -0
  6. package/dist/cli/commands/index.d.ts.map +1 -1
  7. package/dist/cli/commands/index.js +1 -0
  8. package/dist/cli/commands/index.js.map +1 -1
  9. package/dist/cli/commands/item.d.ts.map +1 -1
  10. package/dist/cli/commands/item.js +60 -23
  11. package/dist/cli/commands/item.js.map +1 -1
  12. package/dist/cli/commands/plan-import.js +51 -12
  13. package/dist/cli/commands/plan-import.js.map +1 -1
  14. package/dist/cli/commands/ralph.d.ts.map +1 -1
  15. package/dist/cli/commands/ralph.js +144 -329
  16. package/dist/cli/commands/ralph.js.map +1 -1
  17. package/dist/cli/commands/session/checkpoint.d.ts +19 -0
  18. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -0
  19. package/dist/cli/commands/session/checkpoint.js +161 -0
  20. package/dist/cli/commands/session/checkpoint.js.map +1 -0
  21. package/dist/cli/commands/session/commands.d.ts +18 -0
  22. package/dist/cli/commands/session/commands.d.ts.map +1 -0
  23. package/dist/cli/commands/session/commands.js +259 -0
  24. package/dist/cli/commands/session/commands.js.map +1 -0
  25. package/dist/cli/commands/session/context.d.ts +17 -0
  26. package/dist/cli/commands/session/context.d.ts.map +1 -0
  27. package/dist/cli/commands/session/context.js +493 -0
  28. package/dist/cli/commands/session/context.js.map +1 -0
  29. package/dist/cli/commands/session/create.d.ts +29 -0
  30. package/dist/cli/commands/session/create.d.ts.map +1 -0
  31. package/dist/cli/commands/session/create.js +147 -0
  32. package/dist/cli/commands/session/create.js.map +1 -0
  33. package/dist/cli/commands/session/format.d.ts +27 -0
  34. package/dist/cli/commands/session/format.d.ts.map +1 -0
  35. package/dist/cli/commands/session/format.js +401 -0
  36. package/dist/cli/commands/session/format.js.map +1 -0
  37. package/dist/cli/commands/session/index.d.ts +13 -0
  38. package/dist/cli/commands/session/index.d.ts.map +1 -0
  39. package/dist/cli/commands/session/index.js +17 -0
  40. package/dist/cli/commands/session/index.js.map +1 -0
  41. package/dist/cli/commands/session/log.d.ts +52 -0
  42. package/dist/cli/commands/session/log.d.ts.map +1 -0
  43. package/dist/cli/commands/session/log.js +570 -0
  44. package/dist/cli/commands/session/log.js.map +1 -0
  45. package/dist/cli/commands/session/types.d.ts +230 -0
  46. package/dist/cli/commands/session/types.d.ts.map +1 -0
  47. package/dist/cli/commands/session/types.js +7 -0
  48. package/dist/cli/commands/session/types.js.map +1 -0
  49. package/dist/cli/commands/session.d.ts +4 -179
  50. package/dist/cli/commands/session.d.ts.map +1 -1
  51. package/dist/cli/commands/session.js +6 -1424
  52. package/dist/cli/commands/session.js.map +1 -1
  53. package/dist/cli/commands/setup.d.ts.map +1 -1
  54. package/dist/cli/commands/setup.js +69 -223
  55. package/dist/cli/commands/setup.js.map +1 -1
  56. package/dist/cli/commands/task.d.ts.map +1 -1
  57. package/dist/cli/commands/task.js +95 -37
  58. package/dist/cli/commands/task.js.map +1 -1
  59. package/dist/cli/commands/validate.d.ts.map +1 -1
  60. package/dist/cli/commands/validate.js +23 -7
  61. package/dist/cli/commands/validate.js.map +1 -1
  62. package/dist/cli/index.d.ts.map +1 -1
  63. package/dist/cli/index.js +2 -1
  64. package/dist/cli/index.js.map +1 -1
  65. package/dist/cli/output.d.ts.map +1 -1
  66. package/dist/cli/output.js +14 -2
  67. package/dist/cli/output.js.map +1 -1
  68. package/dist/parser/file-lock.d.ts +14 -0
  69. package/dist/parser/file-lock.d.ts.map +1 -0
  70. package/dist/parser/file-lock.js +124 -0
  71. package/dist/parser/file-lock.js.map +1 -0
  72. package/dist/parser/index.d.ts +1 -0
  73. package/dist/parser/index.d.ts.map +1 -1
  74. package/dist/parser/index.js +1 -0
  75. package/dist/parser/index.js.map +1 -1
  76. package/dist/parser/plan-document.d.ts +36 -0
  77. package/dist/parser/plan-document.d.ts.map +1 -1
  78. package/dist/parser/plan-document.js +75 -8
  79. package/dist/parser/plan-document.js.map +1 -1
  80. package/dist/parser/plans.d.ts.map +1 -1
  81. package/dist/parser/plans.js +28 -102
  82. package/dist/parser/plans.js.map +1 -1
  83. package/dist/parser/shadow.d.ts +5 -1
  84. package/dist/parser/shadow.d.ts.map +1 -1
  85. package/dist/parser/shadow.js +29 -17
  86. package/dist/parser/shadow.js.map +1 -1
  87. package/dist/parser/validate.d.ts +4 -1
  88. package/dist/parser/validate.d.ts.map +1 -1
  89. package/dist/parser/validate.js +50 -35
  90. package/dist/parser/validate.js.map +1 -1
  91. package/dist/parser/yaml.d.ts.map +1 -1
  92. package/dist/parser/yaml.js +322 -297
  93. package/dist/parser/yaml.js.map +1 -1
  94. package/dist/schema/task.d.ts +22 -0
  95. package/dist/schema/task.d.ts.map +1 -1
  96. package/dist/schema/task.js +7 -0
  97. package/dist/schema/task.js.map +1 -1
  98. package/dist/sessions/store.d.ts +254 -1
  99. package/dist/sessions/store.d.ts.map +1 -1
  100. package/dist/sessions/store.js +621 -1
  101. package/dist/sessions/store.js.map +1 -1
  102. package/dist/sessions/types.d.ts +51 -2
  103. package/dist/sessions/types.d.ts.map +1 -1
  104. package/dist/sessions/types.js +25 -0
  105. package/dist/sessions/types.js.map +1 -1
  106. package/dist/strings/labels.d.ts +2 -0
  107. package/dist/strings/labels.d.ts.map +1 -1
  108. package/dist/strings/labels.js +2 -0
  109. package/dist/strings/labels.js.map +1 -1
  110. package/dist/utils/git.d.ts +2 -0
  111. package/dist/utils/git.d.ts.map +1 -1
  112. package/dist/utils/git.js +21 -5
  113. package/dist/utils/git.js.map +1 -1
  114. package/package.json +4 -1
  115. package/plugin/.claude-plugin/marketplace.json +1 -1
  116. package/plugin/.claude-plugin/plugin.json +1 -1
  117. package/plugin/plugins/kspec/skills/review/SKILL.md +37 -0
  118. package/plugin/plugins/kspec/skills/task-work/SKILL.md +16 -0
  119. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
  120. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +14 -0
  121. package/templates/agents-sections/05-commit-convention.md +14 -0
  122. package/templates/skills/review/SKILL.md +37 -0
  123. package/templates/skills/task-work/SKILL.md +16 -0
  124. package/templates/skills/triage-inbox/SKILL.md +1 -1
  125. package/templates/skills/writing-specs/SKILL.md +14 -0
@@ -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
- // Ensure directory exists
486
- const dir = path.dirname(taskFilePath);
487
- await fs.mkdir(dir, { recursive: true });
488
- // Load existing tasks from the target file
489
- let existingRaw = null;
490
- let useTasksWrapper = false;
491
- try {
492
- existingRaw = await readYamlFile(taskFilePath);
493
- // Detect if file uses { tasks: [...] } format
494
- if (existingRaw &&
495
- typeof existingRaw === "object" &&
496
- "tasks" in existingRaw) {
497
- useTasksWrapper = true;
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
- else if (useTasksWrapper) {
515
- // Try TasksFileSchema first (has kynetic_tasks version)
516
- const parsed = TasksFileSchema.safeParse(existingRaw);
517
- if (parsed.success) {
518
- fileTasks = parsed.data.tasks;
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
- // Fall back to raw tasks array (common format without version field)
522
- const rawTasks = existingRaw.tasks;
523
- if (Array.isArray(rawTasks)) {
524
- for (const t of rawTasks) {
525
- const result = TaskSchema.safeParse(t);
526
- if (result.success) {
527
- fileTasks.push(result.data);
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
- // Strip runtime metadata before saving
535
- const cleanTask = stripRuntimeMetadata(task);
536
- // Update existing or add new
537
- const existingIndex = fileTasks.findIndex((t) => t._ulid === task._ulid);
538
- if (existingIndex >= 0) {
539
- fileTasks[existingIndex] = cleanTask;
540
- }
541
- else {
542
- fileTasks.push(cleanTask);
543
- }
544
- // Save in the same format as original (or tasks: wrapper for new files)
545
- // Use format-preserving write to maintain formatting and comments
546
- if (useTasksWrapper) {
547
- await writeYamlFilePreserveFormat(taskFilePath, { tasks: fileTasks });
548
- }
549
- else {
550
- await writeYamlFilePreserveFormat(taskFilePath, fileTasks);
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
- // Load existing file
563
- let existingRaw = null;
564
- let useTasksWrapper = false;
565
- try {
566
- existingRaw = await readYamlFile(taskFilePath);
567
- if (existingRaw &&
568
- typeof existingRaw === "object" &&
569
- "tasks" in existingRaw) {
570
- useTasksWrapper = true;
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
- else if (useTasksWrapper) {
588
- const parsed = TasksFileSchema.safeParse(existingRaw);
589
- if (parsed.success) {
590
- fileTasks = parsed.data.tasks;
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 rawTasks = existingRaw.tasks;
594
- if (Array.isArray(rawTasks)) {
595
- for (const t of rawTasks) {
596
- const result = TaskSchema.safeParse(t);
597
- if (result.success) {
598
- fileTasks.push(result.data);
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
- // Remove the task
606
- const originalCount = fileTasks.length;
607
- fileTasks = fileTasks.filter((t) => t._ulid !== task._ulid);
608
- if (fileTasks.length === originalCount) {
609
- throw new Error(`Task not found in file: ${task._ulid}`);
610
- }
611
- // Save the modified file with format preservation
612
- if (useTasksWrapper) {
613
- await writeYamlFilePreserveFormat(taskFilePath, { tasks: fileTasks });
614
- }
615
- else {
616
- await writeYamlFilePreserveFormat(taskFilePath, fileTasks);
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
- // Load the raw YAML
1176
- const raw = await readYamlFile(parent._sourceFile);
1177
- // Find the parent in the structure
1178
- let parentObj;
1179
- let parentPath;
1180
- if (parent._path) {
1181
- const nav = navigateToPath(raw, parent._path);
1182
- if (!nav) {
1183
- throw new Error(`Could not navigate to parent path: ${parent._path}`);
1184
- }
1185
- parentObj = nav.array[nav.index];
1186
- parentPath = parent._path;
1187
- }
1188
- else {
1189
- // Parent is the root item
1190
- parentObj = raw;
1191
- parentPath = "";
1192
- }
1193
- // Ensure the child field array exists
1194
- if (!Array.isArray(parentObj[field])) {
1195
- parentObj[field] = [];
1196
- }
1197
- // Add the child
1198
- const childArray = parentObj[field];
1199
- const cleanChild = stripSpecItemMetadata(child);
1200
- childArray.push(cleanChild);
1201
- // Calculate the new child's path
1202
- const childIndex = childArray.length - 1;
1203
- const childPath = parentPath
1204
- ? `${parentPath}.${field}[${childIndex}]`
1205
- : `${field}[${childIndex}]`;
1206
- // Write back with format preservation
1207
- await writeYamlFilePreserveFormat(parent._sourceFile, raw);
1208
- return { item: cleanChild, path: childPath };
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
- // Load the raw YAML
1219
- const raw = await readYamlFile(item._sourceFile);
1220
- // Find the item in the structure (use stored path or search by ULID)
1221
- let targetObj;
1222
- if (item._path) {
1223
- const nav = navigateToPath(raw, item._path);
1224
- if (!nav) {
1225
- throw new Error(`Could not navigate to path: ${item._path}`);
1226
- }
1227
- targetObj = nav.array[nav.index];
1228
- }
1229
- else {
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
- throw new Error(`Could not find item ${item._ulid} in structure`);
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
- // Apply updates (but never change _ulid)
1243
- for (const [key, value] of Object.entries(updates)) {
1244
- if (key !== "_ulid" && key !== "_sourceFile" && key !== "_path") {
1245
- targetObj[key] = value;
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
- // Write back with format preservation
1249
- await writeYamlFilePreserveFormat(item._sourceFile, raw);
1250
- return { ...item, ...updates, _ulid: item._ulid };
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
- try {
1278
- const raw = await readYamlFile(item._sourceFile);
1279
- // If item has a path, navigate to it and remove from parent array
1280
- if (item._path) {
1281
- const nav = navigateToPath(raw, item._path);
1282
- if (!nav) {
1283
- return false;
1284
- }
1285
- // Remove the item from the array
1286
- nav.array.splice(nav.index, 1);
1287
- await writeYamlFilePreserveFormat(item._sourceFile, raw);
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
- // Maybe it's a root-level array item
1301
- if (Array.isArray(raw)) {
1302
- const index = raw.findIndex((i) => typeof i === "object" &&
1303
- i !== null &&
1304
- i._ulid === item._ulid);
1305
- if (index >= 0) {
1306
- raw.splice(index, 1);
1307
- await writeYamlFilePreserveFormat(item._sourceFile, raw);
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
- return false;
1312
- }
1313
- catch {
1314
- return false;
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
- // Ensure directory exists
1404
- const dir = path.dirname(inboxPath);
1405
- await fs.mkdir(dir, { recursive: true });
1406
- // Load existing items
1407
- let existingItems = [];
1408
- try {
1409
- const raw = await readYamlFile(inboxPath);
1410
- if (raw && typeof raw === "object" && "inbox" in raw) {
1411
- const parsed = InboxFileSchema.safeParse(raw);
1412
- if (parsed.success) {
1413
- existingItems = parsed.data.inbox;
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
- else if (Array.isArray(raw)) {
1417
- for (const i of raw) {
1418
- const result = InboxItemSchema.safeParse(i);
1419
- if (result.success) {
1420
- existingItems.push(result.data);
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
- catch {
1426
- // File doesn't exist, start fresh
1427
- }
1428
- const cleanItem = stripInboxMetadata(item);
1429
- // Update existing or add new
1430
- const existingIndex = existingItems.findIndex((i) => i._ulid === item._ulid);
1431
- if (existingIndex >= 0) {
1432
- existingItems[existingIndex] = cleanItem;
1433
- }
1434
- else {
1435
- existingItems.push(cleanItem);
1436
- }
1437
- // Save with { inbox: [...] } format and format preservation
1438
- await writeYamlFilePreserveFormat(inboxPath, { inbox: existingItems });
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
- try {
1446
- const raw = await readYamlFile(inboxPath);
1447
- let existingItems = [];
1448
- if (raw && typeof raw === "object" && "inbox" in raw) {
1449
- const parsed = InboxFileSchema.safeParse(raw);
1450
- if (parsed.success) {
1451
- existingItems = parsed.data.inbox;
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
- const index = existingItems.findIndex((i) => i._ulid === ulid);
1455
- if (index < 0) {
1483
+ catch {
1456
1484
  return false;
1457
1485
  }
1458
- existingItems.splice(index, 1);
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
- // Ensure directory exists
1542
- const dir = path.dirname(triagePath);
1543
- await fs.mkdir(dir, { recursive: true });
1544
- // Load existing records
1545
- let existingRecords = [];
1546
- try {
1547
- const raw = await readYamlFile(triagePath);
1548
- if (raw && typeof raw === "object" && "triage" in raw) {
1549
- const parsed = TriageFileSchema.safeParse(raw);
1550
- if (parsed.success) {
1551
- existingRecords = parsed.data.triage;
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
- else if (Array.isArray(raw)) {
1555
- for (const item of raw) {
1556
- const result = TriageRecordSchema.safeParse(item);
1557
- if (result.success) {
1558
- existingRecords.push(result.data);
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
- catch {
1564
- // File doesn't exist, start fresh
1565
- }
1566
- const cleanRecord = stripTriageMetadata(record);
1567
- // AC: ac-9 — set updated_at on every mutation
1568
- cleanRecord.updated_at = new Date().toISOString();
1569
- // AC: ac-8 upsert: check for existing record by ULID first, then by inbox_ref
1570
- const existingByUlid = existingRecords.findIndex((r) => r._ulid === record._ulid);
1571
- if (existingByUlid >= 0) {
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
- existingRecords.push(cleanRecord);
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
- // Save with { kynetic_triage: "1.0", triage: [...] } format
1591
- await writeYamlFilePreserveFormat(triagePath, {
1592
- kynetic_triage: "1.0",
1593
- triage: existingRecords,
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
  /**