devflow-kit 3.1.0 → 3.3.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 (138) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +2 -2
  3. package/dist/cli/agents-view/render.js +69 -15
  4. package/dist/cli/agents-view/state.js +40 -14
  5. package/dist/cli/commands/agents.js +135 -45
  6. package/dist/cli/commands/init.js +128 -53
  7. package/dist/cli/commands/learning.js +61 -13
  8. package/dist/cli/commands/memory.js +35 -14
  9. package/dist/cli/commands/uninstall.js +163 -39
  10. package/dist/commands/code-review.md +1 -3
  11. package/dist/commands/debug.md +15 -12
  12. package/dist/commands/dynamic-build.md +172 -135
  13. package/dist/commands/dynamic-plan.md +9 -3
  14. package/dist/commands/explore.md +10 -4
  15. package/dist/commands/implement.md +149 -145
  16. package/dist/commands/plan.md +13 -9
  17. package/dist/commands/release.md +8 -2
  18. package/dist/commands/research.md +8 -2
  19. package/dist/commands/resolve.md +28 -19
  20. package/dist/commands/self-review.md +16 -13
  21. package/dist/core/agent-frontmatter.js +25 -0
  22. package/dist/core/agent-models.js +201 -36
  23. package/dist/core/agent-state.js +27 -5
  24. package/dist/core/assets.js +1 -1
  25. package/dist/core/feature-config.js +68 -10
  26. package/dist/core/flags.js +24 -0
  27. package/dist/core/learning-queue-cleanup.js +10 -11
  28. package/dist/core/learning-tuning-config.js +8 -0
  29. package/dist/core/linked-path.js +46 -0
  30. package/dist/core/plugins.js +16 -5
  31. package/dist/core/queue-drain.js +31 -0
  32. package/dist/hud/components/learning-counts.js +54 -8
  33. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  34. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +1 -1
  35. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  36. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +1 -1
  37. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  38. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +1 -1
  39. package/dist/targets/claude-code/installer.js +36 -9
  40. package/dist/targets/claude-code/post-install.js +128 -38
  41. package/package.json +1 -1
  42. package/src/assets/agents/code.md +85 -35
  43. package/src/assets/agents/design.md +12 -0
  44. package/src/assets/agents/diagnose.md +18 -11
  45. package/src/assets/agents/evaluate.md +17 -24
  46. package/src/assets/agents/knowledge.md +7 -3
  47. package/src/assets/agents/learning.md +4 -6
  48. package/src/assets/agents/research.md +21 -0
  49. package/src/assets/agents/review.md +12 -0
  50. package/src/assets/agents/scrutinize.md +37 -9
  51. package/src/assets/agents/simplify.md +24 -0
  52. package/src/assets/agents/skim.md +6 -2
  53. package/src/assets/agents/synthesize.md +18 -0
  54. package/src/assets/agents/test.md +19 -11
  55. package/src/assets/agents/triage.md +8 -0
  56. package/src/assets/agents/validate.md +20 -11
  57. package/src/assets/commands/_partials/_engine.mds +36 -55
  58. package/src/assets/commands/_partials/_knowledge.mds +1 -3
  59. package/src/assets/commands/_partials/_plan_contract.mds +1 -1
  60. package/src/assets/commands/_partials/_tracker.mds +1 -1
  61. package/src/assets/commands/_partials/_wave.mds +8 -6
  62. package/src/assets/commands/code-review.mds +1 -3
  63. package/src/assets/commands/debug.mds +13 -8
  64. package/src/assets/commands/dynamic-build.mds +126 -72
  65. package/src/assets/commands/dynamic-plan.mds +7 -1
  66. package/src/assets/commands/explore.mds +9 -1
  67. package/src/assets/commands/implement.mds +147 -141
  68. package/src/assets/commands/plan.mds +12 -8
  69. package/src/assets/commands/release.md +8 -2
  70. package/src/assets/commands/research.mds +8 -2
  71. package/src/assets/commands/resolve.mds +27 -16
  72. package/src/assets/commands/self-review.mds +15 -10
  73. package/src/assets/mds/tracker/_common.mds +1 -1
  74. package/src/assets/mds/tracker/_github.mds +2 -2
  75. package/src/assets/mds/tracker/_jira.mds +2 -2
  76. package/src/assets/mds/tracker/_linear.mds +2 -2
  77. package/src/assets/scripts/ci-wait.cjs +636 -0
  78. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
  79. package/src/assets/scripts/hooks/background-memory-update +356 -17
  80. package/src/assets/scripts/hooks/capture-prompt +4 -3
  81. package/src/assets/scripts/hooks/capture-question +4 -3
  82. package/src/assets/scripts/hooks/capture-turn +4 -3
  83. package/src/assets/scripts/hooks/ensure-devflow-init +13 -1
  84. package/src/assets/scripts/hooks/ensure-root-gitignore +122 -10
  85. package/src/assets/scripts/hooks/git-marker +71 -0
  86. package/src/assets/scripts/hooks/json-helper.cjs +12 -145
  87. package/src/assets/scripts/hooks/json-parse +24 -129
  88. package/src/assets/scripts/hooks/lib/learning-store.cjs +169 -64
  89. package/src/assets/scripts/hooks/lib/render-decisions.cjs +1 -1
  90. package/src/assets/scripts/hooks/memory-worker +10 -0
  91. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  92. package/src/assets/scripts/hooks/preamble +9 -1
  93. package/src/assets/scripts/hooks/queue-append +53 -21
  94. package/src/assets/scripts/hooks/session-start-context +108 -29
  95. package/src/assets/scripts/hooks/session-start-memory +33 -11
  96. package/src/assets/scripts/release-trace.cjs +27 -10
  97. package/src/assets/skills/accessibility/SKILL.md +1 -1
  98. package/src/assets/skills/apply-decisions/SKILL.md +12 -82
  99. package/src/assets/skills/apply-feature-knowledge/SKILL.md +8 -42
  100. package/src/assets/skills/architecture/SKILL.md +1 -1
  101. package/src/assets/skills/boundary-validation/SKILL.md +1 -1
  102. package/src/assets/skills/complexity/SKILL.md +1 -1
  103. package/src/assets/skills/compliance/SKILL.md +1 -1
  104. package/src/assets/skills/consistency/SKILL.md +1 -1
  105. package/src/assets/skills/database/SKILL.md +1 -1
  106. package/src/assets/skills/dependencies/SKILL.md +1 -1
  107. package/src/assets/skills/dependency-research/SKILL.md +3 -6
  108. package/src/assets/skills/design-review/SKILL.md +1 -1
  109. package/src/assets/skills/docs-framework/SKILL.md +1 -1
  110. package/src/assets/skills/documentation/SKILL.md +1 -1
  111. package/src/assets/skills/gap-analysis/SKILL.md +1 -1
  112. package/src/assets/skills/git/SKILL.md +1 -1
  113. package/src/assets/skills/go/SKILL.md +1 -1
  114. package/src/assets/skills/java/SKILL.md +1 -1
  115. package/src/assets/skills/patterns/SKILL.md +1 -1
  116. package/src/assets/skills/performance/SKILL.md +1 -1
  117. package/src/assets/skills/python/SKILL.md +1 -1
  118. package/src/assets/skills/qa/SKILL.md +1 -3
  119. package/src/assets/skills/quality-gates/SKILL.md +9 -12
  120. package/src/assets/skills/quality-gates/references/report-template.md +20 -20
  121. package/src/assets/skills/react/SKILL.md +1 -1
  122. package/src/assets/skills/regression/SKILL.md +1 -1
  123. package/src/assets/skills/reliability/SKILL.md +1 -1
  124. package/src/assets/skills/research-codebase/SKILL.md +1 -1
  125. package/src/assets/skills/research-competitor/SKILL.md +1 -1
  126. package/src/assets/skills/research-external/SKILL.md +1 -1
  127. package/src/assets/skills/research-technology/SKILL.md +1 -1
  128. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  129. package/src/assets/skills/rust/SKILL.md +1 -1
  130. package/src/assets/skills/security/SKILL.md +1 -1
  131. package/src/assets/skills/software-design/SKILL.md +1 -1
  132. package/src/assets/skills/test-driven-development/SKILL.md +15 -33
  133. package/src/assets/skills/testing/SKILL.md +1 -1
  134. package/src/assets/skills/typescript/SKILL.md +1 -1
  135. package/src/assets/skills/ui-design/SKILL.md +1 -1
  136. package/src/assets/skills/worktree-support/SKILL.md +3 -55
  137. package/src/assets/skills/worktree-support/references/discovery.md +48 -0
  138. package/src/assets/skills/worktree-support/references/roots.md +2 -2
