devflow-kit 2.0.1 → 2.1.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.
@@ -1,10 +1,69 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import * as path from 'path';
3
3
  import { LEGACY_PLUGIN_NAMES, DELETED_PLUGIN_NAMES } from './plugins.js';
4
- import { VIEW_MODES } from './flags.js';
4
+ import { VIEW_MODES, migrateLegacyFlagsToRecord, sanitizeFlagsRecord, } from './flags.js';
5
5
  import { normalizeComplianceFeature } from './compliance.js';
6
+ import { writeFileAtomicExclusive } from './fs-atomic.js';
7
+ /**
8
+ * Parse features.flags across the three on-disk shapes; reports whether a heal is owed.
9
+ *
10
+ * Case A: string[] — legacy format, migrated to FlagsRecord (fold viewMode in).
11
+ * Case B: object — already a FlagsRecord, fold lingering viewMode if present.
12
+ * Case C: missing/other — default to empty record.
13
+ *
14
+ * The returned `legacy` flag is true only for Case A (array). Callers use it as the
15
+ * flags-specific clause of needsHeal, keeping the legacy-artifact knowledge in one
16
+ * place and letting needsHeal derive from the parse result instead of re-inspecting
17
+ * features.flags after the fact.
18
+ */
19
+ function parseManifestFlags(features, knownFlags) {
20
+ const rawFlags = features.flags;
21
+ if (Array.isArray(rawFlags)) {
22
+ // Case A: string[] → FlagsRecord migration.
23
+ // Filter to strings only (malformed elements are silently dropped).
24
+ const enabledIds = rawFlags.filter(e => typeof e === 'string');
25
+ // Extract legacyViewMode for the migration fold.
26
+ const rawViewMode = features.viewMode;
27
+ const legacyViewMode = typeof rawViewMode === 'string' && VIEW_MODES.includes(rawViewMode)
28
+ ? rawViewMode
29
+ : undefined;
30
+ return { flags: migrateLegacyFlagsToRecord(enabledIds, knownFlags, legacyViewMode), legacy: true };
31
+ }
32
+ if (rawFlags !== null && typeof rawFlags === 'object') {
33
+ // Case B: already a FlagsRecord. Spread to avoid mutating the parsed value.
34
+ // Single cast: rawFlags is already confirmed to be a non-null, non-array object.
35
+ // sanitizeFlagsRecord (called by the outer readManifest) validates all values,
36
+ // dropping invalid ones — so the double assertion is unnecessary here (applies TS-M3).
37
+ const flagsRecord = { ...rawFlags };
38
+ // Fold lingering viewMode into flags['view-mode'] when the record lacks a
39
+ // non-default value (e.g. a manifest written by an older init that stored viewMode
40
+ // as a separate deprecated field alongside a FlagsRecord with view-mode:null).
41
+ const rawViewMode = features.viewMode;
42
+ if (typeof rawViewMode === 'string' && VIEW_MODES.includes(rawViewMode)) {
43
+ const existing = flagsRecord['view-mode'];
44
+ if (existing === null || existing === undefined || existing === 'default') {
45
+ flagsRecord['view-mode'] = rawViewMode;
46
+ }
47
+ }
48
+ return { flags: flagsRecord, legacy: false };
49
+ }
50
+ // Case C: missing/malformed → empty record
51
+ return { flags: {}, legacy: false };
52
+ }
6
53
  /**
7
54
  * Read and parse the manifest file. Returns null if missing or corrupt.
55
+ *
56
+ * Self-heals the following on-disk inconsistencies (applies ADR-014):
57
+ * - features.kb → features.knowledge rename
58
+ * - features.decisions → features.learning rename
59
+ * - features.flags as string[] → FlagsRecord (via migrateLegacyFlagsToRecord)
60
+ * - features.viewMode folded into flags['view-mode'] and stripped from result
61
+ * - features.knownFlags stripped from result (folded into FlagsRecord key-presence)
62
+ * - features.proxy absent → false
63
+ * - features.compliance absent/malformed → {enabled:false, frameworks:[]}
64
+ *
65
+ * D39: heal-write failure returns the migrated in-memory manifest (not null).
66
+ * The on-disk format remains unhealed; next read triggers another attempt.
8
67
  */
