@kynetic-ai/spec 0.9.0 → 0.10.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 (291) hide show
  1. package/README.md +2 -1
  2. package/dist/acp/client.d.ts +13 -1
  3. package/dist/acp/client.d.ts.map +1 -1
  4. package/dist/acp/client.js +17 -2
  5. package/dist/acp/client.js.map +1 -1
  6. package/dist/acp/framing.d.ts +12 -1
  7. package/dist/acp/framing.d.ts.map +1 -1
  8. package/dist/acp/framing.js +27 -4
  9. package/dist/acp/framing.js.map +1 -1
  10. package/dist/agent-runtime/dispatch.d.ts +261 -0
  11. package/dist/agent-runtime/dispatch.d.ts.map +1 -0
  12. package/dist/agent-runtime/dispatch.js +791 -0
  13. package/dist/agent-runtime/dispatch.js.map +1 -0
  14. package/dist/agent-runtime/index.d.ts +11 -0
  15. package/dist/agent-runtime/index.d.ts.map +1 -0
  16. package/dist/agent-runtime/index.js +11 -0
  17. package/dist/agent-runtime/index.js.map +1 -0
  18. package/dist/agent-runtime/invocation.d.ts +86 -0
  19. package/dist/agent-runtime/invocation.d.ts.map +1 -0
  20. package/dist/agent-runtime/invocation.js +442 -0
  21. package/dist/agent-runtime/invocation.js.map +1 -0
  22. package/dist/agent-runtime/prompts.d.ts +50 -0
  23. package/dist/agent-runtime/prompts.d.ts.map +1 -0
  24. package/dist/agent-runtime/prompts.js +108 -0
  25. package/dist/agent-runtime/prompts.js.map +1 -0
  26. package/dist/agents/spawner.d.ts.map +1 -1
  27. package/dist/agents/spawner.js +60 -4
  28. package/dist/agents/spawner.js.map +1 -1
  29. package/dist/cli/batch-exec.d.ts.map +1 -1
  30. package/dist/cli/batch-exec.js +183 -81
  31. package/dist/cli/batch-exec.js.map +1 -1
  32. package/dist/cli/batch-write-buffer.d.ts +141 -0
  33. package/dist/cli/batch-write-buffer.d.ts.map +1 -0
  34. package/dist/cli/batch-write-buffer.js +400 -0
  35. package/dist/cli/batch-write-buffer.js.map +1 -0
  36. package/dist/cli/commands/agent.d.ts +20 -0
  37. package/dist/cli/commands/agent.d.ts.map +1 -0
  38. package/dist/cli/commands/agent.js +831 -0
  39. package/dist/cli/commands/agent.js.map +1 -0
  40. package/dist/cli/commands/agents.d.ts +1 -1
  41. package/dist/cli/commands/agents.d.ts.map +1 -1
  42. package/dist/cli/commands/agents.js +2 -1
  43. package/dist/cli/commands/agents.js.map +1 -1
  44. package/dist/cli/commands/batch.js +1 -1
  45. package/dist/cli/commands/batch.js.map +1 -1
  46. package/dist/cli/commands/inbox.d.ts.map +1 -1
  47. package/dist/cli/commands/inbox.js +46 -22
  48. package/dist/cli/commands/inbox.js.map +1 -1
  49. package/dist/cli/commands/index.d.ts +1 -0
  50. package/dist/cli/commands/index.d.ts.map +1 -1
  51. package/dist/cli/commands/index.js +1 -0
  52. package/dist/cli/commands/index.js.map +1 -1
  53. package/dist/cli/commands/init.d.ts.map +1 -1
  54. package/dist/cli/commands/init.js +4 -6
  55. package/dist/cli/commands/init.js.map +1 -1
  56. package/dist/cli/commands/item.d.ts.map +1 -1
  57. package/dist/cli/commands/item.js +34 -17
  58. package/dist/cli/commands/item.js.map +1 -1
  59. package/dist/cli/commands/log.js +1 -1
  60. package/dist/cli/commands/log.js.map +1 -1
  61. package/dist/cli/commands/merge-driver.d.ts.map +1 -1
  62. package/dist/cli/commands/merge-driver.js +8 -3
  63. package/dist/cli/commands/merge-driver.js.map +1 -1
  64. package/dist/cli/commands/meta.d.ts.map +1 -1
  65. package/dist/cli/commands/meta.js +159 -6
  66. package/dist/cli/commands/meta.js.map +1 -1
  67. package/dist/cli/commands/module.d.ts.map +1 -1
  68. package/dist/cli/commands/module.js +2 -1
  69. package/dist/cli/commands/module.js.map +1 -1
  70. package/dist/cli/commands/plan-import.js +19 -3
  71. package/dist/cli/commands/plan-import.js.map +1 -1
  72. package/dist/cli/commands/plan.d.ts.map +1 -1
  73. package/dist/cli/commands/plan.js +87 -43
  74. package/dist/cli/commands/plan.js.map +1 -1
  75. package/dist/cli/commands/ralph.d.ts +5 -51
  76. package/dist/cli/commands/ralph.d.ts.map +1 -1
  77. package/dist/cli/commands/ralph.js +52 -1462
  78. package/dist/cli/commands/ralph.js.map +1 -1
  79. package/dist/cli/commands/search.d.ts.map +1 -1
  80. package/dist/cli/commands/search.js +22 -13
  81. package/dist/cli/commands/search.js.map +1 -1
  82. package/dist/cli/commands/serve.d.ts.map +1 -1
  83. package/dist/cli/commands/serve.js +70 -11
  84. package/dist/cli/commands/serve.js.map +1 -1
  85. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  86. package/dist/cli/commands/session/checkpoint.js +7 -2
  87. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  88. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  89. package/dist/cli/commands/session/commands.js +15 -0
  90. package/dist/cli/commands/session/commands.js.map +1 -1
  91. package/dist/cli/commands/session/context.d.ts.map +1 -1
  92. package/dist/cli/commands/session/context.js +10 -5
  93. package/dist/cli/commands/session/context.js.map +1 -1
  94. package/dist/cli/commands/session/log.d.ts +1 -0
  95. package/dist/cli/commands/session/log.d.ts.map +1 -1
  96. package/dist/cli/commands/session/log.js +124 -8
  97. package/dist/cli/commands/session/log.js.map +1 -1
  98. package/dist/cli/commands/session/stale-close.d.ts +17 -0
  99. package/dist/cli/commands/session/stale-close.d.ts.map +1 -0
  100. package/dist/cli/commands/session/stale-close.js +378 -0
  101. package/dist/cli/commands/session/stale-close.js.map +1 -0
  102. package/dist/cli/commands/setup.d.ts +4 -0
  103. package/dist/cli/commands/setup.d.ts.map +1 -1
  104. package/dist/cli/commands/setup.js +150 -6
  105. package/dist/cli/commands/setup.js.map +1 -1
  106. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  107. package/dist/cli/commands/skill-crud.js +4 -3
  108. package/dist/cli/commands/skill-crud.js.map +1 -1
  109. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  110. package/dist/cli/commands/skill-diff.js +15 -0
  111. package/dist/cli/commands/skill-diff.js.map +1 -1
  112. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  113. package/dist/cli/commands/skill-install.js +50 -18
  114. package/dist/cli/commands/skill-install.js.map +1 -1
  115. package/dist/cli/commands/task.d.ts.map +1 -1
  116. package/dist/cli/commands/task.js +552 -323
  117. package/dist/cli/commands/task.js.map +1 -1
  118. package/dist/cli/commands/tasks.js +1 -1
  119. package/dist/cli/commands/tasks.js.map +1 -1
  120. package/dist/cli/commands/triage.d.ts.map +1 -1
  121. package/dist/cli/commands/triage.js +37 -13
  122. package/dist/cli/commands/triage.js.map +1 -1
  123. package/dist/cli/commands/validate.d.ts.map +1 -1
  124. package/dist/cli/commands/validate.js +99 -50
  125. package/dist/cli/commands/validate.js.map +1 -1
  126. package/dist/cli/help/content.d.ts.map +1 -1
  127. package/dist/cli/help/content.js +5 -0
  128. package/dist/cli/help/content.js.map +1 -1
  129. package/dist/cli/index.d.ts.map +1 -1
  130. package/dist/cli/index.js +2 -1
  131. package/dist/cli/index.js.map +1 -1
  132. package/dist/cli/output.d.ts.map +1 -1
  133. package/dist/cli/output.js +5 -1
  134. package/dist/cli/output.js.map +1 -1
  135. package/dist/cli/validators.d.ts +4 -0
  136. package/dist/cli/validators.d.ts.map +1 -1
  137. package/dist/cli/validators.js +12 -0
  138. package/dist/cli/validators.js.map +1 -1
  139. package/dist/daemon/project-context.ts +22 -0
  140. package/dist/daemon/routes/agent-dispatch.ts +272 -0
  141. package/dist/daemon/server.ts +55 -20
  142. package/dist/daemon/websocket/handler.ts +67 -6
  143. package/dist/daemon/websocket/lifecycle.ts +19 -0
  144. package/dist/daemon/websocket/pubsub.ts +74 -3
  145. package/dist/export/html.d.ts.map +1 -1
  146. package/dist/export/html.js +5 -2
  147. package/dist/export/html.js.map +1 -1
  148. package/dist/export/triage.d.ts +1 -1
  149. package/dist/export/triage.d.ts.map +1 -1
  150. package/dist/export/triage.js +5 -3
  151. package/dist/export/triage.js.map +1 -1
  152. package/dist/parser/alignment.d.ts.map +1 -1
  153. package/dist/parser/alignment.js +6 -3
  154. package/dist/parser/alignment.js.map +1 -1
  155. package/dist/parser/assess.js +1 -1
  156. package/dist/parser/assess.js.map +1 -1
  157. package/dist/parser/config.d.ts +6 -6
  158. package/dist/parser/meta.d.ts.map +1 -1
  159. package/dist/parser/meta.js +9 -8
  160. package/dist/parser/meta.js.map +1 -1
  161. package/dist/parser/plan-document.d.ts +12 -12
  162. package/dist/parser/plans.d.ts +7 -0
  163. package/dist/parser/plans.d.ts.map +1 -1
  164. package/dist/parser/plans.js +100 -15
  165. package/dist/parser/plans.js.map +1 -1
  166. package/dist/parser/refs.d.ts +5 -0
  167. package/dist/parser/refs.d.ts.map +1 -1
  168. package/dist/parser/refs.js +17 -12
  169. package/dist/parser/refs.js.map +1 -1
  170. package/dist/parser/shadow.d.ts +1 -1
  171. package/dist/parser/shadow.d.ts.map +1 -1
  172. package/dist/parser/shadow.js +241 -76
  173. package/dist/parser/shadow.js.map +1 -1
  174. package/dist/parser/skill-render.d.ts.map +1 -1
  175. package/dist/parser/skill-render.js +6 -3
  176. package/dist/parser/skill-render.js.map +1 -1
  177. package/dist/parser/validate.d.ts.map +1 -1
  178. package/dist/parser/validate.js +70 -108
  179. package/dist/parser/validate.js.map +1 -1
  180. package/dist/parser/yaml.d.ts +24 -5
  181. package/dist/parser/yaml.d.ts.map +1 -1
  182. package/dist/parser/yaml.js +228 -66
  183. package/dist/parser/yaml.js.map +1 -1
  184. package/dist/schema/meta.d.ts +442 -119
  185. package/dist/schema/meta.d.ts.map +1 -1
  186. package/dist/schema/meta.js +55 -0
  187. package/dist/schema/meta.js.map +1 -1
  188. package/dist/schema/plan.d.ts +22 -22
  189. package/dist/schema/spec.d.ts +39 -39
  190. package/dist/schema/task.d.ts +43 -32
  191. package/dist/schema/task.d.ts.map +1 -1
  192. package/dist/schema/task.js +5 -0
  193. package/dist/schema/task.js.map +1 -1
  194. package/dist/sessions/store.d.ts +112 -0
  195. package/dist/sessions/store.d.ts.map +1 -1
  196. package/dist/sessions/store.js +414 -22
  197. package/dist/sessions/store.js.map +1 -1
  198. package/dist/sessions/types.d.ts +75 -17
  199. package/dist/sessions/types.d.ts.map +1 -1
  200. package/dist/sessions/types.js +51 -1
  201. package/dist/sessions/types.js.map +1 -1
  202. package/dist/triage/actions.d.ts +1 -0
  203. package/dist/triage/actions.d.ts.map +1 -1
  204. package/dist/triage/actions.js +34 -7
  205. package/dist/triage/actions.js.map +1 -1
  206. package/dist/utils/commit.js +1 -1
  207. package/dist/utils/commit.js.map +1 -1
  208. package/dist/web-ui/_app/env.js +1 -0
  209. package/dist/web-ui/_app/immutable/assets/0.BxCxvrZR.css +1 -0
  210. package/dist/web-ui/_app/immutable/assets/select-trigger.CV-KWLNP.css +1 -0
  211. package/dist/web-ui/_app/immutable/chunks/B-CZR0q8.js +1 -0
  212. package/dist/web-ui/_app/immutable/chunks/B1IR5Su5.js +1 -0
  213. package/dist/web-ui/_app/immutable/chunks/BCkp8Hs8.js +1 -0
  214. package/dist/web-ui/_app/immutable/chunks/B_Cvvtc4.js +1 -0
  215. package/dist/web-ui/_app/immutable/chunks/BtFaGGII.js +1 -0
  216. package/dist/web-ui/_app/immutable/chunks/Bu8JVsCH.js +1 -0
  217. package/dist/web-ui/_app/immutable/chunks/C87u-CNA.js +1 -0
  218. package/dist/web-ui/_app/immutable/chunks/CrFkBTYp.js +1 -0
  219. package/dist/web-ui/_app/immutable/chunks/D1ArdqNb.js +1 -0
  220. package/dist/web-ui/_app/immutable/chunks/D28BF5MJ.js +1 -0
  221. package/dist/web-ui/_app/immutable/chunks/D6RtLpzL.js +1 -0
  222. package/dist/web-ui/_app/immutable/chunks/D7FHSgx2.js +1 -0
  223. package/dist/web-ui/_app/immutable/chunks/DBXrsxZQ.js +2 -0
  224. package/dist/web-ui/_app/immutable/chunks/Da_hHMuA.js +1 -0
  225. package/dist/web-ui/_app/immutable/chunks/Do6LchSF.js +1 -0
  226. package/dist/web-ui/_app/immutable/chunks/DoNPtcAw.js +1 -0
  227. package/dist/web-ui/_app/immutable/chunks/DtUbXRZz.js +1 -0
  228. package/dist/web-ui/_app/immutable/chunks/DyFPRlLl.js +1 -0
  229. package/dist/web-ui/_app/immutable/chunks/DzAP8lRM.js +1 -0
  230. package/dist/web-ui/_app/immutable/chunks/DzVXElzN.js +2 -0
  231. package/dist/web-ui/_app/immutable/chunks/aoPBFken.js +1 -0
  232. package/dist/web-ui/_app/immutable/chunks/i-XnOIX0.js +1 -0
  233. package/dist/web-ui/_app/immutable/chunks/laxtrUO3.js +1 -0
  234. package/dist/web-ui/_app/immutable/chunks/q1nIWgqB.js +1 -0
  235. package/dist/web-ui/_app/immutable/chunks/sTLbk5Nm.js +1 -0
  236. package/dist/web-ui/_app/immutable/chunks/vwKgQu5P.js +5 -0
  237. package/dist/web-ui/_app/immutable/entry/app.BCwMcqnT.js +2 -0
  238. package/dist/web-ui/_app/immutable/entry/start.wKCQH-tt.js +1 -0
  239. package/dist/web-ui/_app/immutable/nodes/0.CjGVMG74.js +1 -0
  240. package/dist/web-ui/_app/immutable/nodes/1.B6_AIPan.js +1 -0
  241. package/dist/web-ui/_app/immutable/nodes/2.q4oCS7Ws.js +1 -0
  242. package/dist/web-ui/_app/immutable/nodes/3.rTKZf9o2.js +1 -0
  243. package/dist/web-ui/_app/immutable/nodes/4.DVIDRu1d.js +1 -0
  244. package/dist/web-ui/_app/immutable/nodes/5.8PtPXIOd.js +1 -0
  245. package/dist/web-ui/_app/immutable/nodes/6.ZZrTemy_.js +1 -0
  246. package/dist/web-ui/_app/immutable/nodes/7.IP-gxCxi.js +1 -0
  247. package/dist/web-ui/_app/version.json +1 -0
  248. package/dist/web-ui/index.html +36 -0
  249. package/dist/web-ui/robots.txt +3 -0
  250. package/package.json +3 -2
  251. package/plugin/.claude-plugin/marketplace.json +1 -1
  252. package/plugin/.claude-plugin/plugin.json +1 -1
  253. package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +1 -1
  254. package/plugin/plugins/kspec/skills/{observations → observe}/SKILL.md +1 -1
  255. package/plugin/plugins/kspec/skills/plan/SKILL.md +1 -1
  256. package/plugin/plugins/kspec/skills/task-work/SKILL.md +26 -3
  257. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
  258. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +1 -1
  259. package/templates/agents-sections/01-quick-start.md +1 -0
  260. package/templates/agents-sections/06-ralph-loop.md +64 -11
  261. package/templates/skills/create-workflow/SKILL.md +1 -1
  262. package/templates/skills/manifest.yaml +1 -1
  263. package/templates/skills/plan/SKILL.md +1 -1
  264. package/templates/skills/task-work/SKILL.md +26 -3
  265. package/templates/skills/triage-inbox/SKILL.md +1 -1
  266. package/templates/skills/writing-specs/SKILL.md +1 -1
  267. package/dist/ralph/cli-renderer.d.ts +0 -27
  268. package/dist/ralph/cli-renderer.d.ts.map +0 -1
  269. package/dist/ralph/cli-renderer.js +0 -250
  270. package/dist/ralph/cli-renderer.js.map +0 -1
  271. package/dist/ralph/events.d.ts +0 -65
  272. package/dist/ralph/events.d.ts.map +0 -1
  273. package/dist/ralph/events.js +0 -600
  274. package/dist/ralph/events.js.map +0 -1
  275. package/dist/ralph/index.d.ts +0 -11
  276. package/dist/ralph/index.d.ts.map +0 -1
  277. package/dist/ralph/index.js +0 -16
  278. package/dist/ralph/index.js.map +0 -1
  279. package/dist/ralph/loop-errors.d.ts +0 -83
  280. package/dist/ralph/loop-errors.d.ts.map +0 -1
  281. package/dist/ralph/loop-errors.js +0 -150
  282. package/dist/ralph/loop-errors.js.map +0 -1
  283. package/dist/ralph/subagent.d.ts +0 -96
  284. package/dist/ralph/subagent.d.ts.map +0 -1
  285. package/dist/ralph/subagent.js +0 -195
  286. package/dist/ralph/subagent.js.map +0 -1
  287. package/dist/ralph/wrap-up.d.ts +0 -127
  288. package/dist/ralph/wrap-up.d.ts.map +0 -1
  289. package/dist/ralph/wrap-up.js +0 -271
  290. package/dist/ralph/wrap-up.js.map +0 -1
  291. /package/templates/skills/{observations → observe}/SKILL.md +0 -0
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { ulid } from "ulid";
5
5
  import * as YAML from "yaml";
