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.
- package/CHANGELOG.md +52 -0
- package/README.md +2 -2
- package/dist/cli/agents-view/render.js +69 -15
- package/dist/cli/agents-view/state.js +40 -14
- package/dist/cli/commands/agents.js +135 -45
- package/dist/cli/commands/init.js +128 -53
- package/dist/cli/commands/learning.js +61 -13
- package/dist/cli/commands/memory.js +35 -14
- package/dist/cli/commands/uninstall.js +163 -39
- package/dist/commands/code-review.md +1 -3
- package/dist/commands/debug.md +15 -12
- package/dist/commands/dynamic-build.md +172 -135
- package/dist/commands/dynamic-plan.md +9 -3
- package/dist/commands/explore.md +10 -4
- package/dist/commands/implement.md +149 -145
- package/dist/commands/plan.md +13 -9
- package/dist/commands/release.md +8 -2
- package/dist/commands/research.md +8 -2
- package/dist/commands/resolve.md +28 -19
- package/dist/commands/self-review.md +16 -13
- package/dist/core/agent-frontmatter.js +25 -0
- package/dist/core/agent-models.js +201 -36
- package/dist/core/agent-state.js +27 -5
- package/dist/core/assets.js +1 -1
- package/dist/core/feature-config.js +68 -10
- package/dist/core/flags.js +24 -0
- package/dist/core/learning-queue-cleanup.js +10 -11
- package/dist/core/learning-tuning-config.js +8 -0
- package/dist/core/linked-path.js +46 -0
- package/dist/core/plugins.js +16 -5
- package/dist/core/queue-drain.js +31 -0
- package/dist/hud/components/learning-counts.js +54 -8
- package/dist/skills/git/references/tracker/github/create-release.md +2 -2
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +1 -1
- package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +1 -1
- package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +1 -1
- package/dist/targets/claude-code/installer.js +36 -9
- package/dist/targets/claude-code/post-install.js +128 -38
- package/package.json +1 -1
- package/src/assets/agents/code.md +85 -35
- package/src/assets/agents/design.md +12 -0
- package/src/assets/agents/diagnose.md +18 -11
- package/src/assets/agents/evaluate.md +17 -24
- package/src/assets/agents/knowledge.md +7 -3
- package/src/assets/agents/learning.md +4 -6
- package/src/assets/agents/research.md +21 -0
- package/src/assets/agents/review.md +12 -0
- package/src/assets/agents/scrutinize.md +37 -9
- package/src/assets/agents/simplify.md +24 -0
- package/src/assets/agents/skim.md +6 -2
- package/src/assets/agents/synthesize.md +18 -0
- package/src/assets/agents/test.md +19 -11
- package/src/assets/agents/triage.md +8 -0
- package/src/assets/agents/validate.md +20 -11
- package/src/assets/commands/_partials/_engine.mds +36 -55
- package/src/assets/commands/_partials/_knowledge.mds +1 -3
- package/src/assets/commands/_partials/_plan_contract.mds +1 -1
- package/src/assets/commands/_partials/_tracker.mds +1 -1
- package/src/assets/commands/_partials/_wave.mds +8 -6
- package/src/assets/commands/code-review.mds +1 -3
- package/src/assets/commands/debug.mds +13 -8
- package/src/assets/commands/dynamic-build.mds +126 -72
- package/src/assets/commands/dynamic-plan.mds +7 -1
- package/src/assets/commands/explore.mds +9 -1
- package/src/assets/commands/implement.mds +147 -141
- package/src/assets/commands/plan.mds +12 -8
- package/src/assets/commands/release.md +8 -2
- package/src/assets/commands/research.mds +8 -2
- package/src/assets/commands/resolve.mds +27 -16
- package/src/assets/commands/self-review.mds +15 -10
- package/src/assets/mds/tracker/_common.mds +1 -1
- package/src/assets/mds/tracker/_github.mds +2 -2
- package/src/assets/mds/tracker/_jira.mds +2 -2
- package/src/assets/mds/tracker/_linear.mds +2 -2
- package/src/assets/scripts/ci-wait.cjs +636 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
- package/src/assets/scripts/hooks/background-memory-update +356 -17
- package/src/assets/scripts/hooks/capture-prompt +4 -3
- package/src/assets/scripts/hooks/capture-question +4 -3
- package/src/assets/scripts/hooks/capture-turn +4 -3
- package/src/assets/scripts/hooks/ensure-devflow-init +13 -1
- package/src/assets/scripts/hooks/ensure-root-gitignore +122 -10
- package/src/assets/scripts/hooks/git-marker +71 -0
- package/src/assets/scripts/hooks/json-helper.cjs +12 -145
- package/src/assets/scripts/hooks/json-parse +24 -129
- package/src/assets/scripts/hooks/lib/learning-store.cjs +169 -64
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +1 -1
- package/src/assets/scripts/hooks/memory-worker +10 -0
- package/src/assets/scripts/hooks/pre-compact-memory +66 -14
- package/src/assets/scripts/hooks/preamble +9 -1
- package/src/assets/scripts/hooks/queue-append +53 -21
- package/src/assets/scripts/hooks/session-start-context +108 -29
- package/src/assets/scripts/hooks/session-start-memory +33 -11
- package/src/assets/scripts/release-trace.cjs +27 -10
- package/src/assets/skills/accessibility/SKILL.md +1 -1
- package/src/assets/skills/apply-decisions/SKILL.md +12 -82
- package/src/assets/skills/apply-feature-knowledge/SKILL.md +8 -42
- package/src/assets/skills/architecture/SKILL.md +1 -1
- package/src/assets/skills/boundary-validation/SKILL.md +1 -1
- package/src/assets/skills/complexity/SKILL.md +1 -1
- package/src/assets/skills/compliance/SKILL.md +1 -1
- package/src/assets/skills/consistency/SKILL.md +1 -1
- package/src/assets/skills/database/SKILL.md +1 -1
- package/src/assets/skills/dependencies/SKILL.md +1 -1
- package/src/assets/skills/dependency-research/SKILL.md +3 -6
- package/src/assets/skills/design-review/SKILL.md +1 -1
- package/src/assets/skills/docs-framework/SKILL.md +1 -1
- package/src/assets/skills/documentation/SKILL.md +1 -1
- package/src/assets/skills/gap-analysis/SKILL.md +1 -1
- package/src/assets/skills/git/SKILL.md +1 -1
- package/src/assets/skills/go/SKILL.md +1 -1
- package/src/assets/skills/java/SKILL.md +1 -1
- package/src/assets/skills/patterns/SKILL.md +1 -1
- package/src/assets/skills/performance/SKILL.md +1 -1
- package/src/assets/skills/python/SKILL.md +1 -1
- package/src/assets/skills/qa/SKILL.md +1 -3
- package/src/assets/skills/quality-gates/SKILL.md +9 -12
- package/src/assets/skills/quality-gates/references/report-template.md +20 -20
- package/src/assets/skills/react/SKILL.md +1 -1
- package/src/assets/skills/regression/SKILL.md +1 -1
- package/src/assets/skills/reliability/SKILL.md +1 -1
- package/src/assets/skills/research-codebase/SKILL.md +1 -1
- package/src/assets/skills/research-competitor/SKILL.md +1 -1
- package/src/assets/skills/research-external/SKILL.md +1 -1
- package/src/assets/skills/research-technology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/rust/SKILL.md +1 -1
- package/src/assets/skills/security/SKILL.md +1 -1
- package/src/assets/skills/software-design/SKILL.md +1 -1
- package/src/assets/skills/test-driven-development/SKILL.md +15 -33
- package/src/assets/skills/testing/SKILL.md +1 -1
- package/src/assets/skills/typescript/SKILL.md +1 -1
- package/src/assets/skills/ui-design/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/SKILL.md +3 -55
- package/src/assets/skills/worktree-support/references/discovery.md +48 -0
- 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
|
|
354
|
-
*
|
|
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
|
-
|
|
358
|
-
|
|
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
|
|
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),
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
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.
|
|
1261
|
-
* not-a-directory
|
|
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
|
-
/**
|
|
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
|
-
|
|
1357
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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
|