@@ -336,6 +336,59 @@ function parseRow(text) {
336
336
  }
337
337
  }
338
338
 
339
+ /**
340
+ * The first `size` bytes of the open file `fd` as UTF-8 text, fewer when the file
341
+ * ends sooner: the read never takes more than `size` bytes.
342
+ *
343
+ * @param {number} fd
344
+ * @param {number} size - the byte count fstat reported for `fd`
345
+ * @returns {string}
346
+ */
347
+ function readOpenedText(fd, size) {
348
+ const buf = Buffer.alloc(size);
349
+ let total = 0;
350
+ while (total < buf.length) {
351
+ const read = fs.readSync(fd, buf, total, buf.length - total, total);
352
+ if (read === 0) break;
353
+ total += read;
354
+ }
355
+ return buf.toString('utf8', 0, total);
356
+ }
357
+
358
+ /**
359
+ * The text of `file`, or null when nothing is there, when the file is anything
360
+ * but a regular file — a symbolic link, a directory, a FIFO or a device — or when
361
+ * it is larger than `maxBytes` (D-NO-LINKED-READ, at readJsonl). lstat decides
362
+ * before anything is opened, seeing a link without following it. The open never
363
+ * follows a link (O_NOFOLLOW) and never waits on a FIFO (O_NONBLOCK), and fstat
364
+ * confirms that what it opened is a regular file within the bound: a link that
365
+ * took the file's place after the lstat fails the read rather than being read
366
+ * through, and anything else that did reads as absent. The read never takes more
367
+ * bytes than fstat reported.
368
+ *
369
+ * @param {string} file - an absolute path
370
+ * @param {{ maxBytes?: number }} [opts] - maxBytes: the largest file read (default no cap)
371
+ * @returns {string|null}
372
+ * @throws on any other read error
373
+ */
374
+ function readTextUnlinked(file, { maxBytes = Infinity } = {}) {
375
+ let fd;
376
+ try {
377
+ const stat = fs.lstatSync(file);
378
+ if (!stat.isFile() || stat.size > maxBytes) return null;
379
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
380
+ } catch (err) {
381
+ if (err && err.code === 'ENOENT') return null;
382
+ throw err;
383
+ }
384
+ try {
385
+ const stat = fs.fstatSync(fd);
386
+ return stat.isFile() && stat.size <= maxBytes ? readOpenedText(fd, stat.size) : null;
387
+ } finally {
388
+ fs.closeSync(fd);
389
+ }
390
+ }
391
+
339
392
  /**
340
393
  * Read a JSONL file strictly: every non-blank line is either one JSON object (a
341
394
  * row) or rejected, with its 1-based line number and its text.
@@ -348,19 +401,28 @@ function parseRow(text) {
348
401
  * delete them without a trace, and a reader that quarantined would make list,
349
402
  * show and the HUD write files.
350
403
  *
404
+ * D-NO-LINKED-READ: a learning file that is itself a symbolic link reads as
405
+ * missing, and nothing is read through it; the pre-v2 backup skips one the same
406
+ * way (ensurePreV2Backup), and release-claim reads the claim's owner file the same
407
+ * way, and only up to CLAIM_OWNER_MAX_BYTES (readClaimOwner). readJsonl and
408
+ * readClaimOwner read a regular file alone (readTextUnlinked): a directory, a FIFO
409
+ * or a device where the file belongs reads as missing too, and neither waits on
410
+ * one. Reason: a repository can commit any learning file as a link to a file
411
+ * elsewhere on the machine, and a read that followed it would put that file's
412
+ * lines into list and show and, through a rewrite, the quarantine or a render,
413
+ * into the project's learning folder; a read that followed one to a FIFO or to
414
+ * /dev/zero would wait forever or fill memory, holding the learning lock when a
415
+ * writer or release-claim reads. The writers never write through a link either:
416
+ * a rename replaces one, and an append refuses one.
417
+ *
351
418
  * @param {string} file
352
419
  * @returns {{ rows: object[], rejected: Array<{ line: number, text: string }>, missing: boolean }}
353
- * `missing` is true when the file does not exist. Any read error other than a
354
- * missing file is thrown.
420
+ * `missing` is true when the file does not exist, or is a symbolic link or
421
+ * anything else but a regular file. Any other read error is thrown.
355
422
  */