6
6
  import { withFileLock } from "./file-lock.js";
7
+ import { accessBufferAware, getActiveBatchBuffer, readdirBufferAware, } from "../cli/batch-write-buffer.js";
7
8
  import { InboxFileSchema, InboxItemSchema, ManifestSchema, SpecItemSchema, TaskSchema, TasksFileSchema, TriageFileSchema, TriageRecordSchema, } from "../schema/index.js";
8
9
  import { errors } from "../strings/index.js";
9
10
  import { ItemIndex } from "./items.js";
@@ -68,10 +69,32 @@ export function toYaml(obj) {
68
69
  return yamlString;
69
70
  }
70
71
  /**
71
- * Read and parse a YAML file
72
+ * Read a text file with batch buffer overlay semantics.
73
+ */
74
+ export async function readFileBufferAware(filePath) {
75
+ // AC: @batch-write-buffer ac-2 — check buffer first for read-after-write consistency
76
+ const buffer = getActiveBatchBuffer();
77
+ if (buffer?.isInScope(filePath)) {
78
+ const buffered = buffer.read(filePath);
79
+ if (buffered !== undefined) {
80
+ if (buffered === null) {
81
+ // File was deleted in this batch
82
+ throw Object.assign(new Error(`ENOENT: no such file or directory, open '${filePath}'`), {
83
+ code: "ENOENT",
84
+ });
85
+ }
86
+ return typeof buffered === "string"
87
+ ? buffered
88
+ : Buffer.from(buffered).toString("utf-8");
89
+ }
90
+ }
91
+ return fs.readFile(filePath, "utf-8");
92
+ }
93
+ /**
94
+ * Read and parse a YAML file.
72
95
  */