9
68
  export async function readManifest(devflowDir) {
10
69
  const manifestPath = path.join(devflowDir, 'manifest.json');
@@ -32,14 +91,27 @@ export async function readManifest(devflowDir) {
32
91
  const learning = typeof features.learning === 'boolean' ? features.learning
33
92
  : typeof features.decisions === 'boolean' ? features.decisions
34
93
  : false;
35
- const needsHeal = features.kb !== undefined || features.decisions !== undefined;
36
- const SECURITY_MODES = ['none', 'user', 'managed'];
37
94
  // Self-heal: non-string-array or absent knownFlags/knownPlugins → undefined (never partial/garbage)
38
95
  const asStringArray = (val) => Array.isArray(val) && val.every(e => typeof e === 'string')
39
96
  ? val
40
97
  : undefined;
41
- const knownFlags = asStringArray(features.knownFlags);
42
98
  const knownPlugins = asStringArray(data.knownPlugins);
99
+ // knownFlags is consumed here for migration; NOT carried into the returned manifest
100
+ const knownFlags = asStringArray(features.knownFlags);
101
+ // ── Parse flags ────────────────────────────────────────────────────────────
102
+ // Delegates to parseManifestFlags (three cases: A=string[], B=object, C=missing).
103
+ // `flagsWereLegacy` is true only when the on-disk shape was a string[] (Case A),
104
+ // keeping the needsHeal predicate in lockstep with the parse branch above.
105
+ const { flags: parsedFlags, legacy: flagsWereLegacy } = parseManifestFlags(features, knownFlags);
106
+ // PF-023 + D39: sanitize all values; block prototype pollution keys.
107
+ const sanitizedFlags = sanitizeFlagsRecord(parsedFlags);
108
+ // needsHeal when any legacy artifact is present on disk
109
+ const needsHeal = features.kb !== undefined ||
110
+ features.decisions !== undefined ||
111
+ flagsWereLegacy ||
112
+ features.knownFlags !== undefined ||
113
+ features.viewMode !== undefined;
114
+ const SECURITY_MODES = ['none', 'user', 'managed'];
43
115
  const manifest = {
44
116
  version: data.version,
45
117
  plugins: data.plugins,
@@ -52,25 +124,34 @@ export async function readManifest(devflowDir) {
52
124
  knowledge,
53
125
  learning,
54
126
  rules: typeof features.rules === 'boolean' ? features.rules : true,
55
- flags: Array.isArray(features.flags) ? features.flags : [],
56
- knownFlags,
57
- viewMode: typeof features.viewMode === 'string' && VIEW_MODES.includes(features.viewMode)
58
- ? features.viewMode
59
- : undefined,
127
+ flags: sanitizedFlags,
128
+ // knownFlags and viewMode are NOT carried into the result.
129
+ // - knownFlags: its semantic (which flags are "known") is encoded in
130
+ // FlagsRecord key-presence (present key = known, absent = new/adopt-on-seed).
131
+ // - viewMode: folded into flags['view-mode'] above.
132
+ // Leaving them out prevents them from being echoed back on the next write
133
+ // and causing a spurious needsHeal on every read.
60
134
  security: typeof features.security === 'string' && SECURITY_MODES.includes(features.security)
61
135
  ? features.security
62
136
  : undefined,
63
137
  // Self-heal: absent proxy field defaults to false (applies ADR-014 self-heal idiom)
64
138
  proxy: typeof features.proxy === 'boolean' ? features.proxy : false,
65
139
  // Self-heal: absent/malformed compliance → {enabled:false, frameworks:[]}
66
- // (applies ADR-014 self-heal idiom; normalizeComplianceFeature is in TOLERANT section)
67
140
  compliance: normalizeComplianceFeature(features.compliance),
68
141
  },
69
142
  installedAt: data.installedAt,
70
143
  updatedAt: data.updatedAt,
71
144
  };
72
145
  if (needsHeal) {
73
- await writeManifest(devflowDir, manifest);
146
+ // D39: wrap the heal-write in its own try/catch so a write failure does NOT
147
+ // propagate to the caller. The migrated in-memory manifest is returned even
148
+ // when the on-disk heal fails. The next read will retry the heal.
149
+ try {
150
+ await writeManifest(devflowDir, manifest);
151
+ }
152
+ catch {
153
+ // heal-write failed — return migrated in-memory manifest unchanged
154
+ }
74
155
  }
75
156
  return manifest;
76
157
  }
@@ -79,12 +160,15 @@ export async function readManifest(devflowDir) {
79
160
  }
80
161
  }
81
162
  /**
82
- * Write manifest to disk. Creates parent directory if needed.
163
+ * Write manifest to disk atomically. Creates parent directory if needed.
164
+ *
165
+ * Uses writeFileAtomicExclusive (tmp+rename) to prevent readers from seeing
166
+ * partial writes. Applies D34 atomic-write semantics across all manifest updates.
83
167
  */
