devflow-kit 3.7.0 → 3.9.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 +479 -1
- package/README.md +15 -15
- package/dist/agents/code.md +6 -8
- package/dist/agents/design.md +1 -1
- package/dist/agents/fix.md +7 -8
- package/dist/agents/git.md +3 -3
- package/dist/agents/harden.md +1 -1
- package/dist/agents/refactor.md +2 -4
- package/dist/agents/review.md +4 -4
- package/dist/agents/skim.md +2 -2
- package/dist/agents/triage.md +9 -2
- package/dist/cli/commands/capture.js +1 -1
- package/dist/cli/commands/curation-move-io.js +102 -0
- package/dist/cli/commands/curation-move-step.js +374 -0
- package/dist/cli/commands/curation.js +808 -0
- package/dist/cli/commands/gate-level-prompts.js +428 -0
- package/dist/cli/commands/gates.js +35 -11
- package/dist/cli/commands/init-seed.js +11 -8
- package/dist/cli/commands/init.js +244 -57
- package/dist/cli/commands/memory.js +60 -13
- package/dist/cli/commands/prompt-io.js +37 -0
- package/dist/cli/commands/uninstall.js +157 -17
- package/dist/cli.js +2 -2
- package/dist/commands/bug-analysis.md +25 -36
- package/dist/commands/code-review.md +40 -44
- package/dist/commands/debug.md +5 -5
- package/dist/commands/dynamic-build.md +26 -25
- package/dist/commands/dynamic-plan.md +5 -5
- package/dist/commands/dynamic-profile.md +5 -5
- package/dist/commands/dynamic-tickets.md +5 -5
- package/dist/commands/explore.md +6 -6
- package/dist/commands/implement.md +53 -82
- package/dist/commands/plan.md +6 -6
- package/dist/commands/release.md +5 -5
- package/dist/commands/research.md +6 -6
- package/dist/commands/resolve.md +39 -41
- package/dist/commands/self-review.md +5 -13
- package/dist/core/area-pause-notice.js +44 -0
- package/dist/core/assets.js +20 -20
- package/dist/core/curate-tuning-config.js +5 -6
- package/dist/core/curation-move-notice.js +84 -0
- package/dist/core/curation-move-skip.js +35 -0
- package/dist/core/curation-move.js +35 -0
- package/dist/core/curation-queue-cleanup.js +135 -0
- package/dist/core/curation-store.js +99 -0
- package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
- package/dist/core/echo-value.js +11 -5
- package/dist/core/evidence-policy.js +11 -9
- package/dist/core/feature-switch.js +95 -19
- package/dist/core/fs-atomic.js +1 -1
- package/dist/core/gate-level.js +10 -2
- package/dist/core/git.js +16 -14
- package/dist/core/ledger-root.js +82 -22
- package/dist/core/linked-path.js +4 -4
- package/dist/core/manifest-lock.js +238 -0
- package/dist/core/manifest.js +58 -14
- package/dist/core/migrations.js +1 -13
- package/dist/core/observations.js +2 -2
- package/dist/core/plugins.js +19 -19
- package/dist/core/pool.js +26 -0
- package/dist/core/project-paths.js +103 -34
- package/dist/core/queue-drain.js +2 -2
- package/dist/core/tracked-areas.js +159 -0
- package/dist/core/tracker.js +1 -1
- package/dist/{learning-off → curation-off}/agents/code.md +6 -8
- package/dist/{learning-off → curation-off}/agents/fix.md +7 -8
- package/dist/{learning-off → curation-off}/agents/refactor.md +2 -4
- package/dist/{learning-off → curation-off}/agents/review.md +3 -3
- package/dist/{learning-off → curation-off}/agents/triage.md +8 -1
- package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
- package/dist/{learning-off → curation-off}/commands/code-review.md +37 -41
- package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
- package/dist/{learning-off → curation-off}/commands/dynamic-build.md +23 -22
- package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
- package/dist/{learning-off → curation-off}/commands/implement.md +50 -79
- package/dist/{learning-off → curation-off}/commands/plan.md +2 -2
- package/dist/{learning-off → curation-off}/commands/resolve.md +36 -38
- package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
- package/dist/hud/components/{learning-counts.js → curation-counts.js} +63 -17
- package/dist/hud/config.js +1 -1
- package/dist/hud/index.js +6 -6
- package/dist/hud/render.js +3 -3
- package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
- package/dist/skills/git/references/pr/post-review-summary.md +12 -11
- package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
- package/dist/skills/git/references/pr/validate-branch.md +8 -5
- package/dist/skills/git/references/publication-gate.md +6 -7
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
- package/dist/targets/claude-code/{learning-install.js → curation-install.js} +85 -65
- package/dist/targets/claude-code/installer.js +15 -15
- package/dist/targets/claude-code/language-stamp.js +5 -5
- package/dist/targets/claude-code/post-install.js +2 -2
- package/dist/targets/claude-code/tracker-servers.js +1 -1
- package/package.json +3 -2
- package/src/assets/agents/_partials/_code.mds +11 -11
- package/src/assets/agents/code.mds +6 -6
- package/src/assets/agents/curate.md +8 -8
- package/src/assets/agents/debug.mds +6 -6
- package/src/assets/agents/design.mds +18 -18
- package/src/assets/agents/evaluate.md +6 -12
- package/src/assets/agents/fix.mds +7 -6
- package/src/assets/agents/git.mds +3 -3
- package/src/assets/agents/harden.mds +7 -7
- package/src/assets/agents/knowledge.mds +23 -23
- package/src/assets/agents/refactor.mds +2 -2
- package/src/assets/agents/research.mds +15 -15
- package/src/assets/agents/review.mds +43 -43
- package/src/assets/agents/skim.mds +11 -11
- package/src/assets/agents/triage.mds +29 -22
- package/src/assets/agents/validate.md +30 -50
- package/src/assets/commands/_partials/_decisions.mds +22 -20
- package/src/assets/commands/_partials/_engine.mds +8 -8
- package/src/assets/commands/_partials/_knowledge.mds +29 -15
- package/src/assets/commands/_partials/_preamble.mds +4 -4
- package/src/assets/commands/_partials/_settings.mds +3 -3
- package/src/assets/commands/_partials/_wave.mds +7 -7
- package/src/assets/commands/bug-analysis.mds +37 -48
- package/src/assets/commands/code-review.mds +53 -57
- package/src/assets/commands/debug.mds +14 -14
- package/src/assets/commands/dynamic-build.mds +38 -37
- package/src/assets/commands/dynamic-plan.mds +22 -22
- package/src/assets/commands/dynamic-profile.mds +2 -2
- package/src/assets/commands/dynamic-tickets.mds +8 -8
- package/src/assets/commands/explore.mds +20 -20
- package/src/assets/commands/implement.mds +77 -98
- package/src/assets/commands/plan.mds +27 -27
- package/src/assets/commands/release.mds +12 -12
- package/src/assets/commands/research.mds +19 -19
- package/src/assets/commands/resolve.mds +66 -60
- package/src/assets/commands/self-review.mds +20 -20
- package/src/assets/mds/git/_ops.mds +9 -2
- package/src/assets/mds/git/_ops_pr.mds +21 -11
- package/src/assets/mds/git/_ops_steps.mds +20 -4
- package/src/assets/mds/git/_pr.mds +21 -18
- package/src/assets/mds/git/_references.mds +6 -7
- package/src/assets/mds/tracker/_github.mds +1 -4
- package/src/assets/mds/tracker/_jira.mds +1 -4
- package/src/assets/mds/tracker/_linear.mds +1 -4
- package/src/assets/rules/quality.md +1 -0
- package/src/assets/scripts/ci-wait.cjs +389 -92
- package/src/assets/scripts/hooks/background-memory-update +81 -3
- package/src/assets/scripts/hooks/capture-prompt +12 -12
- package/src/assets/scripts/hooks/capture-question +11 -11
- package/src/assets/scripts/hooks/capture-turn +12 -12
- package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
- package/src/assets/scripts/hooks/curation-move +293 -0
- package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
- package/src/assets/scripts/hooks/ensure-proxy +4 -4
- package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
- package/src/assets/scripts/hooks/git-marker +81 -0
- package/src/assets/scripts/hooks/json-helper.cjs +176 -51
- package/src/assets/scripts/hooks/json-parse +1 -1
- package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
- package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +308 -122
- package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
- package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
- package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +42 -22
- package/src/assets/scripts/hooks/lib/tracked-areas.cjs +452 -0
- package/src/assets/scripts/hooks/memory-permission +10 -2
- package/src/assets/scripts/hooks/memory-worker +12 -1
- package/src/assets/scripts/hooks/pre-compact-memory +17 -16
- package/src/assets/scripts/hooks/queue-append +444 -102
- package/src/assets/scripts/hooks/resolve-project-root +2 -2
- package/src/assets/scripts/hooks/session-start-context +198 -72
- package/src/assets/scripts/hooks/session-start-memory +42 -43
- package/src/assets/scripts/lib/project-config.cjs +12 -5
- package/src/assets/scripts/pr-evidence.cjs +5 -2
- package/src/assets/scripts/resolve-base.cjs +1043 -0
- package/src/assets/scripts/resolve-settings.cjs +283 -45
- package/src/assets/scripts/verify-evidence.cjs +307 -75
- package/src/assets/skills/agent-references/SKILL.md +11 -2
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +29 -0
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
- package/src/assets/skills/agent-references/references/commands/resolve-levels.md +25 -0
- package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
- package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
- package/src/assets/skills/apply-decisions/SKILL.md +1 -1
- package/src/assets/skills/docs-framework/SKILL.md +7 -7
- package/src/assets/skills/docs-framework/references/patterns.md +3 -4
- package/src/assets/skills/quality-gates/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +2 -2
- package/templates/lint-pack/.lint-pack.conf +86 -0
- package/templates/lint-pack/README.md +190 -0
- package/templates/lint-pack/lint-pack.sh +905 -0
- package/dist/cli/commands/learning.js +0 -554
- package/dist/core/learning-queue-cleanup.js +0 -27
- package/dist/core/learning-store.js +0 -61
- /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file manifest-lock.ts
|
|
3
|
+
*
|
|
4
|
+
* A mkdir lock for `<devflowDir>/manifest.json`. It covers two writers, which read the
|
|
5
|
+
* manifest, decide and write it back: the toggle write of the `memory`, `curation`,
|
|
6
|
+
* `knowledge` and `behavior` switches (`writeMachineFeature`) and the read heal
|
|
7
|
+
* (`readManifest`). Without a lock, a heal that parsed the file
|
|
8
|
+
* before a `--disable` landed writes its stale snapshot over the disable, after the
|
|
9
|
+
* toggle reported success. Both writers take this lock around their read and their write.
|
|
10
|
+
*
|
|
11
|
+
* Every other writer of the manifest takes no lock: `syncManifestFeature` (the `hud`,
|
|
12
|
+
* `security`, `proxy` and `tracker` toggles), the `flags` and `rules` writes, the gate
|
|
13
|
+
* level writer and init. Such a write can still overwrite a toggle that lands between its
|
|
14
|
+
* read and its write. That gap is older than this lock and is tracked in issue #587.
|
|
15
|
+
*
|
|
16
|
+
* The lock is a directory beside the manifest. `mkdir` is atomic, so one caller makes
|
|
17
|
+
* it and every other caller sees EEXIST. A holder that died leaves the directory
|
|
18
|
+
* behind; a directory older than {@link MANIFEST_LOCK_STALE_MS} counts as left over
|
|
19
|
+
* and a waiter breaks it.
|
|
20
|
+
*
|
|
21
|
+
* Breaking is where the shared hook lock (`hooks/lib/mkdir-lock.cjs`) is weak: two
|
|
22
|
+
* waiters that both saw the same stale lock can both remove it, and the second
|
|
23
|
+
* removal can hit the lock that the first waiter took in between. This module breaks
|
|
24
|
+
* a lock in two steps. The waiter renames the directory to a unique name, so only one
|
|
25
|
+
* waiter can move it, and then checks that the directory it moved really was stale.
|
|
26
|
+
* A waiter that moved a fresh lock gives it back and counts the lock as busy.
|
|
27
|
+
*
|
|
28
|
+
* The give-back leaves a short time in which the live holder's directory is not at the
|
|
29
|
+
* path, and a third caller can make a directory there. So a holder writes a random
|
|
30
|
+
* token into a file inside the directory it made, and `stillHeld()` tells it whether the
|
|
31
|
+
* directory at the path still holds that token. A holder calls it just before its write.
|
|
32
|
+
* When it answers false, the holder does not write: the heal skips, and the toggle takes
|
|
33
|
+
* the lock again. A release removes the directory only while it still holds the holder's
|
|
34
|
+
* own token. A token tells two directories apart on every file system. The device and
|
|
35
|
+
* inode numbers of a directory do not: Linux file systems often give the freed inode
|
|
36
|
+
* number of a removed directory to the next one made. The check narrows the window to
|
|
37
|
+
* the time between the check and the write itself; a directory lock cannot close it.
|
|
38
|
+
*/
|
|
39
|
+
import { promises as fs } from 'fs';
|
|
40
|
+
import { randomBytes } from 'crypto';
|
|
41
|
+
import * as path from 'path';
|
|
42
|
+
/** The lock directory's name, in the machine directory beside `manifest.json`. */
|
|
43
|
+
export const MANIFEST_LOCK_NAME = 'manifest.json.lock';
|
|
44
|
+
/**
|
|
45
|
+
* What a stale break adds to the lock's name for the directory it moves aside:
|
|
46
|
+
* `manifest.json.lock.stale-<pid>-<12 lowercase hex>`. A break whose removal failed leaves one.
|
|
47
|
+
*/
|
|
48
|
+
export const MANIFEST_LOCK_STALE_MARK = '.stale-';
|
|
49
|
+
/**
|
|
50
|
+
* Age after which a lock directory counts as left behind by a dead holder. The
|
|
51
|
+
* critical sections are one read and one atomic write, so a live holder is done in
|
|
52
|
+
* milliseconds.
|
|
53
|
+
*/
|
|
54
|
+
export const MANIFEST_LOCK_STALE_MS = 5_000;
|
|
55
|
+
/** The file inside the lock directory that holds the holder's token. */
|
|
56
|
+
export const MANIFEST_LOCK_TOKEN_NAME = 'holder';
|
|
57
|
+
/**
|
|
58
|
+
* The longest a toggle waits for the lock. It is longer than the stale age, so a lock
|
|
59
|
+
* left by a dead holder is always broken inside the wait.
|
|
60
|
+
*/
|
|
61
|
+
export const MANIFEST_LOCK_WAIT_MS = 7_000;
|
|
62
|
+
const POLL_MS = 25;
|
|
63
|
+
/** The lock directory for `devflowDir`. */
|
|
64
|
+
export function getManifestLockPath(devflowDir) {
|
|
65
|
+
return path.join(devflowDir, MANIFEST_LOCK_NAME);
|
|
66
|
+
}
|
|
67
|
+
function errorCode(err) {
|
|
68
|
+
return err?.code;
|
|
69
|
+
}
|
|
70
|
+
function sleep(ms) {
|
|
71
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
}
|
|
73
|
+
/** `made`: this call made the directory, with its token. `exists`: another caller has it, or it was taken away at once. `unusable`: the folder cannot hold it. */
|
|
74
|
+
async function makeLockDir(lockDir) {
|
|
75
|
+
try {
|
|
76
|
+
await fs.mkdir(lockDir, { recursive: false });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return errorCode(err) === 'EEXIST' ? 'exists' : 'unusable';
|
|
80
|
+
}
|
|
81
|
+
const token = randomBytes(16).toString('hex');
|
|
82
|
+
try {
|
|
83
|
+
await fs.writeFile(path.join(lockDir, MANIFEST_LOCK_TOKEN_NAME), token, { flag: 'wx', mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
// ENOENT: the directory was taken away at once. Another failure: the directory is
|
|
87
|
+
// empty or another's, so `rmdir` removes only an empty one of ours.
|
|
88
|
+
if (errorCode(err) === 'ENOENT')
|
|
89
|
+
return 'exists';
|
|
90
|
+
await fs.rmdir(lockDir).catch(() => undefined);
|
|
91
|
+
return 'unusable';
|
|
92
|
+
}
|
|
93
|
+
return { made: token };
|
|
94
|
+
}
|
|
95
|
+
/** True when the directory at `lockDir` is a real directory (not a link) that holds `token`. */
|
|
96
|
+
async function holdsToken(lockDir, token) {
|
|
97
|
+
try {
|
|
98
|
+
if (!(await fs.lstat(lockDir)).isDirectory())
|
|
99
|
+
return false;
|
|
100
|
+
return (await fs.readFile(path.join(lockDir, MANIFEST_LOCK_TOKEN_NAME), 'utf-8')) === token;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function removeQuietly(dir) {
|
|
107
|
+
try {
|
|
108
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Best effort: a leftover renamed directory holds no lock.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Break a stale lock. True when the lock path is free to take again (this call removed
|
|
116
|
+
* a stale lock, or it was already gone); false when it is held by a live holder or
|
|
117
|
+
* could not be moved.
|
|
118
|
+
*
|
|
119
|
+
* The directory is renamed to a unique name before anything is removed, so of any
|
|
120
|
+
* number of waiters that judged the same lock stale, one rename succeeds and the rest
|
|
121
|
+
* get ENOENT. The rename is also where a late waiter can hit a lock that a new holder
|
|
122
|
+
* has taken since it judged: the age of the moved directory tells the two apart, and
|
|
123
|
+
* a fresh one is put back.
|
|
124
|
+
*/
|
|
125
|
+
async function breakStaleLock(lockDir, staleMs) {
|
|
126
|
+
try {
|
|
127
|
+
const seen = await fs.stat(lockDir);
|
|
128
|
+
if (Date.now() - seen.mtimeMs <= staleMs)
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
return errorCode(err) === 'ENOENT';
|
|
133
|
+
}
|
|
134
|
+
const moved = `${lockDir}${MANIFEST_LOCK_STALE_MARK}${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
135
|
+
try {
|
|
136
|
+
await fs.rename(lockDir, moved);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
// ENOENT: another waiter broke it, or the holder released it, since the age check.
|
|
140
|
+
return errorCode(err) === 'ENOENT';
|
|
141
|
+
}
|
|
142
|
+
let movedAge;
|
|
143
|
+
try {
|
|
144
|
+
movedAge = Date.now() - (await fs.stat(moved)).mtimeMs;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (movedAge > staleMs) {
|
|
150
|
+
await removeQuietly(moved);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
// A live holder took the lock between the age check and the rename. Give it back,
|
|
154
|
+
// unless the path was taken again meanwhile; then the moved copy is removed, and its
|
|
155
|
+
// holder finds out through `stillHeld` that its directory is not at the path any more.
|
|
156
|
+
try {
|
|
157
|
+
await fs.lstat(lockDir);
|
|
158
|
+
await removeQuietly(moved);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
try {
|
|
162
|
+
await fs.rename(moved, lockDir);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
await removeQuietly(moved);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
/** One try: make the lock, or break a stale one and make it once more. */
|
|
171
|
+
async function tryOnce(lockDir, staleMs) {
|
|
172
|
+
const first = await makeLockDir(lockDir);
|
|
173
|
+
if (typeof first === 'object')
|
|
174
|
+
return first.made;
|
|
175
|
+
if (first === 'unusable')
|
|
176
|
+
return 'unusable';
|
|
177
|
+
if (!(await breakStaleLock(lockDir, staleMs)))
|
|
178
|
+
return 'busy';
|
|
179
|
+
const second = await makeLockDir(lockDir);
|
|
180
|
+
if (typeof second === 'object')
|
|
181
|
+
return second.made;
|
|
182
|
+
return second === 'exists' ? 'busy' : 'unusable';
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Take the manifest lock. Waits at most `timeoutMs` (polling every 25 ms), then gives
|
|
186
|
+
* up with `not-acquired`; with `timeoutMs: 0` it tries once. The number of tries is
|
|
187
|
+
* fixed by the timeout, so no wait is unbounded. A folder that cannot hold a lock (it
|
|
188
|
+
* does not exist, or it is read-only) also answers `not-acquired`, at once, and this
|
|
189
|
+
* call never creates the folder.
|
|
190
|
+
*/
|
|
191
|
+
export async function acquireManifestLock(devflowDir, options = {}) {
|
|
192
|
+
const timeoutMs = Math.max(0, options.timeoutMs ?? MANIFEST_LOCK_WAIT_MS);
|
|
193
|
+
const staleMs = options.staleMs ?? MANIFEST_LOCK_STALE_MS;
|
|
194
|
+
const lockDir = getManifestLockPath(devflowDir);
|
|
195
|
+
const tries = Math.floor(timeoutMs / POLL_MS) + 1;
|
|
196
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
197
|
+
const outcome = await tryOnce(lockDir, staleMs);
|
|
198
|
+
if (outcome !== 'busy' && outcome !== 'unusable') {
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
value: {
|
|
202
|
+
stillHeld: () => holdsToken(lockDir, outcome),
|
|
203
|
+
release: async () => {
|
|
204
|
+
if (!(await holdsToken(lockDir, outcome)))
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
await fs.rm(path.join(lockDir, MANIFEST_LOCK_TOKEN_NAME), { force: true });
|
|
208
|
+
await fs.rmdir(lockDir);
|
|
209
|
+
}
|
|
210
|
+
catch { /* already released */ }
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (outcome === 'unusable')
|
|
216
|
+
break;
|
|
217
|
+
if (attempt < tries - 1)
|
|
218
|
+
await sleep(POLL_MS);
|
|
219
|
+
}
|
|
220
|
+
return { ok: false, error: 'not-acquired' };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Run `work` while holding the manifest lock, and release it after, whether `work`
|
|
224
|
+
* returns or throws (a throw goes on to the caller). `work` does not run when the lock
|
|
225
|
+
* is not acquired. `work` gets the holder's `stillHeld` check, to call just before it writes.
|
|
226
|
+
*/
|
|
227
|
+
export async function withManifestLock(devflowDir, options, work) {
|
|
228
|
+
const lock = await acquireManifestLock(devflowDir, options);
|
|
229
|
+
if (!lock.ok)
|
|
230
|
+
return lock;
|
|
231
|
+
try {
|
|
232
|
+
return { ok: true, value: await work({ stillHeld: lock.value.stillHeld }) };
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await lock.value.release();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=manifest-lock.js.map
|
package/dist/core/manifest.js
CHANGED
|
@@ -5,6 +5,8 @@ import { VIEW_MODES, migrateLegacyFlagsToRecord, sanitizeFlagsRecord, } from './
|
|
|
5
5
|
import { normalizeComplianceFeature } from './compliance.js';
|
|
6
6
|
import { normalizeTrackerFeature } from './tracker.js';
|
|
7
7
|
import { writeFileAtomicExclusive } from './fs-atomic.js';
|
|
8
|
+
import { isMachineFeatureOn } from './feature-switch.js';
|
|
9
|
+
import { withManifestLock } from './manifest-lock.js';
|
|
8
10
|
/**
|
|
9
11
|
* Parse features.flags across the three on-disk shapes; reports whether a heal is owed.
|
|
10
12
|
*
|
|
@@ -77,7 +79,11 @@ export function hasRequiredManifestFields(data) {
|
|
|
77
79
|
*
|
|
78
80
|
* Self-heals the following on-disk inconsistencies:
|
|
79
81
|
* - features.kb → features.knowledge rename (both absent → true, D-FEATURES-ABSENT-ON)
|
|
80
|
-
* -
|
|
82
|
+
* - the curation switch (features.curation beside its pre-rename copy features.learning,
|
|
83
|
+
* and the oldest features.decisions) resolves by the machine rule — false in either
|
|
84
|
+
* current key wins, both absent → true (D-FEATURES-ABSENT-ON) — and the heal writes
|
|
85
|
+
* both current keys to the resolved value and drops `decisions`; a second read writes
|
|
86
|
+
* nothing
|
|
81
87
|
* - features.flags as string[] → FlagsRecord (via migrateLegacyFlagsToRecord)
|
|
82
88
|
* - features.viewMode folded into flags['view-mode'] and stripped from result
|
|
83
89
|
* - features.knownFlags stripped from result (folded into FlagsRecord key-presence)
|
|
@@ -90,6 +96,12 @@ export function hasRequiredManifestFields(data) {
|
|
|
90
96
|
*
|
|
91
97
|
* D39: heal-write failure returns the migrated in-memory manifest (not null).
|
|
92
98
|
* The on-disk format remains unhealed; next read triggers another attempt.
|
|
99
|
+
*
|
|
100
|
+
* The heal is a read-modify-write and takes the manifest lock (manifest-lock.ts) like
|
|
101
|
+
* the toggle write does. It never waits for it: when the lock is held, it skips the
|
|
102
|
+
* write, because the next read heals. Under the lock it reads the file again and
|
|
103
|
+
* writes only when those bytes are the bytes it parsed, so a toggle that landed after
|
|
104
|
+
* the parse is never written over with the older snapshot.
|
|
93
105
|
*/
|
|
94
106
|
export async function readManifest(devflowDir, opts) {
|
|
95
107
|
const manifestPath = path.join(devflowDir, 'manifest.json');
|
|
@@ -100,7 +112,7 @@ export async function readManifest(devflowDir, opts) {
|
|
|
100
112
|
return null;
|
|
101
113
|
const features = data.features;
|
|
102
114
|
// D-FEATURES-ABSENT-ON (a sub-decision of D-FEATURES-NARROW-ONLY,
|
|
103
|
-
// src/core/feature-switch.ts): knowledge and
|
|
115
|
+
// src/core/feature-switch.ts): knowledge and curation are machine-wide
|
|
104
116
|
// switches, and every runtime gate — queue_read_gates in the hooks,
|
|
105
117
|
// isMachineFeatureOn in the CLI, the knowledge write-back prose gate — reads
|
|
106
118
|
// an ABSENT key as ON. This reader must agree: init seeds from it and every
|
|
@@ -119,13 +131,15 @@ export async function readManifest(devflowDir, opts) {
|
|
|
119
131
|
const knowledge = typeof features.knowledge === 'boolean' ? features.knowledge
|
|
120
132
|
: typeof features.kb === 'boolean' ? features.kb
|
|
121
133
|
: true;
|
|
122
|
-
// Self-heal:
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
// Self-heal: the curation switch is stored under two current keys, `curation`
|
|
135
|
+
// and its pre-rename copy `learning` (#529). A boolean false in either is off, a
|
|
136
|
+
// boolean true in either is on when neither is false, and the pre-rename
|
|
137
|
+
// features.decisions decides only when neither current key is a boolean;
|
|
138
|
+
// default ON. isMachineFeatureOn IS that rule (D-LEARNING-LEGACY-DECISIONS),
|
|
139
|
+
// and queue_read_gates and resolve-settings.cjs mirror it, so the legacy key
|
|
140
|
+
// is honoured before the heal lands and a disagreeing pair reads off. The heal
|
|
141
|
+
// (needsHeal below) writes the resolved value to both current keys once.
|
|
142
|
+
const curation = isMachineFeatureOn(data, 'curation');
|
|
129
143
|
// Self-heal: non-string-array or absent knownFlags/knownPlugins → undefined (never partial/garbage)
|
|
130
144
|
const asStringArray = (val) => Array.isArray(val) && val.every(e => typeof e === 'string')
|
|
131
145
|
? val
|
|
@@ -140,9 +154,14 @@ export async function readManifest(devflowDir, opts) {
|
|
|
140
154
|
const { flags: parsedFlags, legacy: flagsWereLegacy } = parseManifestFlags(features, knownFlags);
|
|
141
155
|
// D39: sanitize all values; block prototype pollution keys.
|
|
142
156
|
const sanitizedFlags = sanitizeFlagsRecord(parsedFlags);
|
|
143
|
-
// needsHeal when any legacy artifact is present on disk
|
|
157
|
+
// needsHeal when any legacy artifact is present on disk. The curation pair is a
|
|
158
|
+
// defect unless BOTH current keys are present and equal to the resolved value
|
|
159
|
+
// (a disagreeing pair, one key alone and neither key all heal); once healed,
|
|
160
|
+
// the pair holds and a second read writes nothing.
|
|
144
161
|
const needsHeal = features.kb !== undefined ||
|
|
145
162
|
features.decisions !== undefined ||
|
|
163
|
+
features.curation !== curation ||
|
|
164
|
+
features.learning !== curation ||
|
|
146
165
|
flagsWereLegacy ||
|
|
147
166
|
features.knownFlags !== undefined ||
|
|
148
167
|
features.viewMode !== undefined ||
|
|
@@ -161,7 +180,7 @@ export async function readManifest(devflowDir, opts) {
|
|
|
161
180
|
memory: features.memory,
|
|
162
181
|
hud: typeof features.hud === 'boolean' ? features.hud : false,
|
|
163
182
|
knowledge,
|
|
164
|
-
|
|
183
|
+
curation,
|
|
165
184
|
rules: typeof features.rules === 'boolean' ? features.rules : true,
|
|
166
185
|
flags: sanitizedFlags,
|
|
167
186
|
// knownFlags and viewMode are NOT carried into the result.
|
|
@@ -177,7 +196,7 @@ export async function readManifest(devflowDir, opts) {
|
|
|
177
196
|
proxy: typeof features.proxy === 'boolean' ? features.proxy : false,
|
|
178
197
|
// Self-heal: absent/malformed compliance → {enabled:false, frameworks:[]}
|
|
179
198
|
compliance: normalizeComplianceFeature(features.compliance),
|
|
180
|
-
// D-BEHAVIOR-OPT-IN: absent/malformed → false (opt-in; the inverse of knowledge/
|
|
199
|
+
// D-BEHAVIOR-OPT-IN: absent/malformed → false (opt-in; the inverse of knowledge/curation).
|
|
181
200
|
behavior: features.behavior === true,
|
|
182
201
|
// Self-heal: absent/malformed/unknown tracker → {provider:'github'} (AC-3.21).
|
|
183
202
|
// Deliberately NOT in the hard-null set above — see the field's doc comment.
|
|
@@ -197,7 +216,15 @@ export async function readManifest(devflowDir, opts) {
|
|
|
197
216
|
// propagate to the caller. The migrated in-memory manifest is returned even
|
|
198
217
|
// when the on-disk heal fails. The next read will retry the heal.
|
|
199
218
|
try {
|
|
200
|
-
|
|
219
|
+
// No wait: a held lock means a toggle is writing, and the next read heals.
|
|
220
|
+
// Under the lock the write goes ahead only while the file still holds the
|
|
221
|
+
// bytes parsed above and the lock directory is still this heal's own; any
|
|
222
|
+
// other writer's result stands, and a skipped heal is made by the next read.
|
|
223
|
+
await withManifestLock(devflowDir, { timeoutMs: 0 }, async (lock) => {
|
|
224
|
+
if ((await fs.readFile(manifestPath, 'utf-8')) === content && (await lock.stillHeld())) {
|
|
225
|
+
await writeManifest(devflowDir, manifest);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
201
228
|
}
|
|
202
229
|
catch {
|
|
203
230
|
// heal-write failed — return migrated in-memory manifest unchanged
|
|
@@ -219,9 +246,26 @@ export async function writeManifest(devflowDir, data) {
|
|
|
219
246
|
await fs.mkdir(devflowDir, { recursive: true });
|
|
220
247
|
const manifestPath = path.join(devflowDir, 'manifest.json');
|
|
221
248
|
// D-MANIFEST-SCOPE-PINNED: every write records 'user', whatever the caller holds.
|
|
222
|
-
|
|
249
|
+
// The manifest mirror (#529): the pre-rename key `learning` is written equal to
|
|
250
|
+
// `curation`, right after it, so every typed writer keeps the copy a 3.7.0 CLI reads.
|
|
251
|
+
const pinned = { ...data, scope: 'user', features: withCurationMirror(data.features) };
|
|
223
252
|
await writeFileAtomicExclusive(manifestPath, JSON.stringify(pinned, null, 2) + '\n');
|
|
224
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* `features` with the pre-rename key `learning` set equal to `curation`, placed right
|
|
256
|
+
* after it. Pure. Any `learning` the input carried is replaced, never kept.
|
|
257
|
+
*/
|
|
258
|
+
function withCurationMirror(features) {
|
|
259
|
+
const mirrored = {};
|
|
260
|
+
for (const [key, value] of Object.entries(features)) {
|
|
261
|
+
if (key === 'learning')
|
|
262
|
+
continue;
|
|
263
|
+
mirrored[key] = value;
|
|
264
|
+
if (key === 'curation')
|
|
265
|
+
mirrored.learning = value;
|
|
266
|
+
}
|
|
267
|
+
return mirrored;
|
|
268
|
+
}
|
|
225
269
|
/**
|
|
226
270
|
* Update a single feature field in the manifest. No-op when no manifest exists.
|
|
227
271
|
* Reads, mutates, and writes atomically. The `updatedAt` timestamp is always refreshed.
|
package/dist/core/migrations.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { promises as fs } from 'fs';
|
|
12
12
|
import * as path from 'path';
|
|
13
13
|
import { writeFileAtomicExclusive } from './fs-atomic.js';
|
|
14
|
+
import { pooled } from './pool.js';
|
|
14
15
|
import { getMemoryDir } from './project-paths.js';
|
|
15
16
|
import { LEGACY_AGENT_KEYS, canonicaliseAgentKeys, parseAgentMappingEnvelope } from './agent-models.js';
|
|
16
17
|
import { migrateLegacyTrackerConventions } from './tracker.js';
|
|
@@ -215,19 +216,6 @@ export function reportMigrationResult(result, logger, verbose) {
|
|
|
215
216
|
logger.info(` ✓ ${id}`);
|
|
216
217
|
}
|
|
217
218
|
}
|
|
218
|
-
/**
|
|
219
|
-
* Process an array of items with at most `limit` concurrent Promises.
|
|
220
|
-
* Returns PromiseSettledResult for every item in the original order.
|
|
221
|
-
*/
|
|
222
|
-
async function pooled(items, limit, fn) {
|
|
223
|
-
const results = [];
|
|
224
|
-
for (let i = 0; i < items.length; i += limit) {
|
|
225
|
-
const chunk = items.slice(i, i + limit);
|
|
226
|
-
const chunkResults = await Promise.allSettled(chunk.map(fn));
|
|
227
|
-
results.push(...chunkResults);
|
|
228
|
-
}
|
|
229
|
-
return results;
|
|
230
|
-
}
|
|
231
219
|
/**
|
|
232
220
|
* Run a single global migration, returning { applied, failure, infos, warnings }.
|
|
233
221
|
*
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* INACTIVE_DECISIONS_STATUSES stay in the ledger and leave the rendered files and
|
|
11
11
|
* the HUD counts; an absent or unknown status counts as active. These lists
|
|
12
12
|
* mirror ACTIVE_STATUSES / INACTIVE_STATUSES in
|
|
13
|
-
* src/assets/scripts/hooks/lib/
|
|
14
|
-
* renderer use, and the parity test in tests/decisions/
|
|
13
|
+
* src/assets/scripts/hooks/lib/curation-store.cjs, which the hooks and the
|
|
14
|
+
* renderer use, and the parity test in tests/decisions/curation-store.test.ts
|
|
15
15
|
* pins the two equal. Reason: the renderer, the HUD and this module each kept a
|
|
16
16
|
* list of their own, so a status added to one counted as active in the others.
|
|
17
17
|
*/
|
package/dist/core/plugins.js
CHANGED
|
@@ -51,10 +51,10 @@ export const DEVFLOW_PLUGINS = [
|
|
|
51
51
|
agents: ['curate', 'tracker'],
|
|
52
52
|
/**
|
|
53
53
|
* D-AGENT-REFS: `agent-references` is a reference-only skill. It holds the Synthesize mode
|
|
54
|
-
* templates
|
|
55
|
-
* the command branches that D-CMD-REF-HAND moves out
|
|
56
|
-
* which a command reads by explicit path, so it sits
|
|
57
|
-
* and no other plugin lists or requires it.
|
|
54
|
+
* templates, the Curate maintain step and the Validate passed-alone re-run steps, which those
|
|
55
|
+
* three agents read by explicit path, and the command branches that D-CMD-REF-HAND moves out
|
|
56
|
+
* of a command (`references/commands/`), which a command reads by explicit path, so it sits
|
|
57
|
+
* here (core-skills is always installed) and no other plugin lists or requires it.
|
|
58
58
|
* It is never preloaded: tests/guards/agent-references.test.ts holds that, and the one
|
|
59
59
|
* exception to the repo-wide ban on `disable-model-invocation` names this skill alone.
|
|
60
60
|
*/
|
|
@@ -481,36 +481,36 @@ export const FEATURE_OWNED_SKILLS = ['compliance'];
|
|
|
481
481
|
*/
|
|
482
482
|
export const FEATURE_OWNED_RULES = ['compliance', 'behavior'];
|
|
483
483
|
/**
|
|
484
|
-
* Skills a
|
|
484
|
+
* Skills a curation-off machine does not install.
|
|
485
485
|
*
|
|
486
|
-
* D-LEARNING-VARIANT-INSTALL: the
|
|
486
|
+
* D-LEARNING-VARIANT-INSTALL: the curation-off variants of the commands and
|
|
487
487
|
* agents (D-LEARNING-VARIANTS) carry no decisions text and preload no
|
|
488
|
-
* apply-decisions, so the skill has no reader on a machine with
|
|
488
|
+
* apply-decisions, so the skill has no reader on a machine with curation off. It
|
|
489
489
|
* stays OWNED and REQUIRED by its plugins in the registry — the closure guard
|
|
490
|
-
* reasons over the
|
|
491
|
-
* on top of the selection: `installViaFileCopy` skips it when `
|
|
492
|
-
* and `
|
|
490
|
+
* reasons over the curation-on variant, the superset — and the install drops it
|
|
491
|
+
* on top of the selection: `installViaFileCopy` skips it when `curation` is false
|
|
492
|
+
* and `convergeCurationVariants` installs or removes it when the switch flips.
|
|
493
493
|
*
|
|
494
494
|
* This is a machine-switch condition, not a deselection: the plan
|
|
495
|
-
* ({@link resolveSkillInstallPlan}) is untouched, so a
|
|
495
|
+
* ({@link resolveSkillInstallPlan}) is untouched, so a curation-off machine never
|
|
496
496
|
* reports the skill as "removed because no selected plugin requires it" nor its
|
|
497
497
|
* shadow as inactive for a plugin that is not selected.
|
|
498
498
|
*
|
|
499
499
|
* Used by:
|
|
500
|
-
* - installer.ts installViaFileCopy: omits these from the install loop when
|
|
501
|
-
* -
|
|
500
|
+
* - installer.ts installViaFileCopy: omits these from the install loop when curation is off
|
|
501
|
+
* - curation-install.ts convergeCurationVariants: installs or removes the directory
|
|
502
502
|
* - tests: independent literal ['apply-decisions'] (avoids the EXCLUDED-as-oracle trap)
|
|
503
503
|
*/
|
|
504
|
-
export const
|
|
504
|
+
export const CURATION_GATED_SKILLS = ['apply-decisions'];
|
|
505
505
|
/**
|
|
506
|
-
* A skills map with the
|
|
506
|
+
* A skills map with the curation-gated skills left out when curation is off.
|
|
507
507
|
*
|
|
508
|
-
* Pure: the same map instance comes back when
|
|
508
|
+
* Pure: the same map instance comes back when curation is on, a copy otherwise.
|
|
509
509
|
*/
|
|
510
|
-
export function
|
|
511
|
-
if (
|
|
510
|
+
export function omitCurationGatedSkills(skillsMap, curation) {
|
|
511
|
+
if (curation)
|
|
512
512
|
return skillsMap;
|
|
513
|
-
const gated =
|
|
513
|
+
const gated = CURATION_GATED_SKILLS;
|
|
514
514
|
return new Map([...skillsMap].filter(([skill]) => !gated.includes(skill)));
|
|
515
515
|
}
|
|
516
516
|
// ── Skill-closure boundaries ──────────────────────────────────────────────────
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file pool.ts
|
|
3
|
+
*
|
|
4
|
+
* Bounded concurrency over an array. Shared by the migration runner and the
|
|
5
|
+
* curation move step, so neither imports the other for a general helper.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Process an array of items with at most `limit` concurrent Promises.
|
|
9
|
+
* Returns PromiseSettledResult for every item in the original order.
|
|
10
|
+
*
|
|
11
|
+
* Precondition: `limit` is a positive integer. A limit below 1 (or NaN) would never
|
|
12
|
+
* advance the chunk loop, so the call rejects with a RangeError before `fn` runs.
|
|
13
|
+
*/
|
|
14
|
+
export async function pooled(items, limit, fn) {
|
|
15
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
16
|
+
throw new RangeError(`pooled: limit must be a positive integer, got ${String(limit)}`);
|
|
17
|
+
}
|
|
18
|
+
const results = [];
|
|
19
|
+
for (let i = 0; i < items.length; i += limit) {
|
|
20
|
+
const chunk = items.slice(i, i + limit);
|
|
21
|
+
const chunkResults = await Promise.allSettled(chunk.map(fn));
|
|
22
|
+
results.push(...chunkResults);
|
|
23
|
+
}
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=pool.js.map
|