73
96
  export async function readYamlFile(filePath) {
74
- const content = await fs.readFile(filePath, "utf-8");
97
+ const content = await readFileBufferAware(filePath);
75
98
  return parseYaml(content);
76
99
  }
77
100
  /**
@@ -79,6 +102,12 @@ export async function readYamlFile(filePath) {
79
102
  */
80
103
  export async function writeYamlFile(filePath, data) {
81
104
  const content = toYaml(data);
105
+ // AC: @batch-write-buffer ac-1 — buffer write if in batch mode
106
+ const buffer = getActiveBatchBuffer();
107
+ if (buffer?.isInScope(filePath)) {
108
+ buffer.write(filePath, content);
109
+ return;
110
+ }
82
111
  await fs.writeFile(filePath, content, "utf-8");
83
112
  }
84
113
  /**
@@ -90,6 +119,12 @@ export async function writeYamlFile(filePath, data) {
90
119
  */
91
120
  export async function writeYamlFilePreserveFormat(filePath, data) {
92
121
  const content = toYaml(data);
122
+ // AC: @batch-write-buffer ac-1 — buffer write if in batch mode
123
+ const buffer = getActiveBatchBuffer();
124
+ if (buffer?.isInScope(filePath)) {
125
+ buffer.write(filePath, content);
126
+ return;
127
+ }
93
128
  await fs.writeFile(filePath, content, "utf-8");
94
129
  }