356
423
  function readJsonl(file) {
357
- let raw;
358
- try {
359
- raw = fs.readFileSync(safePath(file), 'utf8');
360
- } catch (err) {
361
- if (err && err.code === 'ENOENT') return { rows: [], rejected: [], missing: true };
362
- throw err;
363
- }
424
+ const raw = readTextUnlinked(safePath(file));
425
+ if (raw === null) return { rows: [], rejected: [], missing: true };
364
426
  const rows = [];
365
427
  const rejected = [];
366
428
  const lines = raw.split('\n');
@@ -512,6 +574,46 @@ function noLearningDir(opName, root) {
512
574
  };
513
575
  }
514
576
 
577
+ /** True when `file` is itself a symbolic link; false for anything else, or nothing, there. */
578
+ function isSymbolicLink(file) {
579
+ try {
580
+ return fs.lstatSync(file).isSymbolicLink();
581
+ } catch (err) {
582
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return false;
583
+ throw err;
584
+ }
585
+ }
586
+
587
+ /**
588
+ * The first of `<root>/.devflow` and `<root>/.devflow/learning` that is itself a
589
+ * symbolic link, or null when neither is (D-NO-LINKED-TREE).
590
+ *
591
+ * @param {string} root - project root
592
+ * @returns {string|null}
593
+ */
594
+ function linkedLearningFolder(root) {
595
+ const learningDir = getLearningDir(root);
596
+ for (const dir of [path.dirname(learningDir), learningDir]) {
597
+ if (isSymbolicLink(dir)) return dir;
598
+ }
599
+ return null;
600
+ }
601
+
602
+ /**
603
+ * The error Result of an op run where `.devflow` or `.devflow/learning` under its
604
+ * root is a symbolic link (D-NO-LINKED-TREE).
605
+ *
606
+ * @param {string} opName - operation name, for the message
607
+ * @param {string} dir - the folder that is a link
608
+ * @returns {{ ok: false, error: { kind: 'not-a-directory', message: string } }}
609
+ */
610
+ function linkedFolder(opName, dir) {
611
+ return {
612
+ ok: false,
613
+ error: { kind: 'not-a-directory', message: `${opName}: ${dir} is a symbolic link, not a directory; nothing was changed` },
614
+ };
615
+ }
616
+
515
617
  /** True for a Result: `{ ok: true, … }` or `{ ok: false, error: { … } }`. */
