@hone-ai/cli 1.4.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/bin/hone.js +2 -0
- package/hone-cli.js +4006 -0
- package/lib/README.md +119 -0
- package/lib/adversarial-negative-lint.js +149 -0
- package/lib/audit.js +156 -0
- package/lib/auto-detect.js +213 -0
- package/lib/autofix-guardrails.js +124 -0
- package/lib/branch-protection.js +256 -0
- package/lib/ci-classifier.js +150 -0
- package/lib/ci-failures.js +173 -0
- package/lib/claude-md-tokens.js +71 -0
- package/lib/compliance-check.js +62 -0
- package/lib/config-augment.js +133 -0
- package/lib/config-update.js +70 -0
- package/lib/dependency-audit.js +108 -0
- package/lib/derive-domain.js +185 -0
- package/lib/doc-registry.js +63 -0
- package/lib/doctor-admin-merge.js +185 -0
- package/lib/doctor-bind-default.js +118 -0
- package/lib/doctor-docs.js +205 -0
- package/lib/doctor-placeholders.js +144 -0
- package/lib/doctor-skill-staleness.js +122 -0
- package/lib/domain-skill-template.md +114 -0
- package/lib/editor-detect.js +169 -0
- package/lib/fast-track-ratify.js +133 -0
- package/lib/git-helpers.js +109 -0
- package/lib/hook-templates/pre-commit.sh +54 -0
- package/lib/hook-templates/pre-push.sh +72 -0
- package/lib/install-hooks.js +205 -0
- package/lib/knowledge-graph.js +188 -0
- package/lib/learnings-audit.js +254 -0
- package/lib/learnings-parse.js +331 -0
- package/lib/learnings-sync.js +75 -0
- package/lib/mcp-detect.js +154 -0
- package/lib/metrics-collect.js +214 -0
- package/lib/overlay-merge.js +267 -0
- package/lib/performance-analyzer.js +142 -0
- package/lib/pipeline-config.js +83 -0
- package/lib/pipeline-status.js +207 -0
- package/lib/pipeline-validate.js +322 -0
- package/lib/platform-detect.js +86 -0
- package/lib/platform-discover.js +334 -0
- package/lib/publish-learning.js +160 -0
- package/lib/python-install.js +84 -0
- package/lib/refresh-check.js +67 -0
- package/lib/refresh-knowledge.js +360 -0
- package/lib/rule-resolver.js +146 -0
- package/lib/security-scanner.js +168 -0
- package/lib/setup-grounding.js +138 -0
- package/lib/skill-assertions.js +276 -0
- package/lib/skill-audit-render.js +158 -0
- package/lib/skill-audit.js +391 -0
- package/lib/stack-detect.js +170 -0
- package/lib/stack-paths.js +285 -0
- package/lib/story-classifier-extract.js +203 -0
- package/lib/story-classifier.js +282 -0
- package/lib/sync-overwrite.js +47 -0
- package/lib/synthetic-pipeline.js +299 -0
- package/lib/validate-metadata.js +175 -0
- package/package.json +41 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* learnings-parse.js — H-035 schema-aware parser + write-back for the
|
|
4
|
+
* 3 active learnings YAML schemas. Pure helpers: callers do all I/O.
|
|
5
|
+
*
|
|
6
|
+
* Schemas (verified on hone-server + OptionsFlow develop, 2026-04-28):
|
|
7
|
+
* A) top-level list of items (legacy, OptionsFlow E13-A → E26-A)
|
|
8
|
+
* B) dict with `enterprise_candidates: [...]` (OptionsFlow E25-A onward)
|
|
9
|
+
* C) dict with `learnings: [...]` (hone-server H-001 onward)
|
|
10
|
+
*
|
|
11
|
+
* Without this module, `hone promote` only sees Schema A — Schemas B and C
|
|
12
|
+
* are silently dropped (no error, no warning), leading to invisible promotion
|
|
13
|
+
* data loss. Confirmed today: hone-server's own H-001/H-002a/H-003 learnings
|
|
14
|
+
* are 100% invisible to `hone promote` on develop@a549651.
|
|
15
|
+
*
|
|
16
|
+
* H-035 is the COMPATIBILITY SHIM that buys time for E29-H schema v2 to ship
|
|
17
|
+
* cleanly without breaking any production adopter. See
|
|
18
|
+
* docs/sdlc/PIPELINE_BACKLOG.md Phase 5 for the upstream lift plan.
|
|
19
|
+
*
|
|
20
|
+
* Closes #59 (H-035). Pairs with Phase 1.{2,3,4} of the backlog.
|
|
21
|
+
*
|
|
22
|
+
* Exports:
|
|
23
|
+
* - SCHEMA_A, SCHEMA_B, SCHEMA_C, SCHEMA_UNKNOWN : string constants
|
|
24
|
+
* - detectSchema(parsed) → 'A' | 'B' | 'C' | 'unknown'
|
|
25
|
+
* - parseLearningsFile(content, opts?) → { schema, eligible, warning?, parseError? }
|
|
26
|
+
* - writeBackPromoted(content, schema, idsPromoted) → newContent
|
|
27
|
+
*
|
|
28
|
+
* - parseSchemaA / parseSchemaB / parseSchemaC (exported for unit tests)
|
|
29
|
+
* - writeBackA / writeBackB / writeBackC (exported for unit tests)
|
|
30
|
+
*/
|
|
31
|
+
const yaml = require('js-yaml');
|
|
32
|
+
|
|
33
|
+
const SCHEMA_A = 'A';
|
|
34
|
+
const SCHEMA_B = 'B';
|
|
35
|
+
const SCHEMA_C = 'C';
|
|
36
|
+
const SCHEMA_UNKNOWN = 'unknown';
|
|
37
|
+
|
|
38
|
+
// LC-001 / #58 G2 partial: optional categorization. Per architect plan for
|
|
39
|
+
// #58: schema extension only — auto-classifier deferred until OptionsFlow
|
|
40
|
+
// E29-H worked examples land. All 3 categories are advisory; tooling
|
|
41
|
+
// (audit-skills, audit-learnings) reads them when present.
|
|
42
|
+
const LEARNING_CATEGORIES = Object.freeze({
|
|
43
|
+
APPLIED_EXISTING_RULE: 'applied-existing-rule', // compliance evidence; reinforces an existing skill
|
|
44
|
+
DISCOVERED_NEW_PATTERN: 'discovered-new-pattern', // promotion candidate; novel insight
|
|
45
|
+
EXCEPTION_WITH_RATIONALE: 'exception-with-rationale', // documented deviation from a rule
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const VALID_CATEGORY_VALUES = Object.values(LEARNING_CATEGORIES);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* LC-001: validate optional category field. Returns the value if recognized,
|
|
52
|
+
* null if absent, throws if malformed.
|
|
53
|
+
*/
|
|
54
|
+
function validateCategory(value) {
|
|
55
|
+
if (value === null || value === undefined || value === '') return null;
|
|
56
|
+
if (typeof value !== 'string') {
|
|
57
|
+
throw new Error(`learning.category must be a string, got ${typeof value}`);
|
|
58
|
+
}
|
|
59
|
+
if (!VALID_CATEGORY_VALUES.includes(value)) {
|
|
60
|
+
throw new Error(`learning.category must be one of [${VALID_CATEGORY_VALUES.join(', ')}], got "${value}"`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
66
|
+
// detectSchema — reads only the top-level structure
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
68
|
+
function detectSchema(parsed) {
|
|
69
|
+
if (parsed === null || parsed === undefined) return SCHEMA_UNKNOWN;
|
|
70
|
+
if (Array.isArray(parsed)) return SCHEMA_A;
|
|
71
|
+
if (typeof parsed !== 'object') return SCHEMA_UNKNOWN;
|
|
72
|
+
// Schema C wins on collision (both arrays present); caller emits warning.
|
|
73
|
+
if (Array.isArray(parsed.learnings)) return SCHEMA_C;
|
|
74
|
+
if (Array.isArray(parsed.enterprise_candidates)) return SCHEMA_B;
|
|
75
|
+
return SCHEMA_UNKNOWN;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
79
|
+
// parseLearningsFile — entry point for the CLI shell
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
81
|
+
//
|
|
82
|
+
// Returns:
|
|
83
|
+
// { schema: 'A' | 'B' | 'C' | 'unknown',
|
|
84
|
+
// eligible: NormalizedLearning[],
|
|
85
|
+
// warning?: string, // present on collision
|
|
86
|
+
// parseError?: Error } // present if yaml.load threw
|
|
87
|
+
//
|
|
88
|
+
// NormalizedLearning shape (matches what hone-cli.js's downstream code
|
|
89
|
+
// already expects — caller fills _file / _filePath / repo_name):
|
|
90
|
+
//
|
|
91
|
+
// { id, story, enterprise_summary, skill_update, status,
|
|
92
|
+
// enterprise_candidate, _schema, _rawItemId? }
|
|
93
|
+
//
|
|
94
|
+
function parseLearningsFile(content, opts = {}) {
|
|
95
|
+
const fileLabel = opts.fileLabel || '<unknown>';
|
|
96
|
+
const includePromoted = opts.includePromoted || false;
|
|
97
|
+
let parsed;
|
|
98
|
+
try { parsed = yaml.load(content); }
|
|
99
|
+
catch (e) { return { schema: SCHEMA_UNKNOWN, eligible: [], parseError: e }; }
|
|
100
|
+
|
|
101
|
+
const schema = detectSchema(parsed);
|
|
102
|
+
if (schema === SCHEMA_UNKNOWN) return { schema, eligible: [] };
|
|
103
|
+
|
|
104
|
+
// Collision: dict with BOTH arrays. C wins, warn the caller.
|
|
105
|
+
let warning;
|
|
106
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
107
|
+
&& Array.isArray(parsed.learnings)
|
|
108
|
+
&& Array.isArray(parsed.enterprise_candidates)) {
|
|
109
|
+
warning = `${fileLabel}: both 'learnings' and 'enterprise_candidates' arrays present — using 'learnings' (Schema C); ignoring 'enterprise_candidates'.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// LC-001: validateCategory may throw on malformed enum value. Catch and
|
|
113
|
+
// surface as parseError rather than crashing the whole file walk.
|
|
114
|
+
try {
|
|
115
|
+
if (schema === SCHEMA_A) return { schema, eligible: parseSchemaA(parsed, includePromoted), warning };
|
|
116
|
+
if (schema === SCHEMA_B) return { schema, eligible: parseSchemaB(parsed, includePromoted), warning };
|
|
117
|
+
if (schema === SCHEMA_C) return { schema, eligible: parseSchemaC(parsed, includePromoted), warning };
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return { schema, eligible: [], warning, parseError: e };
|
|
120
|
+
}
|
|
121
|
+
return { schema, eligible: [], warning };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
125
|
+
// Schema A — legacy list of items
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
127
|
+
function parseSchemaA(items, includePromoted = false) {
|
|
128
|
+
return items
|
|
129
|
+
.filter(i => i && i.enterprise_candidate === true && (includePromoted || i.status === 'pending'))
|
|
130
|
+
.filter(i => i.enterprise_summary || i.summary)
|
|
131
|
+
.map(i => ({
|
|
132
|
+
id: i.id,
|
|
133
|
+
story: i.story,
|
|
134
|
+
enterprise_summary: i.enterprise_summary || i.summary,
|
|
135
|
+
skill_update: i.skill_update || null,
|
|
136
|
+
status: 'pending',
|
|
137
|
+
enterprise_candidate: true,
|
|
138
|
+
_schema: SCHEMA_A,
|
|
139
|
+
// LC-001: optional categorization fields (G2 partial). All optional;
|
|
140
|
+
// default null. validateCategory throws on malformed enum.
|
|
141
|
+
category: validateCategory(i.category),
|
|
142
|
+
referenced_skill: i.referenced_skill || null,
|
|
143
|
+
referenced_section: i.referenced_section || null,
|
|
144
|
+
evidence: Array.isArray(i.evidence) ? i.evidence : (i.evidence ? [i.evidence] : null),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
149
|
+
// Schema B — dict with enterprise_candidates
|
|
150
|
+
// Eligibility: candidate_for non-null/undefined, status implicit 'pending'
|
|
151
|
+
// (or explicit pending), non-empty `learning` body. Synthesizes id as
|
|
152
|
+
// `${story_id}-${entry.id}` to match Schema A/C id-shape.
|
|
153
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
154
|
+
function parseSchemaB(doc, includePromoted = false) {
|
|
155
|
+
const storyId = doc.story_id;
|
|
156
|
+
return (doc.enterprise_candidates || [])
|
|
157
|
+
.filter(c => c && c.candidate_for !== null && c.candidate_for !== undefined)
|
|
158
|
+
.filter(c => includePromoted || (c.status || 'pending') === 'pending')
|
|
159
|
+
.filter(c => c.learning && String(c.learning).trim().length > 0)
|
|
160
|
+
.map(c => ({
|
|
161
|
+
id: `${storyId}-${c.id}`,
|
|
162
|
+
story: storyId,
|
|
163
|
+
enterprise_summary: c.learning,
|
|
164
|
+
skill_update: c.candidate_for,
|
|
165
|
+
status: 'pending',
|
|
166
|
+
enterprise_candidate: true,
|
|
167
|
+
_schema: SCHEMA_B,
|
|
168
|
+
_rawItemId: c.id,
|
|
169
|
+
// LC-001: optional categorization fields. Schema B's pre-existing
|
|
170
|
+
// `candidate_for` already serves the role of referenced_skill, so
|
|
171
|
+
// we fall back to it when the explicit field is absent.
|
|
172
|
+
category: validateCategory(c.category),
|
|
173
|
+
referenced_skill: c.referenced_skill || c.candidate_for || null,
|
|
174
|
+
referenced_section: c.referenced_section || null,
|
|
175
|
+
evidence: Array.isArray(c.evidence) ? c.evidence : (c.evidence ? [c.evidence] : null),
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
180
|
+
// Schema C — dict with learnings
|
|
181
|
+
// Eligibility: per-item enterprise_candidate=true; status defaults to
|
|
182
|
+
// top-level when per-item missing (so a story-level promoted/retracted
|
|
183
|
+
// status skips the whole file). enterprise_summary is required —
|
|
184
|
+
// local_summary is NEVER promoted (HONE-002 contract).
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
186
|
+
function parseSchemaC(doc, includePromoted = false) {
|
|
187
|
+
const storyId = doc.story_id;
|
|
188
|
+
const topStatus = doc.status || 'pending';
|
|
189
|
+
return (doc.learnings || [])
|
|
190
|
+
.filter(l => l && l.enterprise_candidate === true)
|
|
191
|
+
.filter(l => includePromoted || (l.status || topStatus) === 'pending')
|
|
192
|
+
.filter(l => l.enterprise_summary && String(l.enterprise_summary).trim().length > 0)
|
|
193
|
+
.map(l => ({
|
|
194
|
+
id: l.id,
|
|
195
|
+
story: storyId,
|
|
196
|
+
enterprise_summary: l.enterprise_summary,
|
|
197
|
+
skill_update: null, // Schema C doesn't use skill_update yet
|
|
198
|
+
status: 'pending',
|
|
199
|
+
enterprise_candidate: true,
|
|
200
|
+
_schema: SCHEMA_C,
|
|
201
|
+
// LC-001: optional categorization fields (G2 partial).
|
|
202
|
+
category: validateCategory(l.category),
|
|
203
|
+
referenced_skill: l.referenced_skill || null,
|
|
204
|
+
referenced_section: l.referenced_section || null,
|
|
205
|
+
evidence: Array.isArray(l.evidence) ? l.evidence : (l.evidence ? [l.evidence] : null),
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
210
|
+
// writeBackPromoted — schema-aware idempotent regex rewrite
|
|
211
|
+
//
|
|
212
|
+
// All 3 schemas write per-item `status: promoted` because `hone retract`
|
|
213
|
+
// at cli/hone-cli.js:1349 reads `entry?.status === 'retracted'` per-item.
|
|
214
|
+
// Replacing per-item status with a top-level marker would break retract.
|
|
215
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
216
|
+
function writeBackPromoted(content, schema, idsPromoted) {
|
|
217
|
+
if (!idsPromoted || idsPromoted.length === 0) return content;
|
|
218
|
+
if (schema === SCHEMA_A) return writeBackA(content, idsPromoted);
|
|
219
|
+
if (schema === SCHEMA_B) return writeBackB(content, idsPromoted);
|
|
220
|
+
if (schema === SCHEMA_C) return writeBackC(content, idsPromoted);
|
|
221
|
+
return content;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Escape special regex characters from a YAML id (e.g. "H-001-L1").
|
|
225
|
+
function escapeRegex(s) {
|
|
226
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Schema A: per-item id-anchored regex replaces `pending` → `promoted`
|
|
230
|
+
// next to each id. Mirrors the original cli/hone-cli.js:1295 behavior
|
|
231
|
+
// exactly — no whitespace drift, byte-identical for adopters who haven't
|
|
232
|
+
// migrated.
|
|
233
|
+
function writeBackA(content, ids) {
|
|
234
|
+
for (const id of ids) {
|
|
235
|
+
const escaped = escapeRegex(id);
|
|
236
|
+
const idPattern = new RegExp(`(id:\\s*${escaped}[\\s\\S]*?status:\\s*)pending`, 'm');
|
|
237
|
+
if (idPattern.test(content)) {
|
|
238
|
+
content = content.replace(idPattern, '$1promoted');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return content;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Schema B: ids look like "STORY-N" where N is the raw numeric per-item id.
|
|
245
|
+
// Insert ` status: promoted` directly after the matched `- id: <N>` line.
|
|
246
|
+
// Idempotent: skip insertion if any `status:` line already exists in the
|
|
247
|
+
// item's block (between this id line and the next sibling id line).
|
|
248
|
+
//
|
|
249
|
+
// Line-by-line implementation (avoiding regex block-matching with \z, which
|
|
250
|
+
// JS regex does not support — would silently match `z` literal instead).
|
|
251
|
+
function writeBackB(content, idsPromoted) {
|
|
252
|
+
// Strip the story prefix to get the raw numeric ids: "TEST-B-1" → "1".
|
|
253
|
+
const rawIds = new Set();
|
|
254
|
+
for (const id of idsPromoted) {
|
|
255
|
+
const m = String(id).match(/^.*-(\d+)$/);
|
|
256
|
+
if (m) rawIds.add(m[1]);
|
|
257
|
+
}
|
|
258
|
+
if (rawIds.size === 0) return content;
|
|
259
|
+
|
|
260
|
+
return rewriteWithStatusInsertion(content, (line) => {
|
|
261
|
+
const m = line.match(/^( {2,})- id:\s*(\d+)\s*$/);
|
|
262
|
+
if (m && rawIds.has(m[2])) return { matched: true, indent: ' ' };
|
|
263
|
+
return { matched: false };
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Schema C: per-item id-anchored, scoped to entries under `learnings:`.
|
|
268
|
+
// Insert ` status: promoted` directly after the matched `- id: <id>`
|
|
269
|
+
// line. Idempotent: skip if any `status:` already exists in the item's
|
|
270
|
+
// block. Top-level `status:` MUST NOT be touched — it's the story-level
|
|
271
|
+
// status, separate from per-learning status.
|
|
272
|
+
function writeBackC(content, ids) {
|
|
273
|
+
const idSet = new Set(ids.map(String));
|
|
274
|
+
if (idSet.size === 0) return content;
|
|
275
|
+
|
|
276
|
+
return rewriteWithStatusInsertion(content, (line) => {
|
|
277
|
+
const m = line.match(/^( {2,})- id:\s*(\S+)\s*$/);
|
|
278
|
+
if (m && idSet.has(m[2])) return { matched: true, indent: ' ' };
|
|
279
|
+
return { matched: false };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Shared line-by-line worker for B + C. For each line that the matcher
|
|
284
|
+
// flags, look ahead until the next sibling ` - id:` line (or end of file)
|
|
285
|
+
// and check if any `status:` already exists there. If not, insert
|
|
286
|
+
// `<indent>status: promoted` directly after the id line. Idempotent.
|
|
287
|
+
function rewriteWithStatusInsertion(content, matcher) {
|
|
288
|
+
const lines = content.split('\n');
|
|
289
|
+
const out = [];
|
|
290
|
+
for (let i = 0; i < lines.length; i++) {
|
|
291
|
+
const line = lines[i];
|
|
292
|
+
out.push(line);
|
|
293
|
+
const r = matcher(line);
|
|
294
|
+
if (!r.matched) continue;
|
|
295
|
+
|
|
296
|
+
// Look ahead through this item's block to detect existing status.
|
|
297
|
+
let hasStatus = false;
|
|
298
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
299
|
+
const next = lines[j];
|
|
300
|
+
// End of block: next sibling id line at the same-or-lesser list indent.
|
|
301
|
+
if (/^ {2,}- id:/.test(next)) break;
|
|
302
|
+
// End of block: a non-indented (top-level) line means we've left the array.
|
|
303
|
+
if (/^\S/.test(next)) break;
|
|
304
|
+
if (/^ {4,}status:\s*\S/.test(next)) { hasStatus = true; break; }
|
|
305
|
+
}
|
|
306
|
+
if (!hasStatus) out.push(`${r.indent}status: promoted`);
|
|
307
|
+
}
|
|
308
|
+
return out.join('\n');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
SCHEMA_A,
|
|
313
|
+
SCHEMA_B,
|
|
314
|
+
SCHEMA_C,
|
|
315
|
+
SCHEMA_UNKNOWN,
|
|
316
|
+
detectSchema,
|
|
317
|
+
parseLearningsFile,
|
|
318
|
+
writeBackPromoted,
|
|
319
|
+
// LC-001 / #58 G2 partial: categorization API (advisory).
|
|
320
|
+
LEARNING_CATEGORIES,
|
|
321
|
+
VALID_CATEGORY_VALUES,
|
|
322
|
+
validateCategory,
|
|
323
|
+
// Exported for unit tests + future extension; not part of the stable
|
|
324
|
+
// public surface of the helper.
|
|
325
|
+
parseSchemaA,
|
|
326
|
+
parseSchemaB,
|
|
327
|
+
parseSchemaC,
|
|
328
|
+
writeBackA,
|
|
329
|
+
writeBackB,
|
|
330
|
+
writeBackC,
|
|
331
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* learnings-sync.js — H-013 round-trip on promoted learnings.
|
|
4
|
+
*
|
|
5
|
+
* Pure helpers — no I/O. Caller (hone-cli.js sync command) reads the
|
|
6
|
+
* server response + writes back to local YAML files.
|
|
7
|
+
*
|
|
8
|
+
* Closes #22 client-side. Server endpoint
|
|
9
|
+
* `GET /promoted-learnings-report?repo=X` deferred to a follow-up
|
|
10
|
+
* (Hone-team operational concern).
|
|
11
|
+
*
|
|
12
|
+
* 9th instance of pure-helpers + thin-CLI-shell pattern in cli/lib/
|
|
13
|
+
* after H-018, H-035, H-009, H-008, H-021, H-061, H-010, H-015.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
17
|
+
// parseReportMarkdown — parse 4-column table rows
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Expected row shape: `| <id> | <summary> | <status> | <last-updated> |`
|
|
20
|
+
// Skips header row (cell text matches "ID" / "Summary" / "Status" /
|
|
21
|
+
// "Last updated") and separator row (cells are `---`).
|
|
22
|
+
// Returns [{id, summary, status, lastUpdated}]; [] on empty/unparseable.
|
|
23
|
+
function parseReportMarkdown(text) {
|
|
24
|
+
if (typeof text !== 'string' || text.length === 0) return [];
|
|
25
|
+
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const rawLine of text.split('\n')) {
|
|
28
|
+
const line = rawLine.trim();
|
|
29
|
+
// Must look like a table row: starts and ends with `|`, has ≥4 cells
|
|
30
|
+
if (!line.startsWith('|') || !line.endsWith('|')) continue;
|
|
31
|
+
const cells = line.slice(1, -1).split('|').map(c => c.trim());
|
|
32
|
+
if (cells.length < 4) continue;
|
|
33
|
+
|
|
34
|
+
// Skip separator rows (cells are dashes)
|
|
35
|
+
if (cells.every(c => /^[-:\s]+$/.test(c))) continue;
|
|
36
|
+
|
|
37
|
+
// Skip header row (heuristic: first cell is "ID" or contains "ID")
|
|
38
|
+
if (/^id$/i.test(cells[0])) continue;
|
|
39
|
+
|
|
40
|
+
out.push({
|
|
41
|
+
id: cells[0],
|
|
42
|
+
summary: cells[1],
|
|
43
|
+
status: cells[2].toLowerCase(),
|
|
44
|
+
lastUpdated: cells[3],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
51
|
+
// updateTopLevelStatus — surgical regex on TOP-LEVEL `status:` only
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
53
|
+
// Top-level = the `status:` line at column 0 (no leading whitespace).
|
|
54
|
+
// Per-item `status:` lines (indented under `learnings:`) are NOT touched.
|
|
55
|
+
// Returns {text, updated}:
|
|
56
|
+
// - updated=true if the top-level line was replaced
|
|
57
|
+
// - updated=false if no top-level `status:` exists (graceful)
|
|
58
|
+
function updateTopLevelStatus(yamlText, newStatus) {
|
|
59
|
+
if (typeof yamlText !== 'string' || !newStatus) {
|
|
60
|
+
return { text: yamlText, updated: false };
|
|
61
|
+
}
|
|
62
|
+
// Match `status:` at start of line (no leading whitespace) — top-level only.
|
|
63
|
+
// The /m flag makes ^ match line starts; /\S/ guard ensures we capture the
|
|
64
|
+
// existing line even if it has trailing comment.
|
|
65
|
+
const re = /^status:\s*[^\n]*$/m;
|
|
66
|
+
if (!re.test(yamlText)) {
|
|
67
|
+
return { text: yamlText, updated: false };
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
text: yamlText.replace(re, `status: ${newStatus}`),
|
|
71
|
+
updated: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { parseReportMarkdown, updateTopLevelStatus };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* mcp-detect.js — MCP server availability detection (HC-012).
|
|
4
|
+
*
|
|
5
|
+
* Pure helper with injected exec (same DI pattern as platform-detect.js).
|
|
6
|
+
* Checks whether an MCP server is available for the detected platform.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY BOUNDARY:
|
|
9
|
+
* - NEVER reads, stores, or transmits credentials
|
|
10
|
+
* - Auth detection is observational: "does auth exist?" (yes/no)
|
|
11
|
+
* - Output contains auth_method (HOW to auth) and auth_status
|
|
12
|
+
* (authenticated/not/unknown), NEVER the credential itself
|
|
13
|
+
* - Classifier: full-sdlc (security-implications + first-of-its-kind)
|
|
14
|
+
*
|
|
15
|
+
* Architecture: docs/architecture/platform-auto-discovery-v1.md (Tier 3)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ── Safe exec wrapper ──────────────────────────────────────────
|
|
19
|
+
function safeExec(exec, cmd) {
|
|
20
|
+
try {
|
|
21
|
+
const result = exec(cmd);
|
|
22
|
+
return {
|
|
23
|
+
stdout: String(result.stdout || '').trim(),
|
|
24
|
+
exitCode: typeof result.exitCode === 'number' ? result.exitCode : 1,
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return { stdout: '', exitCode: 1 };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Empty result ───────────────────────────────────────────────
|
|
32
|
+
function emptyResult() {
|
|
33
|
+
return {
|
|
34
|
+
available: false,
|
|
35
|
+
server: null,
|
|
36
|
+
auth_method: null,
|
|
37
|
+
auth_status: 'unknown',
|
|
38
|
+
capabilities: [],
|
|
39
|
+
install_hint: null,
|
|
40
|
+
warnings: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Salesforce MCP Detection ───────────────────────────────────
|
|
45
|
+
function detectSalesforceMCP(exec) {
|
|
46
|
+
const result = emptyResult();
|
|
47
|
+
|
|
48
|
+
// 1. Check sf CLI installed
|
|
49
|
+
const sfVersion = safeExec(exec, 'sf --version');
|
|
50
|
+
if (sfVersion.exitCode !== 0) {
|
|
51
|
+
result.warnings.push('sf CLI not installed — MCP requires Salesforce CLI');
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Check @salesforce/mcp installed
|
|
56
|
+
const mcpCheck = safeExec(exec, 'npm ls -g @salesforce/mcp');
|
|
57
|
+
const mcpInstalled = mcpCheck.exitCode === 0 && mcpCheck.stdout.includes('@salesforce/mcp');
|
|
58
|
+
|
|
59
|
+
if (mcpInstalled) {
|
|
60
|
+
result.server = '@salesforce/mcp';
|
|
61
|
+
} else {
|
|
62
|
+
result.install_hint = 'npm install -g @salesforce/mcp';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Check auth status (observational — NEVER extract tokens)
|
|
66
|
+
const authCheck = safeExec(exec, 'sf auth:list --json');
|
|
67
|
+
if (authCheck.exitCode === 0) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(authCheck.stdout);
|
|
70
|
+
const orgs = Array.isArray(parsed.result) ? parsed.result : [];
|
|
71
|
+
if (orgs.length > 0) {
|
|
72
|
+
result.auth_method = 'sf-cli';
|
|
73
|
+
result.auth_status = 'authenticated';
|
|
74
|
+
} else {
|
|
75
|
+
result.auth_status = 'not_authenticated';
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
result.auth_status = 'unknown';
|
|
79
|
+
result.warnings.push('sf auth:list returned non-JSON output');
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
result.auth_status = 'unknown';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 4. Compose availability: need all three
|
|
86
|
+
result.available = mcpInstalled && result.auth_status === 'authenticated';
|
|
87
|
+
|
|
88
|
+
if (result.available) {
|
|
89
|
+
result.capabilities = ['metadata', 'soql', 'schema', 'apex-test'];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── NetSuite MCP Detection ─────────────────────────────────────
|
|
96
|
+
function detectNetSuiteMCP(exec) {
|
|
97
|
+
const result = emptyResult();
|
|
98
|
+
|
|
99
|
+
// 1. Check community MCP server (global or local)
|
|
100
|
+
const globalCheck = safeExec(exec, 'npm ls -g netsuite-mcp-server');
|
|
101
|
+
const globalInstalled = globalCheck.exitCode === 0 && globalCheck.stdout.includes('netsuite-mcp-server');
|
|
102
|
+
|
|
103
|
+
let localInstalled = false;
|
|
104
|
+
if (!globalInstalled) {
|
|
105
|
+
const localCheck = safeExec(exec, 'npm ls netsuite-mcp-server');
|
|
106
|
+
localInstalled = localCheck.exitCode === 0 && localCheck.stdout.includes('netsuite-mcp-server');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (globalInstalled || localInstalled) {
|
|
110
|
+
result.server = 'netsuite-mcp-server';
|
|
111
|
+
result.available = true;
|
|
112
|
+
result.capabilities = ['suiteql', 'records', 'restlet'];
|
|
113
|
+
result.auth_method = 'env-var';
|
|
114
|
+
result.auth_status = 'unknown'; // can't verify without connecting
|
|
115
|
+
} else {
|
|
116
|
+
result.install_hint = 'npm install -g netsuite-mcp-server (community)';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Main Entry Point ───────────────────────────────────────────
|
|
123
|
+
/**
|
|
124
|
+
* Detect MCP server availability for a platform.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} opts
|
|
127
|
+
* @param {string} opts.platform - 'salesforce' | 'netsuite'
|
|
128
|
+
* @param {string} opts.repoRoot - reserved
|
|
129
|
+
* @param {(cmd: string) => {stdout: string, exitCode: number}} opts.exec - injected shell
|
|
130
|
+
* @returns {{ available, server, auth_method, auth_status, capabilities, install_hint, warnings }}
|
|
131
|
+
*/
|
|
132
|
+
function detectMCPServer(opts = {}) {
|
|
133
|
+
const { platform, exec } = opts;
|
|
134
|
+
|
|
135
|
+
if (typeof exec !== 'function') {
|
|
136
|
+
const result = emptyResult();
|
|
137
|
+
result.warnings.push('exec callback not provided');
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
switch (platform) {
|
|
143
|
+
case 'salesforce': return detectSalesforceMCP(exec);
|
|
144
|
+
case 'netsuite': return detectNetSuiteMCP(exec);
|
|
145
|
+
default: return emptyResult();
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
const result = emptyResult();
|
|
149
|
+
result.warnings.push(`MCP detection error: ${e.message}`);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { detectMCPServer };
|