95
130
  /**
@@ -98,7 +133,7 @@ export async function writeYamlFilePreserveFormat(filePath, data) {
98
133
  export async function findTaskFiles(dir) {
99
134
  const files = [];
100
135
  try {
101
- const entries = await fs.readdir(dir, { withFileTypes: true });
136
+ const entries = await readdirBufferAware(dir, { withFileTypes: true });
102
137
  for (const entry of entries) {
103
138
  const fullPath = path.join(dir, entry.name);
104
139
  if (entry.isDirectory()) {
@@ -292,7 +327,7 @@ async function findManifestInDir(dir) {
292
327
  for (const candidate of priorityCandidates) {
293
328
  const filePath = path.join(dir, candidate);
294
329
  try {
295
- await fs.access(filePath);
330
+ await accessBufferAware(filePath);
296
331
  return filePath;
297
332
  }
298
333
  catch {
@@ -301,7 +336,7 @@ async function findManifestInDir(dir) {
301
336
  }
302
337
  // AC: @manifest-discovery ac-3, ac-4, ac-5 - glob fallback with validation
303
338
  try {
304
- const entries = await fs.readdir(dir);
339
+ const entries = await readdirBufferAware(dir);
305
340
  // AC: @manifest-discovery ac-4 - alphabetical order
306
341
  const candidates = entries.filter(isManifestCandidate).sort();
307
342
  for (const candidate of candidates) {
@@ -373,8 +408,10 @@ async function loadTasksFromFile(filePath) {
373
408
  */