84
168
  export async function writeManifest(devflowDir, data) {
85
169
  await fs.mkdir(devflowDir, { recursive: true });
86
170
  const manifestPath = path.join(devflowDir, 'manifest.json');
87
- await fs.writeFile(manifestPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
171
+ await writeFileAtomicExclusive(manifestPath, JSON.stringify(data, null, 2) + '\n');
88
172
  }
89
173
  /**
90
174
  * Update a single feature field in the manifest. No-op when no manifest exists.
@@ -2,8 +2,8 @@
2
2
  * Strip `teammateMode: "auto"` from a freshly parsed copy of the settings JSON.
3
3
  * Returns the serialised JSON string (with trailing newline).
4
4
  *
5
- * Pure string→string — matches the pipeline pattern used by stripFlags /
6
- * stripViewMode so uninstall.ts can chain it without a separate parse/stringify.
5
+ * Pure string→string — matches the pipeline pattern used by stripFlags so
6
+ * uninstall.ts can chain it without a separate parse/stringify.
7
7
  * Only removes the key when the value is exactly `"auto"`; user-set values
8
8
  * (`"tmux"`, `"in-process"`, etc.) are preserved as-is.
9
9
  *
@@ -1,73 +1,9 @@
1
1
  /**
2
- * ANSI color helpers — no dependencies, precompiled escape sequences.
3
- * Used by HUD components for direct terminal output (not @clack/prompts).
2
+ * ANSI color helpers — re-exported from src/core/ansi.ts.
3
+ *
4
+ * The canonical implementation lives in src/core/ansi.ts (agent-neutral home,
5
+ * applies ADR-013). This file is a re-export barrel so all existing HUD
6
+ * component call sites continue to resolve `../colors.js` without change.
4
7
  */
5
- const ESC = '\x1b[';
6
- const RESET = `${ESC}0m`;
7
- export function bold(s) {
8
- return `${ESC}1m${s}${RESET}`;
9
- }
10
- export function dim(s) {
11
- return `${ESC}2m${s}${RESET}`;
12
- }
13
- export function red(s) {
14
- return `${ESC}31m${s}${RESET}`;
15
- }
16
- export function green(s) {
17
- return `${ESC}32m${s}${RESET}`;
18
- }
19
- export function yellow(s) {
20
- return `${ESC}33m${s}${RESET}`;
21
- }
22
- export function blue(s) {
23
- return `${ESC}34m${s}${RESET}`;
24
- }
25
- export function magenta(s) {
26
- return `${ESC}35m${s}${RESET}`;
27
- }
28
- export function cyan(s) {
29
- return `${ESC}36m${s}${RESET}`;
30
- }
31
- export function gray(s) {
32
- return `${ESC}90m${s}${RESET}`;
33
- }
34
- export function white(s) {
35
- return `${ESC}37m${s}${RESET}`;
36
- }
37
- export function orange(s) {
38
- return `${ESC}38;5;208m${s}${RESET}`;
39
- }
40
- export function brightRed(s) {
41
- return `${ESC}91m${s}${RESET}`;
42
- }
43
- export function boldRed(s) {
44
- return `${ESC}1;31m${s}${RESET}`;
45
- }
46
- export function bgGreen(s) {
47
- return `${ESC}42m${s}${RESET}`;
48
- }
49
- export function bgYellow(s) {
50
- return `${ESC}43m${s}${RESET}`;
51
- }
52
- export function bgRed(s) {
53
- return `${ESC}41m${s}${RESET}`;
54
- }
55
- export function truncate(s, max) {
56
- return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
57
- }
58
- // S2 — Terminal-escape and control-character sanitization (HIGH, pre-existing defect).
59
- //
60
- // The prior pattern (/\x1b\[[0-9;]*m/g) matched only SGR sequences (colour).
61
- // The broadened ANSI_PATTERN also covers:
62
- // CSI sequences — \x1b[ ... with intermediate bytes, any final byte
63
- // OSC sequences — \x1b] ... terminated by BEL (\x07) or ST (\x1b\\)
64
- // Two-byte C1 — \x1b followed by any single character in the C1 range
65
- // CTRL_PATTERN removes non-printable C0 control chars that are not TAB (\x09)
66
- // or standard newlines (\x0a, \x0d). Together they prevent agent names
67
- // embedded in model IDs from injecting escape sequences into --list output.
68
- const ANSI_PATTERN = /\x1b(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
69
- const CTRL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g;
70
- export function stripAnsi(s) {
71
- return s.replace(ANSI_PATTERN, '').replace(CTRL_PATTERN, '');
72
- }
8
+ export { bold, dim, red, green, yellow, blue, magenta, cyan, gray, white, orange, brightRed, boldRed, bgGreen, bgYellow, bgRed, inverse, truncate, stripAnsi, } from '../core/ansi.js';
73
9
  //# sourceMappingURL=colors.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devflow-kit",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A meta-harness for Claude Code — turns a single coding agent into an engineering team: orchestration, parallel review, persistent memory, self-learning, and graph workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "build:mds": "npx tsx scripts/build-mds.ts",
23
23
  "dev": "tsc --watch",
24
24
  "cli": "node dist/cli.js",
25
- "prepublishOnly": "rm -rf dist && npm run build:cli && npm run build:mds",
25
+ "prepublishOnly": "rm -rf dist && npm run build:cli && npm run build:mds && chmod +x dist/cli.js",
26
26
  "version:bump": "npx tsx scripts/bump-version.ts",
27
27
  "test": "vitest run",
28
28
  "test:watch": "vitest",