devflow-kit 2.4.0 → 3.0.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 +229 -0
- package/README.md +111 -18
- package/dist/agents/git.md +822 -0
- package/dist/cli/commands/agents.js +6 -1
- package/dist/cli/commands/ambient.js +160 -145
- package/dist/cli/commands/attribution-prompts.js +1 -1
- package/dist/cli/commands/capture.js +29 -55
- package/dist/cli/commands/compliance-prompts.js +1 -1
- package/dist/cli/commands/compliance.js +48 -55
- package/dist/cli/commands/context.js +17 -32
- package/dist/cli/commands/debug.js +65 -26
- package/dist/cli/commands/flags.js +3 -3
- package/dist/cli/commands/hud.js +34 -10
- package/dist/cli/commands/init-seed.js +61 -27
- package/dist/cli/commands/init.js +649 -240
- package/dist/cli/commands/install-report.js +200 -0
- package/dist/cli/commands/knowledge/index.js +2 -2
- package/dist/cli/commands/knowledge/toggle.js +35 -37
- package/dist/cli/commands/learning.js +79 -57
- package/dist/cli/commands/legacy-hooks.js +11 -14
- package/dist/cli/commands/memory.js +134 -135
- package/dist/cli/commands/prompt-io.js +4 -4
- package/dist/cli/commands/proxy.js +23 -41
- package/dist/cli/commands/security.js +81 -29
- package/dist/cli/commands/skills.js +71 -7
- package/dist/cli/commands/tracker-prompts.js +145 -0
- package/dist/cli/commands/tracker.js +277 -0
- package/dist/cli/commands/uninstall.js +520 -169
- package/dist/cli.js +2 -0
- package/dist/commands/bug-analysis.md +58 -14
- package/dist/commands/code-review.md +110 -32
- package/dist/commands/debug.md +55 -11
- package/dist/commands/dynamic-build.md +344 -73
- package/dist/commands/dynamic-plan.md +77 -27
- package/dist/commands/dynamic-profile.md +25 -11
- package/dist/commands/dynamic-tickets.md +76 -15
- package/dist/commands/explore.md +37 -7
- package/dist/commands/implement.md +314 -62
- package/dist/commands/plan.md +146 -32
- package/dist/commands/release.md +64 -17
- package/dist/commands/research.md +34 -8
- package/dist/commands/resolve.md +196 -68
- package/dist/commands/self-review.md +45 -9
- package/dist/core/agent-models.js +55 -12
- package/dist/core/assets.js +58 -2
- package/dist/core/compliance-compose.js +27 -27
- package/dist/core/evidence-policy.js +363 -0
- package/dist/core/feature-config.js +200 -65
- package/dist/core/feature-switch.js +112 -0
- package/dist/core/flags.js +34 -6
- package/dist/core/fs-atomic.js +27 -0
- package/dist/core/hook-log-dirs.js +104 -0
- package/dist/core/learning-tuning-config.js +5 -3
- package/dist/core/ledger-root.js +102 -0
- package/dist/core/manifest.js +38 -10
- package/dist/core/mds-variants.js +798 -0
- package/dist/core/migrations.js +49 -23
- package/dist/core/model-discovery.js +12 -1
- package/dist/core/plugins.js +361 -12
- package/dist/core/project-paths.js +1 -18
- package/dist/core/proxy-log.js +8 -6
- package/dist/core/proxy-state.js +11 -8
- package/dist/core/reference-sweep.js +136 -0
- package/dist/core/same-location.js +25 -0
- package/dist/core/tracker.js +494 -0
- package/dist/hud/components/config-counts.js +15 -4
- package/dist/hud/components/learning-counts.js +14 -0
- package/dist/hud/config.js +2 -1
- package/dist/hud/cost-history.js +2 -4
- package/dist/hud/git.js +52 -7
- package/dist/hud/index.js +7 -9
- package/dist/skills/git/references/decision-markers.md +19 -0
- package/dist/skills/git/references/learn-conventions.md +56 -0
- package/dist/skills/git/references/pr/check-ci-status.md +14 -0
- package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
- package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
- package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
- package/dist/skills/git/references/pr/post-review-summary.md +42 -0
- package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
- package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
- package/dist/skills/git/references/pr/validate-branch.md +18 -0
- package/dist/skills/git/references/publication-gate.md +13 -0
- package/dist/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
- package/dist/skills/git/references/tracker/github/create-release.md +11 -0
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
- package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
- package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
- package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
- package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
- package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
- package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
- package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
- package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
- package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
- package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
- package/dist/skills/git/references/trust-rule.md +7 -0
- package/dist/targets/claude-code/claude-paths.js +59 -57
- package/dist/targets/claude-code/compliance-install.js +49 -65
- package/dist/targets/claude-code/hooks.js +108 -3
- package/dist/targets/claude-code/installer.js +1187 -32
- package/dist/targets/claude-code/legacy.js +5 -0
- package/dist/targets/claude-code/post-install.js +366 -151
- package/dist/targets/claude-code/tracker-install.js +134 -0
- package/package.json +8 -6
- package/src/assets/agents/code.md +45 -6
- package/src/assets/agents/design.md +2 -1
- package/src/assets/agents/git.mds +825 -0
- package/src/assets/agents/knowledge.md +3 -3
- package/src/assets/agents/learning.md +11 -0
- package/src/assets/agents/review.md +3 -1
- package/src/assets/agents/synthesize.md +1 -1
- package/src/assets/agents/test.md +16 -5
- package/src/assets/agents/tracker.md +474 -0
- package/src/assets/agents/validate.md +7 -5
- package/src/assets/commands/_partials/_compliance.mds +19 -1
- package/src/assets/commands/_partials/_decisions.mds +15 -3
- package/src/assets/commands/_partials/_docs_root.mds +35 -0
- package/src/assets/commands/_partials/_engine.mds +13 -11
- package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
- package/src/assets/commands/_partials/_factory.mds +1 -1
- package/src/assets/commands/_partials/_knowledge.mds +27 -9
- package/src/assets/commands/_partials/_plan_contract.mds +22 -7
- package/src/assets/commands/_partials/_preamble.mds +2 -2
- package/src/assets/commands/_partials/_publication.mds +8 -2
- package/src/assets/commands/_partials/_settings.mds +28 -0
- package/src/assets/commands/_partials/_ticket_template.mds +3 -2
- package/src/assets/commands/_partials/_tracker.mds +18 -0
- package/src/assets/commands/_partials/_wave.mds +16 -10
- package/src/assets/commands/bug-analysis.mds +31 -19
- package/src/assets/commands/code-review.mds +67 -41
- package/src/assets/commands/debug.mds +13 -7
- package/src/assets/commands/dynamic-build.mds +274 -66
- package/src/assets/commands/dynamic-plan.mds +50 -23
- package/src/assets/commands/dynamic-profile.mds +24 -11
- package/src/assets/commands/dynamic-tickets.mds +63 -16
- package/src/assets/commands/explore.mds +4 -5
- package/src/assets/commands/implement.mds +234 -67
- package/src/assets/commands/plan.mds +91 -33
- package/src/assets/commands/release.md +64 -17
- package/src/assets/commands/research.mds +11 -9
- package/src/assets/commands/resolve.mds +150 -78
- package/src/assets/commands/self-review.mds +24 -25
- package/src/assets/mds/git/_pr.mds +331 -0
- package/src/assets/mds/git/_references.mds +135 -0
- package/src/assets/mds/tracker/_common.mds +156 -0
- package/src/assets/mds/tracker/_github.mds +472 -0
- package/src/assets/mds/tracker/_jira.mds +407 -0
- package/src/assets/mds/tracker/_linear.mds +449 -0
- package/src/assets/mds/tracker/_mcp.mds +305 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
- package/src/assets/scripts/hooks/background-memory-update +40 -19
- package/src/assets/scripts/hooks/capture-prompt +18 -8
- package/src/assets/scripts/hooks/capture-question +18 -8
- package/src/assets/scripts/hooks/capture-turn +27 -13
- package/src/assets/scripts/hooks/debug-trace +11 -6
- package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
- package/src/assets/scripts/hooks/ensure-proxy +9 -8
- package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
- package/src/assets/scripts/hooks/git-marker +48 -0
- package/src/assets/scripts/hooks/hook-log-init +3 -1
- package/src/assets/scripts/hooks/json-helper.cjs +228 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
- package/src/assets/scripts/hooks/log-paths +80 -0
- package/src/assets/scripts/hooks/memory-worker +22 -13
- package/src/assets/scripts/hooks/pre-compact-memory +44 -15
- package/src/assets/scripts/hooks/preamble +1 -4
- package/src/assets/scripts/hooks/queue-append +146 -28
- package/src/assets/scripts/hooks/resolve-project-root +101 -7
- package/src/assets/scripts/hooks/session-start-context +534 -20
- package/src/assets/scripts/hooks/session-start-memory +38 -15
- package/src/assets/scripts/lib/project-config.cjs +633 -0
- package/src/assets/scripts/pr-evidence.cjs +1961 -0
- package/src/assets/scripts/redact-secrets.cjs +490 -62
- package/src/assets/scripts/release-trace.cjs +1143 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
- package/src/assets/scripts/resolve-settings.cjs +1054 -0
- package/src/assets/scripts/verify-evidence.cjs +1822 -0
- package/src/assets/skills/compliance/SKILL.md +4 -2
- package/src/assets/skills/docs-framework/SKILL.md +11 -10
- package/src/assets/skills/docs-framework/references/patterns.md +10 -17
- package/src/assets/skills/gap-analysis/SKILL.md +2 -2
- package/src/assets/skills/git/SKILL.md +8 -78
- package/src/assets/skills/git/references/github-api.md +179 -141
- package/src/assets/skills/git/references/patterns.md +11 -6
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +6 -61
- package/src/assets/skills/review-methodology/references/violations.md +14 -22
- package/src/assets/skills/worktree-support/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +29 -0
- package/src/targets/claude-code/templates/managed-settings.json +25 -9
- package/src/assets/agents/git.md +0 -938
|
@@ -1,125 +1,260 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
2
|
import { promises as fs } from 'fs';
|
|
3
3
|
import { getFeatureConfigPath } from './project-paths.js';
|
|
4
|
+
import { parseTrackerId } from './tracker.js';
|
|
5
|
+
import { loadProjectConfigLib } from './evidence-policy.js';
|
|
6
|
+
/**
|
|
7
|
+
* Keys devflow itself once wrote and has retired. A managed write drops them
|
|
8
|
+
* rather than carrying them; no reader consults them.
|
|
9
|
+
*
|
|
10
|
+
* D-FEATURES-NARROW-ONLY: `memory`, `learning` and `knowledge` were top-level
|
|
11
|
+
* per-repo feature switches from the per-repo-install era. A repository now
|
|
12
|
+
* narrows a feature only through the `features` namespace, so a stale top-level
|
|
13
|
+
* value must neither decide anything nor linger to be mistaken for a switch:
|
|
14
|
+
* carrying it would leave a `learning: false` in the file that no longer does
|
|
15
|
+
* what it says. `decisions` is the pre-rename spelling of `learning`;
|
|
16
|
+
* `autoCommit` is inert. `features` is deliberately NOT here — it is a live key,
|
|
17
|
+
* carried like any other unmanaged key (avoids PF-071).
|
|
18
|
+
*/
|
|
19
|
+
const RETIRED_CONFIG_KEYS = new Set([
|
|
20
|
+
'memory', 'learning', 'knowledge', 'decisions', 'autoCommit',
|
|
21
|
+
]);
|
|
4
22
|
export const DEFAULT_CONFIG = {
|
|
5
|
-
memory: true,
|
|
6
|
-
learning: true,
|
|
7
|
-
knowledge: true,
|
|
8
23
|
reviewPublication: 'auto',
|
|
9
24
|
};
|
|
10
25
|
export function getConfigPath(projectRoot) {
|
|
11
26
|
return getFeatureConfigPath(projectRoot);
|
|
12
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Parse the per-repo tracker override from the raw config value.
|
|
30
|
+
*
|
|
31
|
+
* Pure. Delegates membership to `parseTrackerId` rather than re-spelling a
|
|
32
|
+
* closed-domain ternary the way `reviewPublication` does: `reviewPublication`
|
|
33
|
+
* self-heals any invalid value to `'auto'`, which is correct for a publication
|
|
34
|
+
* mode and wrong for a provider — a repaired provider is the laundering path
|
|
35
|
+
* GAP-10 names, and §14.2 gives the invalid case its own DEGRADED reason, which
|
|
36
|
+
* only exists if the parse REFUSES instead of healing.
|
|
37
|
+
*
|
|
38
|
+
* An empty string reads as absent: `"tracker": ""` is an unset key with a
|
|
39
|
+
* character in it, not an attempt to name a provider.
|
|
40
|
+
*
|
|
41
|
+
* A non-string JSON value (`42`, `true`, `null`, an array, an object) is
|
|
42
|
+
* `invalid`, not `absent` — a present-but-wrong-typed value means the file was
|
|
43
|
+
* edited, and reporting it as absent would make the whole class silent.
|
|
44
|
+
*/
|
|
45
|
+
export function parseTrackerOverride(raw) {
|
|
46
|
+
if (raw === undefined || raw === '')
|
|
47
|
+
return { kind: 'absent' };
|
|
48
|
+
// `unknown` all the way from the field: this is a boundary parse over
|
|
49
|
+
// hand-edited JSON, and a signature that promised a string would make the
|
|
50
|
+
// wrong-type arm unreachable to the compiler while it stays entirely reachable
|
|
51
|
+
// to a user with a text editor.
|
|
52
|
+
if (typeof raw !== 'string') {
|
|
53
|
+
return { kind: 'invalid', raw: JSON.stringify(raw) ?? String(raw) };
|
|
54
|
+
}
|
|
55
|
+
const parsed = parseTrackerId(raw);
|
|
56
|
+
return parsed.ok ? { kind: 'valid', provider: parsed.value } : { kind: 'invalid', raw };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether a parsed JSON value is an object a config can be read from — the one
|
|
60
|
+
* test for "the file holds a config", shared by the reader and the managed
|
|
61
|
+
* write so the two never disagree about which files count as empty.
|
|
62
|
+
*/
|
|
63
|
+
function isJsonObject(value) {
|
|
64
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
13
66
|
/**
|
|
14
67
|
* Parse and narrow an unknown JSON value into a FeatureConfig, merging onto
|
|
15
68
|
* DEFAULT_CONFIG. Pure function — no I/O, no side effects.
|
|
16
69
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* manifest.ts's new-key-wins self-heal — migration-compat requires the old key
|
|
20
|
-
* to take precedence so old configs with `decisions: false` are not silently
|
|
21
|
-
* re-enabled by a newer `learning: true` key).
|
|
22
|
-
* Silently ignores `autoCommit` — old configs may still contain it.
|
|
70
|
+
* The retired keys ({@link RETIRED_CONFIG_KEYS}) are ignored: an old config may
|
|
71
|
+
* still hold them, and none of them decides anything (D-FEATURES-NARROW-ONLY).
|
|
23
72
|
*
|
|
24
73
|
* Returns null when `parsed` is not a plain object (caller falls through to
|
|
25
74
|
* the next candidate path).
|
|
26
75
|
*/
|
|
27
76
|
function coerceConfig(parsed) {
|
|
28
|
-
if (
|
|
77
|
+
if (!isJsonObject(parsed))
|
|
29
78
|
return null;
|
|
30
79
|
const p = parsed;
|
|
31
|
-
// Coalesce decisions (legacy key) → learning. decisions wins when both present.
|
|
32
|
-
let learning = DEFAULT_CONFIG.learning;
|
|
33
|
-
if (typeof p.learning === 'boolean')
|
|
34
|
-
learning = p.learning;
|
|
35
|
-
if (typeof p.decisions === 'boolean')
|
|
36
|
-
learning = p.decisions; // decisions wins
|
|
37
80
|
// Coerce reviewPublication: any invalid or absent value → 'auto' (self-heal, ADR-014 idiom).
|
|
38
81
|
const rp = p.reviewPublication;
|
|
39
82
|
const reviewPublication = rp === 'auto' || rp === 'full' || rp === 'off' ? rp : 'auto';
|
|
83
|
+
// The per-repo tracker override is carried through VERBATIM — never coerced,
|
|
84
|
+
// never type-filtered. Two reasons, and the second is the one a reader is
|
|
85
|
+
// likely to miss:
|
|
86
|
+
// (a) repair is forbidden for a provider value (§14.9-6), so there is no
|
|
87
|
+
// healed value to fall back to the way reviewPublication has 'auto';
|
|
88
|
+
// (b) a non-string is a state parseTrackerOverride CLASSIFIES (`invalid`),
|
|
89
|
+
// not a state this function repairs. Filtering by type here would leave
|
|
90
|
+
// that arm reachable from a direct call to the parser and unreachable
|
|
91
|
+
// from the file, which is the only place it can actually be written.
|
|
92
|
+
// Key PRESENCE is the whole rule: a present key is carried as written, and an
|
|
93
|
+
// absent one stays absent. `hasOwnProperty` rather than `in` because the
|
|
94
|
+
// object comes from JSON.parse at a trust boundary. Retention on disk is not
|
|
95
|
+
// decided here: the writers carry every unmanaged key from the file itself
|
|
96
|
+
// (D-CONFIG-PRESERVE-UNMANAGED).
|
|
97
|
+
const hasTracker = Object.prototype.hasOwnProperty.call(p, 'tracker');
|
|
40
98
|
return {
|
|
41
|
-
memory: typeof p.memory === 'boolean' ? p.memory : DEFAULT_CONFIG.memory,
|
|
42
|
-
learning,
|
|
43
|
-
knowledge: typeof p.knowledge === 'boolean' ? p.knowledge : DEFAULT_CONFIG.knowledge,
|
|
44
99
|
reviewPublication,
|
|
100
|
+
...(hasTracker ? { tracker: p.tracker } : {}),
|
|
45
101
|
};
|
|
46
102
|
}
|
|
47
103
|
/**
|
|
48
|
-
* Read the
|
|
49
|
-
* Returns DEFAULT_CONFIG when the file is missing or
|
|
50
|
-
* Applies ADR-001: .devflow/config.json is the sole source of truth;
|
|
51
|
-
* `devflow init` writes it directly on first install. An absent file falls
|
|
52
|
-
* through to DEFAULT_CONFIG (all features enabled by default).
|
|
104
|
+
* Read the per-repo config for a project root.
|
|
105
|
+
* Returns DEFAULT_CONFIG when the file is missing or unusable.
|
|
53
106
|
*/
|
|
54
107
|
export async function readConfig(projectRoot) {
|
|
55
|
-
|
|
108
|
+
return coerceConfig(objectOf(await readConfigBody(projectRoot))) ?? { ...DEFAULT_CONFIG };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Classify a config file's bytes (`null` is no file). Pure.
|
|
112
|
+
*
|
|
113
|
+
* D-CONFIG-STRICT-PARSE: `.devflow/config.json` is judged by the one parser the
|
|
114
|
+
* resolvers use (lib/project-config.cjs, D-PROJECT-STRICT-KEYS), never by a bare
|
|
115
|
+
* `JSON.parse`. The two disagreed where it mattered: `JSON.parse` keeps the
|
|
116
|
+
* LAST of two duplicate keys silently, while the resolvers read the file as
|
|
117
|
+
* saying two things and fail its keys closed — so devflow could rewrite a file
|
|
118
|
+
* into a meaning it never had. A file the parser rejects is `malformed`.
|
|
119
|
+
*/
|
|
120
|
+
export function classifyConfigBytes(buf, lib) {
|
|
121
|
+
const decoded = lib.decodeConfigBytes(buf);
|
|
122
|
+
if (decoded.kind === 'absent')
|
|
123
|
+
return { kind: 'absent' };
|
|
124
|
+
if (decoded.kind === 'invalid')
|
|
125
|
+
return { kind: 'malformed' };
|
|
126
|
+
let parsed;
|
|
56
127
|
try {
|
|
57
|
-
|
|
58
|
-
if (config !== null)
|
|
59
|
-
return config;
|
|
60
|
-
return { ...DEFAULT_CONFIG };
|
|
128
|
+
parsed = JSON.parse(decoded.text);
|
|
61
129
|
}
|
|
62
130
|
catch {
|
|
63
|
-
return {
|
|
131
|
+
return { kind: 'malformed' };
|
|
64
132
|
}
|
|
133
|
+
if (!isJsonObject(parsed))
|
|
134
|
+
return { kind: 'malformed' };
|
|
135
|
+
const duplicates = lib.collectDuplicateKeyPaths(decoded.text);
|
|
136
|
+
if (duplicates === null || duplicates.size > 0)
|
|
137
|
+
return { kind: 'malformed' };
|
|
138
|
+
return { kind: 'object', value: parsed };
|
|
139
|
+
}
|
|
140
|
+
/** The object a config body holds, or undefined for any other body. */
|
|
141
|
+
function objectOf(body) {
|
|
142
|
+
return body.kind === 'object' ? body.value : undefined;
|
|
65
143
|
}
|
|
66
144
|
/**
|
|
67
|
-
*
|
|
145
|
+
* Read and classify a project's config file. Every read of the file goes
|
|
146
|
+
* through here, so readConfig, readConfigIfPresent and writeManagedConfig agree
|
|
147
|
+
* on what an unusable file means. Never throws.
|
|
148
|
+
*
|
|
149
|
+
* D-CONFIG-NO-FOLLOW: the bytes come from the resolvers' own bounded read
|
|
150
|
+
* (lib/project-config.cjs readBoundedRegularFile, `followSymlinks` false — the
|
|
151
|
+
* read resolve-settings' readConfigFile makes), so devflow and the settings line
|
|
152
|
+
* never disagree about which file configures the repository. A symlink —
|
|
153
|
+
* dangling or not — a directory, a FIFO or a file over MAX_CONFIG_BYTES is
|
|
154
|
+
* refused unopened and reads as `unreadable`: readers configure nothing from it,
|
|
155
|
+
* and writeManagedConfig leaves it in place (D-CONFIG-NO-REPAIR) rather than
|
|
156
|
+
* writing through the link or renaming a regular file over it.
|
|
157
|
+
*/
|
|
158
|
+
async function readConfigBody(projectRoot, lib = loadProjectConfigLib()) {
|
|
159
|
+
if (!lib.ok)
|
|
160
|
+
return { kind: 'unreadable', detail: `config parser unavailable: ${lib.error.path}` };
|
|
161
|
+
const read = lib.value.readBoundedRegularFile(getFeatureConfigPath(projectRoot), lib.value.MAX_CONFIG_BYTES, false);
|
|
162
|
+
if (read.kind === 'absent')
|
|
163
|
+
return { kind: 'absent' };
|
|
164
|
+
if (read.kind === 'refused') {
|
|
165
|
+
return {
|
|
166
|
+
kind: 'unreadable',
|
|
167
|
+
detail: `not a regular file of at most ${lib.value.MAX_CONFIG_BYTES} bytes; a symlink is never followed`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return classifyConfigBytes(read.bytes, lib.value);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Serialise a config body to a project's config file.
|
|
68
174
|
* Creates the .devflow/ directory if missing.
|
|
69
175
|
* Uses an atomic temp+rename pattern to prevent partial reads under concurrent writes.
|
|
70
176
|
*/
|
|
71
|
-
|
|
177
|
+
async function writeConfigBody(projectRoot, body) {
|
|
72
178
|
const configPath = getFeatureConfigPath(projectRoot);
|
|
73
179
|
await fs.mkdir(path.join(projectRoot, '.devflow'), { recursive: true });
|
|
74
180
|
const tmpPath = configPath + '.tmp.' + process.pid;
|
|
75
|
-
await fs.writeFile(tmpPath, JSON.stringify(
|
|
181
|
+
await fs.writeFile(tmpPath, JSON.stringify(body, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
76
182
|
await fs.rename(tmpPath, configPath);
|
|
77
183
|
}
|
|
78
184
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
185
|
+
* Merge devflow's managed keys over the config body the file already holds.
|
|
186
|
+
* Pure — returns a new object and never mutates `existing`.
|
|
81
187
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* a
|
|
188
|
+
* D-CONFIG-PRESERVE-UNMANAGED (avoids PF-071): `.devflow/config.json` is a
|
|
189
|
+
* user-editable file that devflow only PARTLY owns. The managed keys come from
|
|
190
|
+
* `managed`; every other key comes from the file, verbatim and by key presence
|
|
191
|
+
* — the hand-written per-repo `tracker` override (whose invalid values must
|
|
192
|
+
* survive so their DEGRADED report can name them) and any key devflow does not
|
|
193
|
+
* know. The alternative, writing the declared shape, is a silent delete of all
|
|
194
|
+
* of them on every `devflow init`. Every writer of the file goes through here
|
|
195
|
+
* (writeManagedConfig, called by init). Only the managed key is copied from
|
|
196
|
+
* `managed`, by name, so a caller holding a whole FeatureConfig still cannot
|
|
197
|
+
* overwrite the file's override with its in-memory copy. The retired keys in
|
|
198
|
+
* RETIRED_CONFIG_KEYS are dropped, not carried. A body that is
|
|
199
|
+
* not a JSON object reads as empty, exactly as readConfigIfPresent treats it;
|
|
200
|
+
* writeManagedConfig never reaches here with a malformed file.
|
|
201
|
+
*
|
|
202
|
+
* This holds under `devflow init --reset` too: a factory reset returns
|
|
203
|
+
* devflow's own settings to their defaults through `managed`, and leaves the
|
|
204
|
+
* keys devflow never wrote alone.
|
|
88
205
|
*/
|
|
89
|
-
export
|
|
90
|
-
const
|
|
91
|
-
|
|
206
|
+
export function mergeManagedConfig(existing, managed) {
|
|
207
|
+
const fileKeys = isJsonObject(existing)
|
|
208
|
+
? Object.fromEntries(Object.entries(existing).filter(([key]) => !RETIRED_CONFIG_KEYS.has(key)))
|
|
209
|
+
: {};
|
|
210
|
+
return {
|
|
211
|
+
...fileKeys,
|
|
212
|
+
reviewPublication: managed.reviewPublication,
|
|
213
|
+
};
|
|
92
214
|
}
|
|
93
215
|
/**
|
|
94
|
-
*
|
|
216
|
+
* Write devflow's managed keys to a project's config, keeping every key it
|
|
217
|
+
* does not manage (D-CONFIG-PRESERVE-UNMANAGED). Never throws.
|
|
218
|
+
*
|
|
219
|
+
* D-CONFIG-NO-REPAIR: a file that exists but is malformed or unreadable
|
|
220
|
+
* (D-CONFIG-STRICT-PARSE) is left byte-for-byte as it is, and the Result says
|
|
221
|
+
* why. The file is the user's: a syntax error in it still holds their
|
|
222
|
+
* hand-written keys — the `tracker` override first among them — and a rewrite
|
|
223
|
+
* from an empty merge would delete them silently. The resolvers fail its keys
|
|
224
|
+
* closed meanwhile, so leaving it costs nothing but the managed key.
|
|
225
|
+
*
|
|
226
|
+
* D1: Non-atomic read-modify-write. A concurrent writer could lose the other's
|
|
227
|
+
* change. Acceptable because init is a single-threaded, user-initiated command
|
|
228
|
+
* and the window is milliseconds on a local filesystem; the file swap itself is
|
|
229
|
+
* atomic (temp + rename), so a reader never sees a partial file.
|
|
95
230
|
*/
|
|
96
|
-
export async function
|
|
97
|
-
const
|
|
98
|
-
|
|
231
|
+
export async function writeManagedConfig(projectRoot, managed, lib = loadProjectConfigLib()) {
|
|
232
|
+
const configPath = getFeatureConfigPath(projectRoot);
|
|
233
|
+
const existing = await readConfigBody(projectRoot, lib);
|
|
234
|
+
if (existing.kind === 'malformed')
|
|
235
|
+
return { ok: false, error: { kind: 'malformed', path: configPath } };
|
|
236
|
+
if (existing.kind === 'unreadable') {
|
|
237
|
+
return { ok: false, error: { kind: 'unreadable', path: configPath, detail: existing.detail } };
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
await writeConfigBody(projectRoot, mergeManagedConfig(objectOf(existing), managed));
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
return { ok: false, error: { kind: 'write-failed', path: configPath, detail: err instanceof Error ? err.message : String(err) } };
|
|
244
|
+
}
|
|
245
|
+
return { ok: true };
|
|
99
246
|
}
|
|
100
247
|
/**
|
|
101
|
-
* Read the
|
|
248
|
+
* Read the per-repo config for a project root, returning null when the file
|
|
102
249
|
* is absent or malformed.
|
|
103
250
|
*
|
|
104
251
|
* Unlike readConfig (which falls back to DEFAULT_CONFIG on any error),
|
|
105
252
|
* readConfigIfPresent distinguishes "not configured yet" (null) from
|
|
106
|
-
* "configured with specific values" (FeatureConfig).
|
|
107
|
-
*
|
|
108
|
-
* manifest for memory/learning/knowledge even when the manifest is absent.
|
|
109
|
-
*
|
|
110
|
-
* Applies ADR-001: .devflow/config.json is the source of truth; null means
|
|
111
|
-
* the config file does not exist or is unreadable — not that all features
|
|
112
|
-
* are disabled.
|
|
253
|
+
* "configured with specific values" (FeatureConfig). init relies on the
|
|
254
|
+
* distinction to carry a repo's own reviewPublication across a re-init.
|
|
113
255
|
*/
|
|
114
256
|
export async function readConfigIfPresent(projectRoot) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const text = await fs.readFile(configPath, 'utf-8');
|
|
118
|
-
return coerceConfig(JSON.parse(text)); // null when JSON is not a plain object
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
// ENOENT (absent) or SyntaxError (malformed) — treat as not present
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
257
|
+
// null when the file is absent, malformed or unreadable
|
|
258
|
+
return coerceConfig(objectOf(await readConfigBody(projectRoot)));
|
|
124
259
|
}
|
|
125
260
|
//# sourceMappingURL=feature-config.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { writeFileAtomicExclusive } from './fs-atomic.js';
|
|
4
|
+
function isJsonObject(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The pre-rename key each machine feature was stored under, where one exists:
|
|
9
|
+
* `learning` was `decisions` (ADR-011) and `knowledge` was `kb`. A manifest no
|
|
10
|
+
* command has rewritten since the rename can still hold only the legacy key.
|
|
11
|
+
* `memory` was never renamed.
|
|
12
|
+
*/
|
|
13
|
+
const LEGACY_KEYS = {
|
|
14
|
+
learning: 'decisions',
|
|
15
|
+
knowledge: 'kb',
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Whether a RAW parsed manifest leaves `feature` switched on. Pure.
|
|
19
|
+
*
|
|
20
|
+
* Only an explicit boolean `false` switches a feature off. A missing key, a
|
|
21
|
+
* non-boolean value, or anything that is not a manifest-shaped object reads as
|
|
22
|
+
* ON — fail-open (ADR-028), and the exact rule `queue_read_gates` applies in the
|
|
23
|
+
* shell hooks, so the CLI's status and the runtime never disagree about the
|
|
24
|
+
* same file.
|
|
25
|
+
*
|
|
26
|
+
* D-LEARNING-LEGACY-DECISIONS (a sub-decision of D-FEATURES-NARROW-ONLY):
|
|
27
|
+
* `learning` is read as `features.learning` when that is a boolean, else the
|
|
28
|
+
* legacy `features.decisions` when THAT is a boolean, else ON — readManifest's
|
|
29
|
+
* migration precedence exactly. The legacy key is otherwise honoured only once
|
|
30
|
+
* some command happens to run readManifest and heal it, so a `decisions: false`
|
|
31
|
+
* would keep learning running until then. queue_read_gates applies the same
|
|
32
|
+
* precedence.
|
|
33
|
+
*
|
|
34
|
+
* D-KNOWLEDGE-LEGACY-KB (the same sub-decision for the other renamed key):
|
|
35
|
+
* `knowledge` is read as `features.knowledge` when that is a boolean, else the
|
|
36
|
+
* legacy `features.kb` when THAT is a boolean, else ON — again readManifest's
|
|
37
|
+
* precedence exactly, so `devflow knowledge --status` reports a `kb: false`
|
|
38
|
+
* as disabled. queue_read_gates never reads knowledge, so there is no shell
|
|
39
|
+
* mirror. The knowledge write-back prose gate deliberately does not learn the
|
|
40
|
+
* legacy key (ADR-028: no prompt text for a state only an un-upgraded install
|
|
41
|
+
* can hold); readManifest rewrites `kb` to `knowledge` on the next CLI run
|
|
42
|
+
* that loads the manifest, after which that gate reads the healed key.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately NOT built on readManifest(): it returns null for a manifest
|
|
45
|
+
* missing any of its required fields — reported as "on" here, as the hooks read
|
|
46
|
+
* it — and it writes its heals back to disk, which a read-only status must not.
|
|
47
|
+
*/
|
|
48
|
+
export function isMachineFeatureOn(rawManifest, feature) {
|
|
49
|
+
if (!isJsonObject(rawManifest))
|
|
50
|
+
return true;
|
|
51
|
+
const features = rawManifest.features;
|
|
52
|
+
if (!isJsonObject(features))
|
|
53
|
+
return true;
|
|
54
|
+
const legacyKey = LEGACY_KEYS[feature];
|
|
55
|
+
const value = legacyKey !== undefined && typeof features[feature] !== 'boolean'
|
|
56
|
+
? features[legacyKey]
|
|
57
|
+
: features[feature];
|
|
58
|
+
return value !== false;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Set `feature` in a RAW parsed manifest. Pure — returns a new object and never
|
|
62
|
+
* mutates its input. Null when the value is not a manifest-shaped object (there
|
|
63
|
+
* is no `features` record to write into).
|
|
64
|
+
*
|
|
65
|
+
* Only `features.<feature>` and `updatedAt` change; every other key is carried
|
|
66
|
+
* verbatim. Writing `learning` leaves a legacy `decisions` key in place, and
|
|
67
|
+
* writing `knowledge` a legacy `kb` key, inert: the boolean just written wins
|
|
68
|
+
* over it (D-LEARNING-LEGACY-DECISIONS, D-KNOWLEDGE-LEGACY-KB). Going through
|
|
69
|
+
* readManifest()/writeManifest() instead would refuse a manifest that reader
|
|
70
|
+
* rejects, drop every key ManifestData does not model (one a newer devflow
|
|
71
|
+
* wrote, say), and persist that reader's unrelated heals as a side effect of a
|
|
72
|
+
* one-key toggle.
|
|
73
|
+
*/
|
|
74
|
+
export function setMachineFeature(rawManifest, feature, enabled, now) {
|
|
75
|
+
if (!isJsonObject(rawManifest) || !isJsonObject(rawManifest.features))
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
...rawManifest,
|
|
79
|
+
features: { ...rawManifest.features, [feature]: enabled },
|
|
80
|
+
updatedAt: now,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function readRawManifest(devflowDir) {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(await fs.readFile(path.join(devflowDir, 'manifest.json'), 'utf-8'));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// ENOENT, EACCES or SyntaxError — no usable manifest.
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read the machine-wide switch from `<devflowDir>/manifest.json`. Read-only (no
|
|
94
|
+
* heal write); an absent, unreadable or malformed manifest reads as ON.
|
|
95
|
+
*/
|
|
96
|
+
export async function readMachineFeature(devflowDir, feature) {
|
|
97
|
+
return isMachineFeatureOn(await readRawManifest(devflowDir), feature);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Write the machine-wide switch to `<devflowDir>/manifest.json` (atomic
|
|
101
|
+
* temp + rename). Refuses with `not-installed` when there is no manifest to
|
|
102
|
+
* write into — a manifest is created by `devflow init`, never by a toggle,
|
|
103
|
+
* because a bare `{features: {...}}` file is not a manifest any reader accepts.
|
|
104
|
+
*/
|
|
105
|
+
export async function writeMachineFeature(devflowDir, feature, enabled) {
|
|
106
|
+
const next = setMachineFeature(await readRawManifest(devflowDir), feature, enabled, new Date().toISOString());
|
|
107
|
+
if (next === null)
|
|
108
|
+
return { ok: false, error: 'not-installed' };
|
|
109
|
+
await writeFileAtomicExclusive(path.join(devflowDir, 'manifest.json'), JSON.stringify(next, null, 2) + '\n');
|
|
110
|
+
return { ok: true, value: undefined };
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=feature-switch.js.map
|
package/dist/core/flags.js
CHANGED
|
@@ -121,8 +121,8 @@ export const FLAG_REGISTRY = [
|
|
|
121
121
|
kind: 'boolean',
|
|
122
122
|
target: { type: 'setting', key: 'disableBundledSkills' },
|
|
123
123
|
onPayload: true,
|
|
124
|
-
recommended:
|
|
125
|
-
defaultValue:
|
|
124
|
+
recommended: false,
|
|
125
|
+
defaultValue: false,
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
id: 'pin-sonnet-4-6',
|
|
@@ -133,8 +133,8 @@ export const FLAG_REGISTRY = [
|
|
|
133
133
|
kind: 'boolean',
|
|
134
134
|
target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL' },
|
|
135
135
|
onPayload: 'claude-sonnet-4-6',
|
|
136
|
-
recommended:
|
|
137
|
-
defaultValue:
|
|
136
|
+
recommended: false,
|
|
137
|
+
defaultValue: false,
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
// Devflow fan-outs routinely exceed the upstream default of 20.
|
|
@@ -1259,7 +1259,35 @@ export function convergeFlagsIntoSettings(settingsJson, record, opts) {
|
|
|
1259
1259
|
}
|
|
1260
1260
|
// ── Step 3: strip all managed keys, then apply the folded record ──────────
|
|
1261
1261
|
const stripped = stripFlags(settingsJson);
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1262
|
+
const applied = JSON.parse(applyFlags(stripped, folded));
|
|
1263
|
+
// ── Step 4: restore the pre-strip key order (D-KEY-ORDER) ────────────────
|
|
1264
|
+
const ordered = orderKeysLike(parsed, applied);
|
|
1265
|
+
const beforeEnv = asPlainObject(parsed.env);
|
|
1266
|
+
const afterEnv = asPlainObject(ordered.env);
|
|
1267
|
+
const settings = beforeEnv && afterEnv
|
|
1268
|
+
? { ...ordered, env: orderKeysLike(beforeEnv, afterEnv) }
|
|
1269
|
+
: ordered;
|
|
1270
|
+
return { settings: JSON.stringify(settings, null, 2) + '\n', record: folded };
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Return `after`'s entries ordered as `before` had them: every key `before` held,
|
|
1274
|
+
* in `before`'s order, then the keys only `after` holds, in `after`'s order.
|
|
1275
|
+
*
|
|
1276
|
+
* D-KEY-ORDER: strip-then-apply deletes every managed key and re-adds it, so each
|
|
1277
|
+
* one lands after whatever key followed it on disk. init merges the security deny
|
|
1278
|
+
* list AFTER the flags, which appends `permissions` behind them; a second init then
|
|
1279
|
+
* moved the flags behind `permissions` and re-init stopped being a no-op on disk
|
|
1280
|
+
* (#388 AC-3). Keeping the pre-converge order makes the first run's order the stable
|
|
1281
|
+
* one: a key that stays keeps its place, a key the record newly sets is appended, a
|
|
1282
|
+
* key it drops is simply absent. Values are `after`'s — only the order is borrowed.
|
|
1283
|
+
*
|
|
1284
|
+
* `Object.fromEntries` defines each key as an own data property, so a `__proto__`
|
|
1285
|
+
* key parsed from settings.json stays a key rather than becoming the prototype.
|
|
1286
|
+
*/
|
|
1287
|
+
function orderKeysLike(before, after) {
|
|
1288
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
1289
|
+
const kept = Object.keys(before).filter(k => has(after, k));
|
|
1290
|
+
const added = Object.keys(after).filter(k => !has(before, k));
|
|
1291
|
+
return Object.fromEntries([...kept, ...added].map(k => [k, after[k]]));
|
|
1264
1292
|
}
|
|
1265
1293
|
//# sourceMappingURL=flags.js.map
|
package/dist/core/fs-atomic.js
CHANGED
|
@@ -71,4 +71,31 @@ export async function writeFileAtomicExclusive(filePath, data) {
|
|
|
71
71
|
}
|
|
72
72
|
await fs.rename(tmp, filePath);
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Write a Claude Code settings file (`settings.json`) atomically.
|
|
76
|
+
*
|
|
77
|
+
* D-SETTINGS-ATOMIC: every write of a Claude settings file goes through this one
|
|
78
|
+
* helper, so no command can leave a half-written file for Claude Code — or a
|
|
79
|
+
* concurrent devflow command — to read: the bytes land in a sibling temp file
|
|
80
|
+
* and one rename swaps them in ({@link writeFileAtomicExclusive}).
|
|
81
|
+
*
|
|
82
|
+
* A settings file that is a symbolic link (a dotfiles-managed `settings.json`)
|
|
83
|
+
* stays one: the temp file and the rename target the link's resolved file, so
|
|
84
|
+
* the link keeps pointing where the user pointed it. A dangling link has no file
|
|
85
|
+
* to resolve and is replaced like a missing file.
|
|
86
|
+
*/
|
|
87
|
+
export async function writeSettingsFileAtomic(filePath, data) {
|
|
88
|
+
await writeFileAtomicExclusive(await resolveLinkedFile(filePath), data);
|
|
89
|
+
}
|
|
90
|
+
/** `filePath`, or the file it resolves to when it is a symbolic link that resolves. */
|
|
91
|
+
async function resolveLinkedFile(filePath) {
|
|
92
|
+
try {
|
|
93
|
+
if (!(await fs.lstat(filePath)).isSymbolicLink())
|
|
94
|
+
return filePath;
|
|
95
|
+
return await fs.realpath(filePath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return filePath;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
74
101
|
//# sourceMappingURL=fs-atomic.js.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* @file hook-log-dirs.ts
|
|
5
|
+
*
|
|
6
|
+
* The cap on the hooks' per-directory log folders.
|
|
7
|
+
*
|
|
8
|
+
* D-LOG-DIR-CAP: every hook logs under `~/.devflow/logs/<slug>/`, one directory
|
|
9
|
+
* per working directory a session ran in (`log-paths`' devflow_log_dir). Left
|
|
10
|
+
* uncapped they pile up — a machine was found holding 31k of them, most left by
|
|
11
|
+
* test runs in throwaway temp directories — so `devflow init` keeps the
|
|
12
|
+
* {@link MAX_HOOK_LOG_DIRS} most recently written and removes the rest, oldest
|
|
13
|
+
* first. A folder's recency is the newest mtime among the folder and the log
|
|
14
|
+
* files in it: a log that is only ever appended to leaves the folder's own mtime
|
|
15
|
+
* where the file's creation put it, so the folder mtime alone would rank the
|
|
16
|
+
* busiest project as the oldest.
|
|
17
|
+
*
|
|
18
|
+
* Every pass is bounded — at most {@link MAX_LOG_DIRS_SCANNED} folders read and
|
|
19
|
+
* {@link MAX_LOG_DIRS_PRUNED_PER_RUN} removed. The two bounds are equal, so one
|
|
20
|
+
* init clears every folder it scanned beyond the cap: a 31k backlog costs that
|
|
21
|
+
* init about seven seconds (roughly 2.2 s per 10,000 removals), and only a
|
|
22
|
+
* backlog larger than the scan bound is finished by the next init. The pass is
|
|
23
|
+
* the one-time cleanup of an existing backlog and the standing cap alike.
|
|
24
|
+
* Only directories are touched: files at the logs root (`proxy.log`) and symbolic
|
|
25
|
+
* links are left alone.
|
|
26
|
+
*/
|
|
27
|
+
/** How many hook log folders survive a prune — the most recently written ones. */
|
|
28
|
+
export const MAX_HOOK_LOG_DIRS = 200;
|
|
29
|
+
/** The most folders one prune reads; beyond it the rest wait for a later run. */
|
|
30
|
+
export const MAX_LOG_DIRS_SCANNED = 100_000;
|
|
31
|
+
/** The most folders one prune removes — every scanned folder, so one run clears what it read. */
|
|
32
|
+
export const MAX_LOG_DIRS_PRUNED_PER_RUN = MAX_LOG_DIRS_SCANNED;
|
|
33
|
+
/** The most entries read inside one folder to find its newest log. */
|
|
34
|
+
const MAX_FILES_READ_PER_DIR = 64;
|
|
35
|
+
/** Folders stat'ed or removed at once. */
|
|
36
|
+
const IO_CHUNK = 64;
|
|
37
|
+
/** The newest mtime among `dir` and the entries in it; -Infinity when unreadable. */
|
|
38
|
+
async function recencyOf(dir) {
|
|
39
|
+
let newest;
|
|
40
|
+
try {
|
|
41
|
+
newest = (await fs.lstat(dir)).mtimeMs;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return -Infinity;
|
|
45
|
+
}
|
|
46
|
+
let names;
|
|
47
|
+
try {
|
|
48
|
+
names = (await fs.readdir(dir)).slice(0, MAX_FILES_READ_PER_DIR);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return newest;
|
|
52
|
+
}
|
|
53
|
+
const times = await Promise.all(names.map(name => fs.lstat(path.join(dir, name)).then(st => st.mtimeMs, () => -Infinity)));
|
|
54
|
+
return Math.max(newest, ...times);
|
|
55
|
+
}
|
|
56
|
+
/** Apply `fn` to every item, `IO_CHUNK` at a time. Bounded by `items.length`. */
|
|
57
|
+
async function inChunks(items, fn) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (let i = 0; i < items.length; i += IO_CHUNK) {
|
|
60
|
+
out.push(...await Promise.all(items.slice(i, i + IO_CHUNK).map(fn)));
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Remove the oldest hook log folders under `logsDir` beyond the cap
|
|
66
|
+
* (D-LOG-DIR-CAP). Never throws: a missing logs directory is nothing to do, and
|
|
67
|
+
* a folder that cannot be removed is counted as still over the cap.
|
|
68
|
+
*/
|
|
69
|
+
export async function pruneHookLogDirs(logsDir, opts = {}) {
|
|
70
|
+
const keep = opts.keep ?? MAX_HOOK_LOG_DIRS;
|
|
71
|
+
const maxRemovals = opts.maxRemovals ?? MAX_LOG_DIRS_PRUNED_PER_RUN;
|
|
72
|
+
const maxScanned = opts.maxScanned ?? MAX_LOG_DIRS_SCANNED;
|
|
73
|
+
if (!path.isAbsolute(logsDir) || path.basename(logsDir) !== 'logs') {
|
|
74
|
+
return { ok: false, error: `not a devflow logs directory: ${logsDir}` };
|
|
75
|
+
}
|
|
76
|
+
if (keep < 0 || maxRemovals < 0 || maxScanned < 0) {
|
|
77
|
+
return { ok: false, error: 'prune bounds must be non-negative' };
|
|
78
|
+
}
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await fs.readdir(logsDir, { withFileTypes: true });
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err.code === 'ENOENT')
|
|
85
|
+
return { ok: true, value: { removed: 0, overCap: 0 } };
|
|
86
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
87
|
+
}
|
|
88
|
+
const dirs = entries
|
|
89
|
+
.filter(e => e.isDirectory())
|
|
90
|
+
.slice(0, maxScanned)
|
|
91
|
+
.map(e => path.join(logsDir, e.name));
|
|
92
|
+
if (dirs.length <= keep)
|
|
93
|
+
return { ok: true, value: { removed: 0, overCap: 0 } };
|
|
94
|
+
const recencies = await inChunks(dirs, recencyOf);
|
|
95
|
+
const oldestFirst = dirs
|
|
96
|
+
.map((dir, i) => ({ dir, recency: recencies[i] }))
|
|
97
|
+
.sort((a, b) => a.recency - b.recency);
|
|
98
|
+
const overCap = oldestFirst.slice(0, oldestFirst.length - keep);
|
|
99
|
+
const batch = overCap.slice(0, maxRemovals);
|
|
100
|
+
const outcomes = await inChunks(batch, ({ dir }) => fs.rm(dir, { recursive: true, force: true }).then(() => true, () => false));
|
|
101
|
+
const removed = outcomes.filter(Boolean).length;
|
|
102
|
+
return { ok: true, value: { removed, overCap: overCap.length - removed } };
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=hook-log-dirs.js.map
|
|
@@ -57,13 +57,15 @@ function readConfigFile(filePath) {
|
|
|
57
57
|
* Priority (highest wins): project config → global config → defaults.
|
|
58
58
|
*
|
|
59
59
|
* - Global: `~/.devflow/learning.json`
|
|
60
|
-
* - Project: `<
|
|
60
|
+
* - Project: `<ledgerRoot>/.devflow/learning/learning.json` — pass the ledger root
|
|
61
|
+
* (getLedgerRoot), where session-start-context reads it and `devflow learning
|
|
62
|
+
* --configure` writes it (D-LEDGER-MAIN-WORKTREE).
|
|
61
63
|
*
|
|
62
64
|
* Invalid JSON in either file is silently ignored and treated as absent.
|
|
63
65
|
*/
|
|
64
|
-
export function loadLearningTuningConfig(
|
|
66
|
+
export function loadLearningTuningConfig(ledgerRoot) {
|
|
65
67
|
const globalConfigPath = path.join(getDevFlowDirectory(), 'learning.json');
|
|
66
|
-
const projectConfigPath = getLearningTuningConfigPath(
|
|
68
|
+
const projectConfigPath = getLearningTuningConfigPath(ledgerRoot);
|
|
67
69
|
let config = { ...DEFAULTS };
|
|
68
70
|
const globalJson = readConfigFile(globalConfigPath);
|
|
69
71
|
if (globalJson !== null) {
|