374
409
  export async function loadAllTasks(ctx) {
375
410
  const tasks = [];
376
- // When shadow is enabled, look only in specDir
377
- if (ctx.shadow?.enabled) {
411
+ // When shadow is enabled (or spec dir is explicitly overridden), look only in specDir.
412
+ // KSPEC_SPEC_DIR override is used by batch mode and some integration tests to isolate
413
+ // task state to a temp directory; scanning ctx.rootDir can leak tasks from parent dirs.
414
+ if (ctx.shadow?.enabled || Boolean(process.env.KSPEC_SPEC_DIR)) {
378
415
  const taskFiles = await findTaskFiles(ctx.specDir);
379
416
  // Also check for standalone files in specDir
380
417
  const standaloneLocations = [
@@ -386,7 +423,7 @@ export async function loadAllTasks(ctx) {
386
423
  ];
387
424
  for (const loc of standaloneLocations) {
388
425
  try {
389
- await fs.access(loc);
426
+ await accessBufferAware(loc);
390
427
  if (!taskFiles.includes(loc)) {
391
428
  taskFiles.push(loc);
392
429
  }
@@ -424,7 +461,7 @@ export async function loadAllTasks(ctx) {
424
461
  ];
425
462
  for (const loc of standaloneLocations) {
426
463
  try {
427
- await fs.access(loc);
464
+ await accessBufferAware(loc);
428
465
  if (!taskFiles.includes(loc)) {
429
466
  taskFiles.push(loc);
430
467
  }
@@ -554,6 +591,58 @@ export async function saveTask(ctx, task) {
554
591
  }
555
592
  });
556
593
  }
594
+ /**
595
+ * Atomically mutate a task using the latest on-disk state.
596
+ *
597
+ * The callback receives the current task value while holding the task file lock,
598
+ * so concurrent writers cannot clobber unrelated fields (for example status vs notes).
599
+ */
600
+ export async function mutateTaskAtomically(ctx, task, mutate) {
601
+ const taskFilePath = task._sourceFile || getDefaultTaskFilePath(ctx);
602
+ let updatedTask;
603
+ await withFileLock(taskFilePath, async () => {
604
+ // Ensure directory exists (important for default path in new repos)
605
+ const dir = path.dirname(taskFilePath);
606
+ await fs.mkdir(dir, { recursive: true });
607
+ // Preserve existing file format (tasks wrapper vs plain array)
608
+ let existingRaw = null;
609
+ let useTasksWrapper = false;
610
+ try {
611
+ existingRaw = await readYamlFile(taskFilePath);
612
+ if (existingRaw &&
613
+ typeof existingRaw === "object" &&
614
+ "tasks" in existingRaw) {
615
+ useTasksWrapper = true;
616
+ }
617
+ }
618
+ catch {
619
+ throw new Error(`Task file not found: ${taskFilePath}`);
620
+ }
621
+ const fileTasks = await loadTasksFromFile(taskFilePath);
622
+ const taskIndex = fileTasks.findIndex((t) => t._ulid === task._ulid);
623
+ if (taskIndex === -1) {
624
+ throw new Error(`Task not found in file: ${task._ulid}`);
625
+ }
626
+ const latestTask = fileTasks[taskIndex];
627
+ const mutatedTask = await mutate(latestTask);
628
+ const cleanMutatedTask = stripRuntimeMetadata(mutatedTask);
629
+ const serializedTasks = fileTasks.map((fileTask, index) => index === taskIndex ? cleanMutatedTask : stripRuntimeMetadata(fileTask));
630
+ if (useTasksWrapper) {
631
+ await writeYamlFilePreserveFormat(taskFilePath, { tasks: serializedTasks });
632
+ }
633
+ else {
634
+ await writeYamlFilePreserveFormat(taskFilePath, serializedTasks);
635
+ }
636
+ updatedTask = {
637
+ ...cleanMutatedTask,
638
+ _sourceFile: taskFilePath,
639
+ };
640
+ });
641
+ if (!updatedTask) {
642
+ throw new Error(`Failed to mutate task atomically: ${task._ulid}`);
643
+ }
644
+ return updatedTask;
645
+ }
557
646
  /**
558
647
  * Delete a task from its source file.
559
648
  * Requires _sourceFile to know which file to modify.
@@ -736,28 +825,34 @@ export function areDependenciesMet(task, allTasks) {
736
825
  return true;
737
826
  }
738
827
  /**
739
- * Check if task is ready (pending + deps met + not blocked)
828
+ * Check if task is ready (pending/needs_work + deps met + not blocked)
740
829
  */
741
830
  export function isTaskReady(task, allTasks) {
742
- if (task.status !== "pending")
831
+ if (task.status !== "pending" && task.status !== "needs_work")
743
832
  return false;
744
833
  if (task.blocked_by.length > 0)
745
834
  return false;
746
835
  return areDependenciesMet(task, allTasks);
747
836
  }
748
837
  /**
749
- * Get ready tasks (pending + deps met + not blocked), sorted by priority then creation time.
750
- * Within the same priority tier, older tasks come first (FIFO).
838
+ * Get ready tasks (pending/needs_work + deps met + not blocked), sorted by
839
+ * status (needs_work first), then priority, then creation time.
840
+ * Within the same tier, older tasks come first (FIFO).
751
841
  */
752
842
  export function getReadyTasks(tasks) {
753
843
  return tasks
754
844
  .filter((task) => isTaskReady(task, tasks))
755
845
  .sort((a, b) => {
756
- // Primary: priority (lower number = higher priority)
846
+ // Primary: needs_work before pending (fix cycles take priority)
847
+ const statusOrder = (s) => (s === "needs_work" ? 0 : 1);
848
+ const statusDiff = statusOrder(a.status) - statusOrder(b.status);
849
+ if (statusDiff !== 0)
850
+ return statusDiff;
851
+ // Secondary: priority (lower number = higher priority)
757
852
  if (a.priority !== b.priority) {
758
853
  return a.priority - b.priority;
759
854
  }
760
- // Secondary: creation time (older first - FIFO within priority)
855
+ // Tertiary: creation time (older first - FIFO within priority)
761
856
  return (new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
762
857
  });
763
858
  }
@@ -775,7 +870,7 @@ export async function expandIncludePattern(pattern, baseDir) {
775
870
  // If no glob characters, just return the path if it exists
776
871
  if (!pattern.includes("*")) {
777
872
  try {
778
- await fs.access(fullPattern);
873
+ await accessBufferAware(fullPattern);
779
874
  return [fullPattern];
780
875
  }
781
876
  catch {
@@ -805,7 +900,7 @@ async function expandGlobRecursive(dir, pattern, result) {
805
900
  const currentPattern = parts[0];
806
901
  const remainingPattern = parts.slice(1).join("/");
807
902
  try {
808
- const entries = await fs.readdir(dir, { withFileTypes: true });
903
+ const entries = await readdirBufferAware(dir, { withFileTypes: true });
809
904
  for (const entry of entries) {
810
905
  const matches = matchGlobPart(entry.name, currentPattern);
811
906
  if (matches) {
@@ -948,7 +1043,7 @@ export function extractItemsFromRaw(raw, sourceFile, items = [], currentPath = "
948
1043
  */
949
1044
  export async function loadSpecFile(filePath) {
950
1045
  try {
951
- const content = await fs.readFile(filePath, "utf-8");
1046
+ const content = await readFileBufferAware(filePath);
952
1047
  const items = [];
953
1048
  // Parse all YAML documents in the file (handles files with ---)
954
1049
  const documents = YAML.parseAllDocuments(content);
@@ -1058,6 +1153,12 @@ function stripSpecItemMetadata(item) {
1058
1153
  const { _sourceFile, _path, ...cleanItem } = item;
1059
1154
  return cleanItem;
1060
1155
  }
1156
+ function assertSpecItemPatch(updates, operation) {
1157
+ const patch = updates;
1158
+ if ("_sourceFile" in patch || "_path" in patch) {
1159
+ throw new Error(`${operation} expects a patch object, not a full LoadedSpecItem. Pass only intended fields to update.`);
1160
+ }
1161
+ }
1061
1162
  /**
1062
1163
  * Parse a path string into segments.
1063
1164
  * e.g., "features[0].requirements[2]" -> [["features", 0], ["requirements", 2]]
@@ -1225,6 +1326,7 @@ export async function updateSpecItem(_ctx, item, updates) {
1225
1326
  if (!item._sourceFile) {
1226
1327
  throw new Error("Item has no source file");
1227
1328
  }
1329
+ assertSpecItemPatch(updates, "updateSpecItem");
1228
1330
  // Lock the file to prevent concurrent read-modify-write races
1229
1331
  return withFileLock(item._sourceFile, async () => {
1230
1332
  // Load the raw YAML
@@ -1233,10 +1335,19 @@ export async function updateSpecItem(_ctx, item, updates) {
1233
1335
  let targetObj;
1234
1336
  if (item._path) {
1235
1337
  const nav = navigateToPath(raw, item._path);
1236
- if (!nav) {
1237
- throw new Error(`Could not navigate to path: ${item._path}`);
1338
+ const candidate = nav?.array[nav.index];
1339
+ if (candidate &&
1340
+ typeof candidate === "object" &&
1341
+ candidate._ulid === item._ulid) {
1342
+ targetObj = candidate;
1343
+ }
1344
+ else {
1345
+ const found = findItemInStructure(raw, item._ulid);
1346
+ if (!found) {
1347
+ throw new Error(`Could not find item ${item._ulid} in structure (path: ${item._path})`);
1348
+ }
1349
+ targetObj = found.item;
1238
1350
  }
1239
- targetObj = nav.array[nav.index];
1240
1351
  }
1241
1352
  else {
1242
1353
  // Item might be the root, or we need to find it
@@ -1334,10 +1445,14 @@ export async function deleteSpecItem(_ctx, item) {
1334
1445
  * Save a spec item - either updates existing or adds to parent.
1335
1446
  * For new items, use addChildItem instead.
1336
1447
  */
1337
- export async function saveSpecItem(ctx, item) {
1448
+ export async function saveSpecItem(ctx, item, updates) {
1449
+ assertSpecItemPatch(updates, "saveSpecItem");
1450
+ if (Object.keys(updates).length === 0) {
1451
+ throw new Error("Cannot save spec item without updates. Pass a patch.");
1452
+ }
1338
1453
  // If item has a source file and path, it's an update
1339
1454
  if (item._sourceFile && item._path) {
1340
- await updateSpecItem(ctx, item, item);
1455
+ await updateSpecItem(ctx, item, updates);
1341
1456
  return;
1342
1457
  }
1343
1458
  // Otherwise, this is more complex - would need a parent
@@ -1353,34 +1468,60 @@ export function getInboxFilePath(ctx) {
1353
1468
  return path.join(ctx.specDir, "project.inbox.yaml");
1354
1469
  }
1355
1470
  /**
1356
- * Load all inbox items from the project.
1471
+ * Parse inbox items from raw YAML payload.
1472
+ *
1473
+ * Supports canonical { inbox: [...] } shape and legacy plain-array shape.
1357
1474
  */
1358
- export async function loadInboxItems(ctx) {
1359
- const inboxPath = getInboxFilePath(ctx);
1360
- try {
1361
- const raw = await readYamlFile(inboxPath);
1362
- // Handle { inbox: [...] } format
1363
- if (raw && typeof raw === "object" && "inbox" in raw) {
1364
- const parsed = InboxFileSchema.safeParse(raw);
1365
- if (parsed.success) {
1366
- return parsed.data.inbox.map((item) => ({
1367
- ...item,
1368
- _sourceFile: inboxPath,
1369
- }));
1370
- }
1371
- }
1372
- // Handle plain array format
1373
- if (Array.isArray(raw)) {
1475
+ function parseInboxItemsFromRaw(raw) {
1476
+ // Handle { inbox: [...] } format
1477
+ if (raw && typeof raw === "object" && "inbox" in raw) {
1478
+ const parsed = InboxFileSchema.safeParse(raw);
1479
+ if (parsed.success) {
1480
+ return parsed.data.inbox;
1481
+ }
1482
+ const fallbackItems = raw.inbox;
1483
+ if (Array.isArray(fallbackItems)) {
1374
1484
  const items = [];
1375
- for (const item of raw) {
1485
+ for (const item of fallbackItems) {
1376
1486
  const result = InboxItemSchema.safeParse(item);
1377
1487
  if (result.success) {
1378
- items.push({ ...result.data, _sourceFile: inboxPath });
1488
+ items.push(result.data);
1379
1489
  }
1380
1490
  }
1381
1491
  return items;
1382
1492
  }
1383
- return [];
1493
+ }
1494
+ // Handle plain array format
1495
+ if (Array.isArray(raw)) {
1496
+ const items = [];
1497
+ for (const item of raw) {
1498
+ const result = InboxItemSchema.safeParse(item);
1499
+ if (result.success) {
1500
+ items.push(result.data);
1501
+ }
1502
+ }
1503
+ return items;
1504
+ }
1505
+ return [];
1506
+ }
1507
+ /**
1508
+ * Load inbox items from an explicit file path.
1509
+ */
1510
+ async function loadInboxItemsFromFile(inboxPath) {
1511
+ const raw = await readYamlFile(inboxPath);
1512
+ return parseInboxItemsFromRaw(raw);
1513
+ }
1514
+ /**
1515
+ * Load all inbox items from the project.
1516
+ */
1517
+ export async function loadInboxItems(ctx) {
1518
+ const inboxPath = getInboxFilePath(ctx);
1519
+ try {
1520
+ const items = await loadInboxItemsFromFile(inboxPath);
1521
+ return items.map((item) => ({
1522
+ ...item,
1523
+ _sourceFile: inboxPath,
1524
+ }));
1384
1525
  }
1385
1526
  catch {
1386
1527
  // File doesn't exist or parse error
@@ -1424,21 +1565,7 @@ export async function saveInboxItem(ctx, item) {
1424
1565
  // Load existing items
1425
1566
  let existingItems = [];
1426
1567
  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
- }
1433
- }
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
- }
1440
- }
1441
- }
1568
+ existingItems = await loadInboxItemsFromFile(inboxPath);
1442
1569
  }
1443
1570
  catch {
1444
1571
  // File doesn't exist, start fresh
@@ -1456,6 +1583,48 @@ export async function saveInboxItem(ctx, item) {
1456
1583
  await writeYamlFilePreserveFormat(inboxPath, { inbox: existingItems });
1457
1584
  });
1458
1585
  }
1586
+ /**
1587
+ * Atomically mutate an inbox item using the latest on-disk state.
1588
+ *
1589
+ * The callback receives the current item value while holding the inbox file lock,
1590
+ * so concurrent writers do not clobber unrelated fields (for example text vs tags).
1591
+ */
1592
+ export async function mutateInboxItemAtomically(ctx, item, mutate) {
1593
+ const inboxPath = item._sourceFile || getInboxFilePath(ctx);
1594
+ let updatedItem;
1595
+ await withFileLock(inboxPath, async () => {
1596
+ // Ensure directory exists (important for default path in new repos)
1597
+ const dir = path.dirname(inboxPath);
1598
+ await fs.mkdir(dir, { recursive: true });
1599
+ let existingItems = [];
1600
+ try {
1601
+ existingItems = await loadInboxItemsFromFile(inboxPath);
1602
+ }
1603
+ catch {
1604
+ throw new Error(`Inbox file not found: ${inboxPath}`);
1605
+ }
1606
+ const itemIndex = existingItems.findIndex((existingItem) => existingItem._ulid === item._ulid);
1607
+ if (itemIndex === -1) {
1608
+ throw new Error(`Inbox item not found in file: ${item._ulid}`);
1609
+ }
1610
+ const latestItem = {
1611
+ ...existingItems[itemIndex],
1612
+ _sourceFile: inboxPath,
1613
+ };
1614
+ const mutatedItem = await mutate(latestItem);
1615
+ const cleanMutatedItem = stripInboxMetadata(mutatedItem);
1616
+ existingItems[itemIndex] = cleanMutatedItem;
1617
+ await writeYamlFilePreserveFormat(inboxPath, { inbox: existingItems });
1618
+ updatedItem = {
1619
+ ...cleanMutatedItem,
1620
+ _sourceFile: inboxPath,
1621
+ };
1622
+ });
1623
+ if (!updatedItem) {
1624
+ throw new Error(`Failed to mutate inbox item atomically: ${item._ulid}`);
1625
+ }
1626
+ return updatedItem;
1627
+ }
1459
1628
  /**
1460
1629
  * Delete an inbox item by ULID.
1461
1630
  */
@@ -1464,14 +1633,7 @@ export async function deleteInboxItem(ctx, ulid) {
1464
1633
  // Lock the file to prevent concurrent read-modify-write races
1465
1634
  return withFileLock(inboxPath, async () => {
1466
1635
  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
- }
1636
+ const existingItems = await loadInboxItemsFromFile(inboxPath);
1475
1637
  const index = existingItems.findIndex((i) => i._ulid === ulid);
1476
1638
  if (index < 0) {
1477
1639
  return false;