516
618
  function isResult(value) {
517
619
  return isPlainObject(value) && (value.ok === true || (value.ok === false && isPlainObject(value.error)));
@@ -534,14 +636,24 @@ function isResult(value) {
534
636
  * Reason: a writer run from the wrong directory would otherwise create a learning
535
637
  * tree there and write a ledger that no session ever reads.
536
638
  *
639
+ * D-NO-LINKED-TREE: a learning writer refuses with `not-a-directory`, changing
640
+ * nothing, when `.devflow` or `.devflow/learning` under its root is a symbolic
641
+ * link. Reason: a repository can commit either one as a link to a folder elsewhere
642
+ * on the machine, and a writer that followed it would take its lock there and
643
+ * rewrite, quarantine, archive, render or delete files in whatever folder the link
644
+ * names. Refused here, before the lock is taken, so every writer refuses in one
645
+ * place; the claim heartbeat makes the same check, and read-only paths still read.
646
+ *
537
647
  * @param {string} opName - operation name, for messages
538
648
  * @param {string} root - project root
539
649
  * @param {() => { ok: boolean }} fn - the locked body; it must return a Result
540
650
  * @param {{ timeoutMs?: number, staleMs?: number }} [opts]
541
651
  * @returns {{ ok: true, value?: unknown } | { ok: false, error: { kind: string, message: string } }}
542
- * fn's Result, or an error of kind `no-learning-dir` or `busy`.
652
+ * fn's Result, or an error of kind `not-a-directory`, `no-learning-dir` or `busy`.
543
653
  */
544
654
  function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS, staleMs = LOCK_STALE_MS } = {}) {
655
+ const linked = linkedLearningFolder(root);
656
+ if (linked !== null) return linkedFolder(opName, linked);
545
657
  if (!hasLearningDir(root)) return noLearningDir(opName, root);
546
658
  const lockDir = getDecisionsLockDir(root);
547
659
  let acquired;
@@ -572,7 +684,8 @@ function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_
572
684
 
573
685
  /**
574
686
  * Read the ledger and the log, read-only: malformed lines are reported, never
575
- * quarantined (D-QUARANTINE-MALFORMED). An absent file reads as empty.
687
+ * quarantined (D-QUARANTINE-MALFORMED). An absent file, or one that is a symbolic
688
+ * link or not a regular file (D-NO-LINKED-READ), reads as empty.
576
689
  *
577
690
  * @param {string} root - project root
578
691
  * @returns {{ ledgerRows: object[], logRows: object[], rejected: { ledger: Array<{ line: number, text: string }>, log: Array<{ line: number, text: string }> } }}
@@ -1046,17 +1159,19 @@ function historyVersions(root, id) {
1046
1159
  * log, the ledger and the archive, as they are on disk, to `*.pre-v2.jsonl` with
1047
1160
  * an exclusive create, before anything rewrites them; an existing copy is never
1048
1161
  * overwritten. Reason: v2 writes convert and rewrite v1 rows, and these copies are
1049
- * the only record of the corpus as it was before the conversion.
1162
+ * the only record of the corpus as it was before the conversion. A file that is a
1163
+ * symbolic link is not copied: the store reads none (D-NO-LINKED-READ, at readJsonl).
1050
1164
  *
1051
1165
  * @param {string} root - project root
1052
1166
  * @param {{ logRows?: object[], ledgerRows?: object[] }} rows - the rows just read
1053
1167
  * @returns {string[]} the backup paths written by this call (none when every row
1054
- * is v2, a file is absent or its copy already exists)
1168
+ * is v2, a file is absent or a symbolic link, or its copy already exists)
1055
1169
  */
1056
1170
  function ensurePreV2Backup(root, { logRows = [], ledgerRows = [] } = {}) {
1057
1171
  if ([...logRows, ...ledgerRows].every(row => isV2(row))) return [];
1058
1172
  const written = [];
1059
1173
  for (const file of [getDecisionsLogPath(root), getDecisionsLedgerPath(root), getDecisionsArchivePath(root)]) {
1174
+ if (isSymbolicLink(file)) continue;
1060
1175
  const copy = withJsonlSuffix(file, '.pre-v2.jsonl');
1061
1176
  try {
1062
1177
  fs.copyFileSync(file, copy, fs.constants.COPYFILE_EXCL);
@@ -1117,7 +1232,7 @@ function lastActivityMs(row) {
1117
1232
  * @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
1118
1233
  * @returns {{ ok: true, value: { rotated: number, appended: number } } | { ok: false, error: { kind: string, message: string } }}
1119
1234
  * rotated: rows removed from the log; appended: rows added to the archive.
1120
- * Errors are withDecisionsLock's no-learning-dir and busy.
1235
+ * Errors are withDecisionsLock's not-a-directory, no-learning-dir and busy.
1121
1236
  */
1122
1237
  function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
1123
1238
  return withDecisionsLock('rotate-observations', root, () => {
@@ -1180,7 +1295,7 @@ function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
1180
1295
  * @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
1181
1296
  * @returns {{ ok: true, value: { cleared: number, kept: number } } | { ok: false, error: { kind: string, message: string } }}
1182
1297
  * cleared: rows removed from the log; kept: rows left in it. Error kinds:
1183
- * ledger-malformed, and withDecisionsLock's no-learning-dir and busy.
1298
+ * ledger-malformed, and withDecisionsLock's not-a-directory, no-learning-dir and busy.
1184
1299
  */
1185
1300
  function clearUnreferenced(root, { now = Date.now(), timeoutMs } = {}) {
1186
1301
  return withDecisionsLock('clear', root, () => {
@@ -1224,16 +1339,6 @@ function removeEmptyDir(dir) {
1224
1339
  }
1225
1340
  }
1226
1341
 
1227
- /** True when `file` is itself a symbolic link; false for anything else, or nothing, there. */
1228
- function isSymbolicLink(file) {
1229
- try {
1230
- return fs.lstatSync(file).isSymbolicLink();
1231
- } catch (err) {
1232
- if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return false;
1233
- throw err;
1234
- }
1235
- }
1236
-
1237
1342
  /**
1238
1343
  * Remove every learning file — `devflow learning --reset`: the log, the ledger
1239
1344
  * and their side files, the rendered files, the tuning config, and the queue
@@ -1248,27 +1353,20 @@ function isSymbolicLink(file) {
1248
1353
  * lock — belongs to the next run.
1249
1354
  *
1250
1355
  * Like every learning writer it refuses without `.devflow/learning/` and creates
1251
- * nothing (D-NO-STRAY-TREE), and it waits at most `timeoutMs` for the lock,
1252
- * breaking one a crashed run left behind (D-ONE-LEARNING-LOCK). A symbolic link
1253
- * in the directory is removed, never what it points to. A learning directory that
1254
- * is itself a symbolic link is refused and nothing is removed: emptying it would
1255
- * empty whatever directory the link leads to.
1356
+ * nothing (D-NO-STRAY-TREE), refuses a learning directory or a `.devflow` that is a
1357
+ * symbolic link and removes nothing (D-NO-LINKED-TREE), since emptying it would
1358
+ * empty whatever directory the link leads to, and waits at most `timeoutMs` for the
1359
+ * lock, breaking one a crashed run left behind (D-ONE-LEARNING-LOCK). A symbolic
1360
+ * link in the directory is removed, never what it points to.
1256
1361
  *
1257
1362
  * @param {string} root - project root
1258
1363
  * @param {{ timeoutMs?: number }} [opts]
1259
1364
  * @returns {{ ok: true, value: { removed: number } } | { ok: false, error: { kind: string, message: string } }}
1260
- * removed: the entries removed from the learning directory. Error kinds:
1261
- * not-a-directory (the learning directory is a symbolic link), and
1262
- * withDecisionsLock's no-learning-dir and busy.
1365
+ * removed: the entries removed from the learning directory. Errors are
1366
+ * withDecisionsLock's not-a-directory, no-learning-dir and busy.
1263
1367
  */
1264
1368
  function resetLearning(root, { timeoutMs } = {}) {
1265
1369
  const learningDir = getLearningDir(root);
1266
- if (isSymbolicLink(learningDir)) {
1267
- return {
1268
- ok: false,
1269
- error: { kind: 'not-a-directory', message: `reset: ${learningDir} is a symbolic link, not a directory; nothing was removed` },
1270
- };
1271
- }
1272
1370
  const lockName = path.basename(getDecisionsLockDir(root));
1273
1371
  const reset = withDecisionsLock('reset', root, () => {
1274
1372
  const entries = fs.readdirSync(learningDir).filter(name => name !== lockName);
@@ -1299,6 +1397,13 @@ const QUEUE_LOCK_STALE_MS = 30000;
1299
1397
  /** A claim token: 16 lowercase hex characters. */
1300
1398
  const CLAIM_TOKEN_RE = /^[0-9a-f]{16}$/;
1301
1399
 
1400
+ /**
1401
+ * The largest owner file release-claim reads, in bytes. A token and its newline
1402
+ * take 17; a larger owner file reads as no owner file at all (D-NO-LINKED-READ,
1403
+ * at readJsonl).
1404
+ */
1405
+ const CLAIM_OWNER_MAX_BYTES = 4096;
1406
+
1302
1407
  /** link(2) errors of a filesystem without hard links; the claim renames instead. */
1303
1408
  const NO_HARD_LINK_CODES = Object.freeze(['EPERM', 'ENOTSUP']);
1304
1409
 
@@ -1351,15 +1456,14 @@ function removeIfPresent(file) {
1351
1456
  }
1352
1457
  }
1353
1458
 
1354
- /** The token the owner file records, or null when it is absent or holds no token. */
1459
+ /**
1460
+ * The token the owner file records, or null when it is absent or holds no token.
1461
+ * An owner file that is a symbolic link, anything else but a regular file, or
1462
+ * larger than CLAIM_OWNER_MAX_BYTES reads as absent (D-NO-LINKED-READ, at readJsonl).
1463
+ */
1355
1464
  function readClaimOwner(root) {
1356
- let text;
1357
- try {
1358
- text = fs.readFileSync(getLearningClaimOwnerPath(root), 'utf8');
1359
- } catch (err) {
1360
- if (err && err.code === 'ENOENT') return null;
1361
- throw err;
1362
- }
1465
+ const text = readTextUnlinked(getLearningClaimOwnerPath(root), { maxBytes: CLAIM_OWNER_MAX_BYTES });
1466
+ if (text === null) return null;
1363
1467
  const token = text.trim();
1364
1468
  return CLAIM_TOKEN_RE.test(token) ? token : null;
1365
1469
  }
@@ -1469,13 +1573,15 @@ function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs
1469
1573
  * Release the claim `token` owns (D-OWNED-CLAIM): delete the claim and the owner
1470
1574
  * file when the token owns it (released); refuse when another token does
1471
1575
  * (not-owner); report a claim that is already gone (gone), deleting the owner
1472
- * file only when it names this token.
1576
+ * file only when it names this token. An owner file that is a symbolic link,
1577
+ * anything else but a regular file, or larger than CLAIM_OWNER_MAX_BYTES names no
1578
+ * token, as though it were absent (D-NO-LINKED-READ, at readJsonl).
1473
1579
  *
1474
1580
  * @param {string} root - project root
1475
1581
  * @param {string} token
1476
1582
  * @param {{ timeoutMs?: number }} [opts]
1477
1583
  * @returns {{ ok: true, value: { state: 'released'|'not-owner'|'gone' } } | { ok: false, error: { kind: string, message: string } }}
1478
- * errors include withDecisionsLock's no-learning-dir and busy
1584
+ * errors include withDecisionsLock's not-a-directory, no-learning-dir and busy
1479
1585
  * @throws {TypeError} when `token` is not a claim token
1480
1586
  */
1481
1587
  function releaseClaim(root, token, { timeoutMs } = {}) {
@@ -1501,13 +1607,17 @@ function releaseClaim(root, token, { timeoutMs } = {}) {
1501
1607
  /**
1502
1608
  * The claim heartbeat (D-OWNED-CLAIM): set an existing claim's mtime to now. It
1503
1609
  * never creates a claim and never follows a symlink at the claim path, and it
1504
- * takes no lock — json-helper sends it before each learning op runs.
1610
+ * takes no lock — json-helper sends it before each learning op runs. A claim in a
1611
+ * learning tree reached through a symbolic link is left alone (D-NO-LINKED-TREE):
1612
+ * `lutimes` follows a linked folder above the claim, and the op that follows
1613
+ * refuses that tree anyway.
1505
1614
  *
1506
1615
  * @param {string} root - project root
1507
1616
  * @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
1508
1617
  * @returns {{ ok: true, value: { touched: boolean } } | { ok: false, error: { kind: 'heartbeat-failed', message: string } }}
1509
1618
  */
1510
1619
  function touchClaim(root, { now = Date.now() } = {}) {
1620
+ if (linkedLearningFolder(root) !== null) return { ok: true, value: { touched: false } };
1511
1621
  const claimPath = getLearningPendingTurnsProcessingPath(root);
1512
1622
  const at = new Date(now);
1513
1623
  try {
@@ -2008,7 +2118,7 @@ function restoreFirst(id, carriers) {
2008
2118
  * observations: the count the log row holds afterwards; reprojected: the
2009
2119
  * anchors re-projected, in anchor order. Error kinds: invalid-input (with
2010
2120
  * problems), duplicate-log-id, restore-first, cannot-reproject, and
2011
- * withDecisionsLock's no-learning-dir and busy.
2121
+ * withDecisionsLock's not-a-directory, no-learning-dir and busy.
2012
2122
  * @throws {TypeError} when `mode` is not a put mode
2013
2123
  */
2014
2124
  function putObservation(root, mode, input, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
@@ -2225,7 +2335,8 @@ function warmScopeMatcher(ledgerRows, scopeMatches) {
2225
2335
  * now: epoch ms (default Date.now()); scopeMatches: default gitScopeMatcher(root)
2226
2336
  * @returns {{ ok: true, value: { ref: { ref: 'origin/HEAD'|'HEAD', commit: string } | null, due: Array<{ anchor_id: string, reason: string, bytes: number }> } }
2227
2337
  * | { ok: false, error: { kind: string, message: string } }}
2228
- * due is selectDue's answer; errors are withDecisionsLock's no-learning-dir and busy
2338
+ * due is selectDue's answer; errors are withDecisionsLock's not-a-directory,
2339
+ * no-learning-dir and busy
2229
2340
  */
2230
2341
  function claimDue(root, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
2231
2342
  if (!hasLearningDir(root)) return noLearningDir('claim-due', root);
@@ -2388,14 +2499,7 @@ function readScannedText(file) {
2388
2499
  try {
2389
2500
  const stat = fs.fstatSync(fd);
2390
2501
  if (!stat.isFile() || stat.size > CITED_SCAN_MAX_FILE_BYTES) return null;
2391
- const buf = Buffer.alloc(stat.size);
2392
- let total = 0;
2393
- while (total < buf.length) {
2394
- const read = fs.readSync(fd, buf, total, buf.length - total, total);
2395
- if (read === 0) break;
2396
- total += read;
2397
- }
2398
- const text = buf.toString('utf8', 0, total);
2502
+ const text = readOpenedText(fd, stat.size);
2399
2503
  return text.includes('\u0000') ? null : text;
2400
2504
  } catch {
2401
2505
  return null;
@@ -2508,7 +2612,7 @@ function mintAnchor(ledgerRows, type, citedAnchors) {
2508
2612
  * | { ok: false, error: { kind: string, message: string } }}
2509
2613
  * Error kinds: not-in-log, duplicate-log-id, already-promoted, v1-observation,
2510
2614
  * type-mismatch, cited-numbers-exhausted, and withDecisionsLock's
2511
- * no-learning-dir and busy.
2615
+ * not-a-directory, no-learning-dir and busy.
2512
2616
  * @throws {TypeError} for a type other than decision or pitfall, or a malformed obsId
2513
2617
  */
2514
2618
  function assignAnchor(root, type, obsId, { now = Date.now(), timeoutMs, citedAnchors } = {}) {
@@ -2667,7 +2771,7 @@ function recordRefreshHistory(root, plans, ledgerRows, { now }) {
2667
2771
  * @returns {{ ok: true, value: { refreshed: Array<{ anchor_id: string, state: 'verified'|'reprojected'|'unchanged' }> } }
2668
2772
  * | { ok: false, error: { kind: string, message: string, problems?: Array<{ anchor_id: string, message: string }> } }}
2669
2773
  * refreshed: each anchor once, in the order given. Error kinds: refused (with
2670
- * problems), and withDecisionsLock's no-learning-dir and busy.
2774
+ * problems), and withDecisionsLock's not-a-directory, no-learning-dir and busy.
2671
2775
  * @throws {TypeError} when anchorIds is empty or holds anything but anchor ids
2672
2776
  */
2673
2777
  function refreshAnchors(root, anchorIds, { verified = false, now = Date.now(), timeoutMs } = {}) {
@@ -2904,7 +3008,7 @@ function quoteAtRef(root, at, quote, { verifyRef } = {}) {
2904
3008
  * kinds: invalid-input (with problems), quoteAtRef's, not-found,
2905
3009
  * duplicate-anchor, already-inactive, successor-not-found,
2906
3010
  * successor-duplicate-anchor, successor-inactive, and withDecisionsLock's
2907
- * no-learning-dir and busy.
3011
+ * not-a-directory, no-learning-dir and busy.
2908
3012
  * @throws {TypeError} for a malformed anchorId or a status that is not inactive
2909
3013
  */
2910
3014
  function retireAnchor(root, anchorId, status, input, { now = Date.now(), timeoutMs, verifyRef } = {}) {
@@ -2983,7 +3087,7 @@ function retireUnderLock(root, anchorId, status, note, { now }) {
2983
3087
  * @returns {{ ok: true, value: { anchor_id: string, status: 'Accepted'|'Active' } }
2984
3088
  * | { ok: false, error: { kind: string, message: string } }}
2985
3089
  * Error kinds: not-found, duplicate-anchor, already-active, type-mismatch, and
2986
- * withDecisionsLock's no-learning-dir and busy.
3090
+ * withDecisionsLock's not-a-directory, no-learning-dir and busy.
2987
3091
  * @throws {TypeError} for a malformed anchorId
2988
3092
  */
2989
3093
  function restoreAnchor(root, anchorId, { now = Date.now(), timeoutMs } = {}) {
@@ -3071,6 +3175,7 @@ module.exports = {
3071
3175
  // The queue claim
3072
3176
  CLAIM_STALE_SECS,
3073
3177
  CLAIM_TOKEN_RE,
3178
+ CLAIM_OWNER_MAX_BYTES,
3074
3179
  newClaimToken,
3075
3180
  claimQueue,
3076
3181
  releaseClaim,
@@ -366,7 +366,7 @@ function readIfPresent(file) {
366
366
  * `render`: read the ledger under .decisions.lock and write the three files from
367
367
  * what it read (D-ONE-LEARNING-LOCK), so a ledger write that lands while it waits
368
368
  * is rendered rather than overwritten. Refuses without the learning directory
369
- * (D-NO-STRAY-TREE).
369
+ * (D-NO-STRAY-TREE), and when it or `.devflow` is a symbolic link (D-NO-LINKED-TREE).
370
370
  *
371
371
  * @param {string} root
372
372
  * @returns {number} exit code
@@ -64,6 +64,16 @@ source "$SCRIPT_DIR/get-mtime" || { echo "memory-worker: failed to source get-mt
64
64
  # BEFORE spawning to prevent a second concurrent Stop hook from double-spawning
65
65
  # within the same 120s window.
66
66
  TRIGGER_FILE="$MEMORY_DIR/.working-memory-last-trigger"
67
+
68
+ # D-HOOKS-NO-SYMLINK (git-marker): the stamp below is a `touch`, which follows
69
+ # a symbolic link, so a linked stamp or memory folder is never stamped, and with no
70
+ # stamp to throttle it no worker is spawned either.
71
+ if ! df_no_symlink_below "$PROJECT_ROOT" "$TRIGGER_FILE"; then
72
+ log "SKIP: a symbolic link sits on the path to $TRIGGER_FILE; nothing written, worker not spawned"
73
+ dbg "EXIT: symbolic link on the throttle path"
74
+ exit 0
75
+ fi
76
+
67
77
  NOW=$(date +%s)
68
78
  LAST_TRIGGER=0
69
79
  if [ -f "$TRIGGER_FILE" ]; then
@@ -66,6 +66,16 @@ fi
66
66
  # Auto-create .devflow/ and ensure .gitignore entries (idempotent after first run)
67
67
  source "$SCRIPT_DIR/ensure-devflow-init" "$CWD" || exit 0
68
68
 
69
+ # D-HOOKS-NO-SYMLINK (git-marker): every write below lands in the memory folder,
70
+ # so a linked folder, which would take the backup and the working memory into the
71
+ # folder it names, ends the hook here. The backup's own target is checked where the
72
+ # backup is renamed onto it.
73
+ if ! df_no_symlink_below "$PROJECT_ROOT" "$MEMORY_DIR"; then
74
+ log "SKIP: a symbolic link sits on the path to $MEMORY_DIR; no backup or working memory written"
75
+ dbg "EXIT: symbolic link on the memory folder path"
76
+ exit 0
77
+ fi
78
+
69
79
  BACKUP_FILE="$MEMORY_DIR/backup.json"
70
80
 
71
81
  # Capture git state
@@ -102,25 +112,60 @@ if cd "$CWD" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1; then
102
112
  dbg "GIT_BRANCH=$GIT_BRANCH HEAD=$GIT_HEAD_SHA"
103
113
  fi
104
114
 
105
- # Snapshot current WORKING-MEMORY.md (preserves session context through compaction)
115
+ # Snapshot current WORKING-MEMORY.md (preserves session context through compaction).
116
+ # The snapshot goes into the backup the next session start injects, so a working
117
+ # memory a symbolic link leads to is treated as absent (D-HOOKS-NO-SYMLINK).
106
118
  MEMORY_SNAPSHOT=""
107
- if [ -f "$MEMORY_DIR/WORKING-MEMORY.md" ]; then
119
+ if df_file_below "$PROJECT_ROOT" "$MEMORY_DIR/WORKING-MEMORY.md"; then
108
120
  MEMORY_SNAPSHOT=$(head -c 65536 "$MEMORY_DIR/WORKING-MEMORY.md")
109
121
  dbg "MEMORY_SNAPSHOT_LENGTH=${#MEMORY_SNAPSHOT}"
110
122
  fi
111
123
 
112
124
  # Write backup JSON
113
- json_backup_construct \
114
- --arg ts "$TIMESTAMP" \
115
- --arg branch "$GIT_BRANCH" \
116
- --arg status "$GIT_STATUS" \
117
- --arg log "$GIT_LOG" \
118
- --arg diff "$GIT_DIFF_STAT" \
119
- --arg memory "$MEMORY_SNAPSHOT" \
120
- > "$BACKUP_FILE"
121
-
122
- log "Wrote backup: $BACKUP_FILE"
123
- dbg "Wrote backup: $BACKUP_FILE"
125
+ # D-BACKUP-RENAME: the backup is written whole into a copy beside it, then
126
+ # renamed over it; it is never truncated and refilled in place. Reason:
127
+ # session-start-memory reads it twice, and a read landing between a truncate
128
+ # and the write that refills it would meet a partial file; a rename hands every
129
+ # read either the previous backup or the new one. SEC-2: the copy is created
130
+ # under umask 077, and only where nothing stands at its name: builtin tests come
131
+ # first, so an entry already there, a link to a FIFO or a device included, is
132
+ # never opened (noclobber alone would open one, as no regular file stands there),
133
+ # and noclobber then makes the create itself exclusive. The rename gives the
134
+ # backup the copy's inode and mode, so the backup, which holds the working
135
+ # memory, is 0600 whatever the caller's umask (as in queue-append). A write or
136
+ # rename that fails removes whatever holds the copy's name and keeps the
137
+ # previous backup; the bootstrap below still runs.
138
+ #
139
+ # The rename's target is checked first (D-HOOKS-NO-SYMLINK, git-marker): `mv`
140
+ # moves the copy into a folder a link at backup.json names, so a link there, to
141
+ # anything, skips the backup with nothing created, logged once, and the
142
+ # bootstrap below still runs.
143
+ #
144
+ # A run killed after creating its copy and before renaming it (by the hook's
145
+ # timeout, say) leaves the copy under its own PID, a name no later run writes,
146
+ # so each run first removes the copies nothing has written to for an hour. A
147
+ # write ends within the hook's 10 s timeout, so no live run's copy is that old
148
+ # and a concurrent run's copy is left alone.
149
+ find "$MEMORY_DIR" -maxdepth 1 -type f -name "${BACKUP_FILE##*/}.tmp.*" -mmin +60 -delete 2>/dev/null || true
150
+ BACKUP_TMP="$BACKUP_FILE.tmp.$$"
151
+ if ! df_no_symlink_below "$PROJECT_ROOT" "$BACKUP_FILE"; then
152
+ log "Backup not written: a symbolic link sits on the path to $BACKUP_FILE"
153
+ dbg "Backup not written: symbolic link at $BACKUP_FILE"
154
+ elif [ ! -e "$BACKUP_TMP" ] && [ ! -L "$BACKUP_TMP" ] && (umask 077 && set -o noclobber && json_backup_construct \
155
+ --arg ts "$TIMESTAMP" \
156
+ --arg branch "$GIT_BRANCH" \
157
+ --arg status "$GIT_STATUS" \
158
+ --arg log "$GIT_LOG" \
159
+ --arg diff "$GIT_DIFF_STAT" \
160
+ --arg memory "$MEMORY_SNAPSHOT" \
161
+ > "$BACKUP_TMP") && mv "$BACKUP_TMP" "$BACKUP_FILE"; then
162
+ log "Wrote backup: $BACKUP_FILE"
163
+ dbg "Wrote backup: $BACKUP_FILE"
164
+ else
165
+ rm -f "$BACKUP_TMP" 2>/dev/null || true
166
+ log "Backup not written; the previous one is kept: $BACKUP_FILE"
167
+ dbg "Backup not written: $BACKUP_FILE"
168
+ fi
124
169
 
125
170
  # Bootstrap minimal WORKING-MEMORY.md if absent; skip on an unborn branch or a
126
171
  # malformed SHA. is_hex_sha 40 40: exactly 40 lowercase hex chars required. A detached
@@ -131,9 +176,16 @@ dbg "Wrote backup: $BACKUP_FILE"
131
176
  # avoids REL-5: O_EXCL-style atomic create via noclobber so the existence test and the
132
177
  # create are one operation — if the worker's CAS mv lands in the window, noclobber fails
133
178
  # (file already exists) and we skip the bootstrap rather than truncating fresh memory.
179
+ # D-HOOKS-NO-SYMLINK (git-marker): the create is made only where nothing stands, tested
180
+ # with builtins first, since noclobber alone opens a link to a FIFO or a device (no
181
+ # regular file stands there) and would wait on the FIFO or write the text into the
182
+ # device. A link there is left as it was and logged; the memory folder above it was
183
+ # checked at the top.
134
184
  MEMORY_FILE="$MEMORY_DIR/WORKING-MEMORY.md"
135
185
  if [ -n "$GIT_BRANCH" ] && is_hex_sha "$GIT_HEAD_SHA" 40 40; then
136
- if (set -o noclobber; : > "$MEMORY_FILE") 2>/dev/null; then
186
+ if [ -L "$MEMORY_FILE" ]; then
187
+ log "Working memory not bootstrapped: $MEMORY_FILE is a symbolic link"
188
+ elif [ ! -e "$MEMORY_FILE" ] && (set -o noclobber; : > "$MEMORY_FILE") 2>/dev/null; then
137
189
  {
138
190
  echo "<!-- memory-head: $GIT_HEAD_SHA branch: $GIT_BRANCH -->"
139
191
  echo ""
@@ -62,7 +62,15 @@ if [ -z "$HEAD" ]; then
62
62
  dbg "EXIT: empty prompt"
63
63
  elif [[ "$HEAD" == "Implement the following plan:"* ]]; then
64
64
  dbg "PLAN_HANDOFF detected — injecting devflow:implement directive"
65
- json_prompt_output "The user's prompt is a plan handoff (it begins with \`Implement the following plan:\`). In one short sentence, tell the user you're invoking \`devflow:implement\`. Then immediately invoke it with the Skill tool, passing the full plan (everything after the handoff prefix) as the skill input so it can be executed. Do not pause to ask whether to proceed."
65
+ # D-ARGS-ONCE: the directive passes the Skill call NO arguments. The handoff
66
+ # prompt IS the plan, so it is already in the conversation; handing it to the
67
+ # skill as an argument would put a second copy in the main-thread context for
68
+ # nothing. The skill's empty-input path takes the plan from the conversation.
69
+ # No plan body or plan path is parsed here: detection stays the literal prefix.
70
+ # This string is double-quoted bash: it must hold no dollar sign and no
71
+ # unescaped backtick, and it must equal HANDOFF_TEMPLATE in
72
+ # tests/fixtures/ambient-templates.ts byte for byte.
73
+ json_prompt_output "The user's prompt is a plan handoff (it begins with \`Implement the following plan:\`). In one short sentence, tell the user you're invoking \`devflow:implement\`. Then immediately invoke it with the Skill tool and no arguments: the plan is already in this conversation, so the skill needs no input. Do not pause to ask whether to proceed."
66
74
  elif [[ "$HEAD" == "/"* ]]; then
67
75
  dbg "EXIT: slash command — no reminder"
68
76
  else