archgraph-argo 0.13.5 → 0.13.7

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.
@@ -0,0 +1,604 @@
1
+ 'use strict';
2
+
3
+ // WP2792 (AT-2792-01..06): EA human-draft -> semantic diff -> agent write-back.
4
+ // Compares the EA *visible object model* of two .qea snapshots (baseline committed
5
+ // .qea vs human-edited working .qea) and classifies the human's changes into
6
+ // canonical-graph proposals (addElement/updateElement/removeElement,
7
+ // addRelationship/updateRelationship/removeRelationship, updateView membership).
8
+ //
9
+ // Why the visible object model and NOT kg_sync_meta:
10
+ // kg_sync_meta is a lossless canonical mirror written only by the projector; a human
11
+ // editing in EA touches t_object/t_connector/t_diagram/t_diagramobjects (plus the
12
+ // schema_id / archimate_type anchor tags). Comparing mirrors would show zero diff for
13
+ // human edits. So we anchor via the schema_id tag (elements: t_objectproperties,
14
+ // relationships: t_connectortag; views: t_diagram schema_view_id StyleEx token OR the
15
+ // deterministic diag:<viewId> ea_guid written by the projection).
16
+ //
17
+ // Semantic-first (v1): pure geometry (t_diagramobjects coordinates) never becomes a
18
+ // canonical proposal — it is only counted (layoutOnly) and excluded.
19
+ //
20
+ // Zero third-party deps, no EA required: node:sqlite via the shared lib helper.
21
+ // node argo/scripts/ea-human-diff.js --base <base.qea> --work <work.qea> [--graph <json>]
22
+ // [--out <stem>] [--no-md] [--baseline-commit <sha>]
23
+ // --base is optional: when omitted and --work is a tracked file inside the git repo,
24
+ // the committed (HEAD) version of --work is extracted automatically as the baseline —
25
+ // the day-to-day "human edited archgraph.qea" flow is then a single command.
26
+
27
+ const path = require('node:path');
28
+ const fs = require('node:fs');
29
+ const lib = require('./ea-qea-sync-lib.js');
30
+ const { deterministicGuid } = lib;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // readSnapshot — read one .qea's semantic EA-visible projection surface
34
+ // ---------------------------------------------------------------------------
35
+ function parseStyleToken(styleEx, key) {
36
+ const text = String(styleEx === null || styleEx === undefined ? '' : styleEx);
37
+ const re = new RegExp('(^|;|\\s)' + key + '=([^;]*)', 'i');
38
+ const m = re.exec(text);
39
+ return m ? m[2] : '';
40
+ }
41
+
42
+ function readSnapshot(qeaPath, opts) {
43
+ const o = opts || {};
44
+ const db = lib.openQea(qeaPath);
45
+ try {
46
+ const snapshot = {
47
+ path: qeaPath,
48
+ syncPackageId: 0,
49
+ // viewId -> { diagramId, name }
50
+ diagramsByView: new Map(),
51
+ // diagramId -> viewId
52
+ viewByDiagram: new Map(),
53
+ // canonical id -> element (anchored via schema_id tag)
54
+ elementBySchema: new Map(),
55
+ // eaGuid -> element (all t_object)
56
+ elementByGuid: new Map(),
57
+ // canonical id -> relationship (anchored via t_connectortag schema_id)
58
+ relBySchema: new Map(),
59
+ // eaGuid -> relationship (all t_connector)
60
+ relByGuid: new Map(),
61
+ // diagramId -> Map(objectId -> coords)
62
+ placements: new Map(),
63
+ // informational: kg_sync_meta counts/shas (never used for proposal detection)
64
+ meta: { elements: 0, relationships: 0, views: 0, sha: '' },
65
+ knownViewIds: null, // set by resolveViews when a canonical view catalog is provided
66
+ };
67
+
68
+ // kg_sync_meta informational snapshot (human edits never touch it -> identical).
69
+ try {
70
+ const rows = db.prepare('SELECT kind, key, sha FROM kg_sync_meta ORDER BY kind, key').all();
71
+ snapshot.meta.elements = rows.filter((r) => r.kind === 'element').length;
72
+ snapshot.meta.relationships = rows.filter((r) => r.kind === 'relationship').length;
73
+ snapshot.meta.views = rows.filter((r) => r.kind === 'view').length;
74
+ const hasher = require('node:crypto').createHash('sha256');
75
+ for (const r of rows) { hasher.update(r.kind + '|' + r.key + '|' + r.sha); }
76
+ snapshot.meta.sha = hasher.digest('hex');
77
+ } catch { /* table may be absent on a hand-drawn model */ }
78
+
79
+ // sync package id
80
+ const roots = db.prepare('SELECT Package_ID FROM t_package WHERE Parent_ID=0 ORDER BY Package_ID LIMIT 1').all();
81
+ if (roots.length > 0) {
82
+ const pkg = db.prepare('SELECT Package_ID FROM t_package WHERE Parent_ID=? AND Name=? LIMIT 1').get(Number(roots[0].Package_ID), lib.SYNC_PACKAGE_NAME);
83
+ if (pkg) { snapshot.syncPackageId = Number(pkg.Package_ID); }
84
+ }
85
+
86
+ // diagrams -> view mapping (schema_view_id StyleEx token)
87
+ const diags = db.prepare('SELECT Diagram_ID, Package_ID, Name, StyleEx, ea_guid FROM t_diagram').all();
88
+ for (const d of diags) {
89
+ const v = parseStyleToken(d.StyleEx, 'schema_view_id');
90
+ if (v) {
91
+ const diagramId = Number(d.Diagram_ID);
92
+ snapshot.diagramsByView.set(v, { diagramId, name: String(d.Name || ''), eaGuid: String(d.ea_guid || '') });
93
+ snapshot.viewByDiagram.set(diagramId, v);
94
+ }
95
+ }
96
+ // Optionally also bind known view ids via deterministic diagram guid (robust when EA
97
+ // dropped the StyleEx token). Canonical view catalog supplied via --graph.
98
+ if (o.knownViewIds && Array.isArray(o.knownViewIds)) {
99
+ snapshot.knownViewIds = o.knownViewIds;
100
+ for (const v of o.knownViewIds) {
101
+ if (snapshot.diagramsByView.has(v)) { continue; }
102
+ const guid = deterministicGuid('diag:' + v);
103
+ const d = db.prepare('SELECT Diagram_ID, Name FROM t_diagram WHERE ea_guid = ? LIMIT 1').get(guid);
104
+ if (d) {
105
+ const diagramId = Number(d.Diagram_ID);
106
+ snapshot.diagramsByView.set(v, { diagramId, name: String(d.Name || ''), eaGuid: guid });
107
+ snapshot.viewByDiagram.set(diagramId, v);
108
+ }
109
+ }
110
+ }
111
+
112
+ // element anchor tags
113
+ const elemTags = new Map(); // Object_ID -> {schemaId?, archimateType?}
114
+ try {
115
+ const props = db.prepare("SELECT Object_ID, Property, Value FROM t_objectproperties WHERE Property IN ('schema_id','archimate_type')").all();
116
+ for (const p of props) {
117
+ const oid = Number(p.Object_ID);
118
+ if (!elemTags.has(oid)) { elemTags.set(oid, {}); }
119
+ const t = elemTags.get(oid);
120
+ if (p.Property === 'schema_id') { t.schemaId = String(p.Value); }
121
+ if (p.Property === 'archimate_type') { t.archimateType = String(p.Value); }
122
+ }
123
+ } catch { /* ignore */ }
124
+
125
+ const elems = db.prepare(
126
+ 'SELECT Object_ID, Alias, ea_guid, Object_Type, Stereotype, Name, Note, Status, Package_ID FROM t_object'
127
+ ).all();
128
+ for (const e of elems) {
129
+ const rec = {
130
+ objectId: Number(e.Object_ID),
131
+ alias: e.Alias === null || e.Alias === undefined ? '' : String(e.Alias),
132
+ eaGuid: String(e.ea_guid || ''),
133
+ objectType: String(e.Object_Type || ''),
134
+ stereotype: String(e.Stereotype || ''),
135
+ name: String(e.Name || ''),
136
+ description: String(e.Note === null || e.Note === undefined ? '' : e.Note),
137
+ status: String(e.Status || ''),
138
+ packageId: Number(e.Package_ID || 0),
139
+ };
140
+ const tag = elemTags.get(rec.objectId);
141
+ if (tag && tag.schemaId) {
142
+ rec.schemaId = tag.schemaId;
143
+ rec.archimateType = tag.archimateType || '';
144
+ snapshot.elementBySchema.set(rec.schemaId, rec);
145
+ }
146
+ if (rec.eaGuid) { snapshot.elementByGuid.set(rec.eaGuid, rec); }
147
+ }
148
+
149
+ // relationship anchor tags — t_connectortag's value column is literally named VALUE,
150
+ // so alias it to Value for a case-stable row key (unlike t_objectproperties.Value).
151
+ const relTags = new Map(); // Connector_ID -> schemaId
152
+ try {
153
+ const props = db.prepare("SELECT ElementID, Property, VALUE AS Value FROM t_connectortag WHERE Property IN ('schema_id','archimate_relationship_type')").all();
154
+ for (const p of props) {
155
+ const cid = Number(p.ElementID);
156
+ if (!relTags.has(cid)) { relTags.set(cid, {}); }
157
+ const t = relTags.get(cid);
158
+ if (p.Property === 'schema_id') { t.schemaId = String(p.Value); }
159
+ if (p.Property === 'archimate_relationship_type') { t.archimateType = String(p.Value); }
160
+ }
161
+ } catch { /* ignore */ }
162
+
163
+ const conns = db.prepare(
164
+ 'SELECT Connector_ID, ea_guid, Name, Connector_Type, Stereotype, Notes, Direction, Start_Object_ID, End_Object_ID FROM t_connector'
165
+ ).all();
166
+ for (const c of conns) {
167
+ const rec = {
168
+ connectorId: Number(c.Connector_ID),
169
+ eaGuid: String(c.ea_guid || ''),
170
+ name: String(c.Name || ''),
171
+ connectorType: String(c.Connector_Type || ''),
172
+ stereotype: String(c.Stereotype || ''),
173
+ description: String(c.Notes === null || c.Notes === undefined ? '' : c.Notes),
174
+ direction: String(c.Direction || ''),
175
+ sourceObjectId: Number(c.Start_Object_ID || 0),
176
+ targetObjectId: Number(c.End_Object_ID || 0),
177
+ };
178
+ const tag = relTags.get(rec.connectorId);
179
+ if (tag && tag.schemaId) {
180
+ rec.schemaId = tag.schemaId;
181
+ rec.archimateType = tag.archimateType || '';
182
+ snapshot.relBySchema.set(rec.schemaId, rec);
183
+ }
184
+ if (rec.eaGuid) { snapshot.relByGuid.set(rec.eaGuid, rec); }
185
+ }
186
+
187
+ // placements (diagram membership + geometry)
188
+ const objs = db.prepare(
189
+ 'SELECT Diagram_ID, Object_ID, Sequence, RectLeft, RectTop, RectRight, RectBottom FROM t_diagramobjects'
190
+ ).all();
191
+ for (const r of objs) {
192
+ const diagramId = Number(r.Diagram_ID);
193
+ if (!snapshot.placements.has(diagramId)) { snapshot.placements.set(diagramId, new Map()); }
194
+ snapshot.placements.get(diagramId).set(Number(r.Object_ID), {
195
+ left: Number(r.RectLeft || 0), top: Number(r.RectTop || 0),
196
+ right: Number(r.RectRight || 0), bottom: Number(r.RectBottom || 0),
197
+ });
198
+ }
199
+ return snapshot;
200
+ } finally {
201
+ try { db.close(); } catch { /* ignore */ }
202
+ }
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // semanticDiff — classify human changes between base and work snapshots
207
+ // ---------------------------------------------------------------------------
208
+ function normText(s) {
209
+ return String(s === null || s === undefined ? '' : s).trim();
210
+ }
211
+
212
+ function elementCanonicalContext(work, rec) {
213
+ // Is this EA object part of the projection-owned / canonical surface?
214
+ if (rec.schemaId) { return true; }
215
+ if (rec.packageId === work.syncPackageId) { return true; }
216
+ // placed on any canonical view diagram?
217
+ for (const [diagramId, members] of work.placements) {
218
+ if (work.viewByDiagram.has(diagramId) && members.has(rec.objectId)) { return true; }
219
+ }
220
+ return false;
221
+ }
222
+
223
+ function placedViewIds(work, objectId) {
224
+ const out = [];
225
+ for (const [diagramId, members] of work.placements) {
226
+ const viewId = work.viewByDiagram.get(diagramId);
227
+ if (viewId && members.has(objectId)) { out.push(viewId); }
228
+ }
229
+ return out;
230
+ }
231
+
232
+ function semanticDiff(base, work, opts) {
233
+ const o = opts || {};
234
+ const proposals = [];
235
+ const summary = {
236
+ addElement: 0, updateElement: 0, removeElement: 0,
237
+ addRelationship: 0, updateRelationship: 0, removeRelationship: 0,
238
+ updateView: 0,
239
+ layoutOnly: 0, geometryOnlyObjects: 0, outOfScopeNew: 0, removedUnanchored: 0, orphanAnchored: 0,
240
+ };
241
+ const push = (p) => { proposals.push(p); summary[p.op] = (summary[p.op] || 0) + 1; };
242
+
243
+ const metaUnchanged = base.meta.sha === work.meta.sha;
244
+
245
+ // --- geometry-only counting (never proposed) -----------------------------
246
+ for (const [diagramId, baseMembers] of base.placements) {
247
+ const viewId = base.viewByDiagram.get(diagramId);
248
+ if (!viewId) { continue; }
249
+ const workMembers = work.placements.get(diagramId);
250
+ if (!workMembers) { continue; }
251
+ for (const [objectId, baseCoords] of baseMembers) {
252
+ const workCoords = workMembers.get(objectId);
253
+ if (!workCoords) { continue; }
254
+ const moved = baseCoords.left !== workCoords.left || baseCoords.top !== workCoords.top ||
255
+ baseCoords.right !== workCoords.right || baseCoords.bottom !== workCoords.bottom;
256
+ if (moved) { summary.layoutOnly++; }
257
+ }
258
+ }
259
+
260
+ // --- elements: anchored content / removal / addition ---------------------
261
+ const baseSchemaIds = new Set(base.elementBySchema.keys());
262
+ const workSchemaIds = new Set(work.elementBySchema.keys());
263
+ for (const schemaId of baseSchemaIds) {
264
+ const b = base.elementBySchema.get(schemaId);
265
+ const w = work.elementBySchema.get(schemaId);
266
+ if (!w) {
267
+ // anchored element removed from the model -> removeElement
268
+ push({ op: 'removeElement', kind: 'element', id: schemaId, sourceEa: { guid: b.eaGuid } });
269
+ continue;
270
+ }
271
+ const fields = {};
272
+ if (normText(b.name) !== normText(w.name)) { fields.name = w.name; }
273
+ if (normText(b.description) !== normText(w.description)) { fields.description = w.description; }
274
+ if (normText(b.status) !== normText(w.status)) { fields.status = w.status; }
275
+ if (Object.keys(fields).length > 0) {
276
+ push({ op: 'updateElement', kind: 'element', id: schemaId, fields, sourceEa: { guid: w.eaGuid } });
277
+ }
278
+ }
279
+ for (const schemaId of workSchemaIds) {
280
+ if (baseSchemaIds.has(schemaId)) { continue; }
281
+ // anchored id present only in work (e.g. human duplicated a tagged object) — cannot map
282
+ // to a canonical id safely; count and skip (agent reconciles).
283
+ summary.orphanAnchored++;
284
+ }
285
+ // unanchored NEW objects in work (human drew fresh boxes)
286
+ for (const [eaGuid, rec] of work.elementByGuid) {
287
+ if (rec.schemaId) { continue; }
288
+ if (base.elementByGuid.has(eaGuid)) { continue; }
289
+ if (!elementCanonicalContext(work, rec)) { summary.outOfScopeNew++; continue; }
290
+ const viewIds = placedViewIds(work, rec.objectId);
291
+ push({
292
+ op: 'addElement', kind: 'element', id: null,
293
+ proposed: {
294
+ name: rec.name,
295
+ description: rec.description === '' ? undefined : rec.description,
296
+ eaType: { objectType: rec.objectType, stereotype: rec.stereotype },
297
+ viewIds: viewIds.length > 0 ? viewIds : undefined,
298
+ },
299
+ sourceEa: { guid: eaGuid, objectId: rec.objectId },
300
+ });
301
+ }
302
+ // unanchored objects removed in work (never canonical -> no proposal, count only)
303
+ for (const [eaGuid, rec] of base.elementByGuid) {
304
+ if (rec.schemaId) { continue; }
305
+ if (work.elementByGuid.has(eaGuid)) { continue; }
306
+ if (elementCanonicalContext(base, rec)) { summary.removedUnanchored++; }
307
+ }
308
+
309
+ // --- relationships --------------------------------------------------------
310
+ const baseRelIds = new Set(base.relBySchema.keys());
311
+ const workRelIds = new Set(work.relBySchema.keys());
312
+ const schemaOfObject = (snap, objectId) => {
313
+ for (const [, e] of snap.elementBySchema) { if (e.objectId === objectId) { return e.schemaId; } }
314
+ const w = snap.elementByGuid;
315
+ for (const [, e] of w) { if (e.objectId === objectId && !e.schemaId) { return { newGuid: e.eaGuid }; } }
316
+ return null;
317
+ };
318
+ for (const schemaId of baseRelIds) {
319
+ const b = base.relBySchema.get(schemaId);
320
+ const w = work.relBySchema.get(schemaId);
321
+ if (!w) {
322
+ push({ op: 'removeRelationship', kind: 'relationship', id: schemaId, sourceEa: { guid: b.eaGuid } });
323
+ continue;
324
+ }
325
+ const fields = {};
326
+ if (normText(b.name) !== normText(w.name)) { fields.name = w.name; }
327
+ if (normText(b.description) !== normText(w.description)) { fields.description = w.description; }
328
+ const s = schemaOfObject(work, w.sourceObjectId);
329
+ const t = schemaOfObject(work, w.targetObjectId);
330
+ const bS = schemaOfObject(base, b.sourceObjectId);
331
+ const bT = schemaOfObject(base, b.targetObjectId);
332
+ const src = s && typeof s === 'object' ? null : s;
333
+ const tgt = t && typeof t === 'object' ? null : t;
334
+ const bSrc = bS && typeof bS === 'object' ? null : bS;
335
+ const bTgt = bT && typeof bT === 'object' ? null : bT;
336
+ if (src !== bSrc) { fields.sourceId = src; }
337
+ if (tgt !== bTgt) { fields.targetId = tgt; }
338
+ if (Object.keys(fields).length > 0) {
339
+ push({ op: 'updateRelationship', kind: 'relationship', id: schemaId, fields, sourceEa: { guid: w.eaGuid } });
340
+ }
341
+ }
342
+ for (const schemaId of workRelIds) {
343
+ if (baseRelIds.has(schemaId)) { continue; }
344
+ summary.orphanAnchored++;
345
+ }
346
+ // unanchored NEW connectors in work (human drew fresh links)
347
+ for (const [eaGuid, rec] of work.relByGuid) {
348
+ if (rec.schemaId) { continue; }
349
+ if (base.relByGuid.has(eaGuid)) { continue; }
350
+ // canonical context: at least one endpoint anchored/placed on a canonical view
351
+ const src = work.elementByGuid.get(guidOfObject(work, rec.sourceObjectId));
352
+ const tgt = work.elementByGuid.get(guidOfObject(work, rec.targetObjectId));
353
+ const srcContext = src ? elementCanonicalContext(work, src) : false;
354
+ const tgtContext = tgt ? elementCanonicalContext(work, tgt) : false;
355
+ if (!srcContext && !tgtContext) { summary.outOfScopeNew++; continue; }
356
+ const viewIds = [];
357
+ for (const [diagramId, members] of work.placements) {
358
+ const viewId = work.viewByDiagram.get(diagramId);
359
+ if (viewId && members.has(rec.sourceObjectId) && members.has(rec.targetObjectId)) { viewIds.push(viewId); }
360
+ }
361
+ const srcRef = src ? (src.schemaId || { newGuid: src.eaGuid }) : null;
362
+ const tgtRef = tgt ? (tgt.schemaId || { newGuid: tgt.eaGuid }) : null;
363
+ push({
364
+ op: 'addRelationship', kind: 'relationship', id: null,
365
+ proposed: {
366
+ name: rec.name === '' ? undefined : rec.name,
367
+ sourceRef: srcRef, targetRef: tgtRef,
368
+ eaType: { connectorType: rec.connectorType, stereotype: rec.stereotype },
369
+ viewIds: viewIds.length > 0 ? viewIds : undefined,
370
+ },
371
+ sourceEa: { guid: eaGuid, connectorId: rec.connectorId },
372
+ });
373
+ }
374
+
375
+ // --- view membership (anchored objects only, still present in the model) ---
376
+ const allViewIds = new Set([...base.viewByDiagram.values(), ...work.viewByDiagram.values()]);
377
+ for (const viewId of allViewIds) {
378
+ const bDiag = base.diagramsByView.get(viewId);
379
+ const wDiag = work.diagramsByView.get(viewId);
380
+ if (!bDiag || !wDiag) { continue; } // view's diagram added/removed wholesale: out of v1 scope
381
+ const bMembers = base.placements.get(bDiag.diagramId) || new Map();
382
+ const wMembers = work.placements.get(wDiag.diagramId) || new Map();
383
+ const addMembers = [];
384
+ const removeMembers = [];
385
+ for (const objectId of wMembers.keys()) {
386
+ if (bMembers.has(objectId)) { continue; }
387
+ const rec = elemByObjectId(work, objectId);
388
+ if (!rec || !rec.schemaId) { continue; } // unanchored new placement -> rides addElement
389
+ addMembers.push(rec.schemaId);
390
+ }
391
+ for (const objectId of bMembers.keys()) {
392
+ if (wMembers.has(objectId)) { continue; }
393
+ const rec = elemByObjectId(base, objectId);
394
+ if (!rec || !rec.schemaId) { continue; }
395
+ // removed from diagram but object must still exist in the model (else removeElement)
396
+ if (!work.elementBySchema.has(rec.schemaId)) { continue; }
397
+ removeMembers.push(rec.schemaId);
398
+ }
399
+ if (addMembers.length > 0 || removeMembers.length > 0) {
400
+ push({
401
+ op: 'updateView', kind: 'view', viewId,
402
+ addMembers: addMembers.length > 0 ? addMembers : undefined,
403
+ removeMembers: removeMembers.length > 0 ? removeMembers : undefined,
404
+ sourceEa: {},
405
+ });
406
+ }
407
+ }
408
+
409
+ // orphan/other counts merge
410
+ summary.metaUnchanged = metaUnchanged;
411
+ return { proposals, summary };
412
+ }
413
+
414
+ function guidOfObject(snap, objectId) {
415
+ for (const [, e] of snap.elementByGuid) { if (e.objectId === objectId) { return e.eaGuid; } }
416
+ return '';
417
+ }
418
+ function elemByObjectId(snap, objectId) {
419
+ for (const [, e] of snap.elementByGuid) { if (e.objectId === objectId) { return e; } }
420
+ return null;
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Output rendering (JSON + Markdown)
425
+ // ---------------------------------------------------------------------------
426
+ function buildJsonResult(args) {
427
+ return {
428
+ format: 'archgraph-ea-human-diff',
429
+ version: 1,
430
+ source: { base: args.base, work: args.work },
431
+ baselineCommit: args.baselineCommit || null,
432
+ extractedAt: new Date().toISOString(),
433
+ summary: args.summary,
434
+ proposals: args.proposals,
435
+ };
436
+ }
437
+
438
+ function renderMarkdown(result) {
439
+ const s = result.summary;
440
+ const lines = [];
441
+ lines.push('# EA 人类草稿语义 diff(ea-human-diff)');
442
+ lines.push('');
443
+ lines.push(`- 基线(committed .qea):\`${result.source.base}\``);
444
+ lines.push(`- 工作区(human-edited .qea):\`${result.source.work}\``);
445
+ if (result.baselineCommit) { lines.push(`- 基线 commit:\`${result.baselineCommit}\``); }
446
+ lines.push(`- 提取时间:${result.extractedAt}`);
447
+ lines.push('');
448
+ lines.push('## 摘要');
449
+ lines.push('');
450
+ lines.push('| 操作 | 数量 |');
451
+ lines.push('| --- | --- |');
452
+ const opOrder = ['addElement', 'updateElement', 'removeElement', 'addRelationship', 'updateRelationship', 'removeRelationship', 'updateView'];
453
+ for (const op of opOrder) {
454
+ const label = { addElement: '新增元素', updateElement: '更新元素', removeElement: '删除元素', addRelationship: '新增关系', updateRelationship: '更新关系', removeRelationship: '删除关系', updateView: '视图成员' }[op];
455
+ lines.push(`| ${label}(${op}) | ${s[op] || 0} |`);
456
+ }
457
+ lines.push(`| 纯几何移动(不产出,语义优先排除) | ${s.layoutOnly || 0} |`);
458
+ lines.push(`| 超出 canonical 作用域的新对象(跳过) | ${s.outOfScopeNew || 0} |`);
459
+ lines.push(`| 删除的无锚对象(从未入 canonical,跳过) | ${s.removedUnanchored || 0} |`);
460
+ lines.push(`| 镜像(kg_sync_meta)未变 | ${s.metaUnchanged ? '是' : '否'} |`);
461
+ lines.push('');
462
+ if (result.proposals.length === 0) {
463
+ lines.push('> 未检测到 canonical 语义提议(纯几何/超出作用域改动不计)。');
464
+ lines.push('');
465
+ }
466
+ const groups = {
467
+ addElement: '新增元素提议', updateElement: '更新元素提议', removeElement: '删除元素提议',
468
+ addRelationship: '新增关系提议', updateRelationship: '更新关系提议', removeRelationship: '删除关系提议',
469
+ updateView: '视图成员提议',
470
+ };
471
+ for (const op of opOrder) {
472
+ const items = result.proposals.filter((p) => p.op === op);
473
+ if (items.length === 0) { continue; }
474
+ lines.push(`## ${groups[op]}(${items.length})`);
475
+ lines.push('');
476
+ for (const p of items) {
477
+ if (op === 'addElement') {
478
+ lines.push(`- **${p.proposed.name}** — id 待 agent 分配;EA 类型 \`${p.proposed.eaType.objectType}\`/\`${p.proposed.eaType.stereotype}\`${p.proposed.viewIds ? `;视图候选 ${p.proposed.viewIds.join(', ')}` : ''}${p.proposed.description ? `;描述:${p.proposed.description.slice(0, 120)}` : ''};EA \`${p.sourceEa.guid}\``);
479
+ } else if (op === 'updateElement') {
480
+ lines.push(`- \`${p.id}\` — ${Object.entries(p.fields).map(([k, v]) => `${k} → ${String(v).slice(0, 80)}`).join(';')};EA \`${p.sourceEa.guid}\``);
481
+ } else if (op === 'removeElement' || op === 'removeRelationship') {
482
+ lines.push(`- \`${p.id}\` — 待 agent 确认后删除;EA \`${p.sourceEa.guid}\``);
483
+ } else if (op === 'addRelationship') {
484
+ const src = typeof p.proposed.sourceRef === 'object' ? `新元素 ${p.proposed.sourceRef.newGuid}` : p.proposed.sourceRef;
485
+ const tgt = typeof p.proposed.targetRef === 'object' ? `新元素 ${p.proposed.targetRef.newGuid}` : p.proposed.targetRef;
486
+ lines.push(`- ${p.proposed.name ? `**${p.proposed.name}** ` : ''}${src} → ${tgt};EA 类型 \`${p.proposed.eaType.connectorType}\`/\`${p.proposed.eaType.stereotype}\`;EA \`${p.sourceEa.guid}\``);
487
+ } else if (op === 'updateRelationship') {
488
+ lines.push(`- \`${p.id}\` — ${Object.entries(p.fields).map(([k, v]) => `${k} → ${String(v).slice(0, 80)}`).join(';')};EA \`${p.sourceEa.guid}\``);
489
+ } else if (op === 'updateView') {
490
+ const a = p.addMembers ? `加入:${p.addMembers.join(', ')}` : '';
491
+ const r = p.removeMembers ? `移除:${p.removeMembers.join(', ')}` : '';
492
+ lines.push(`- 视图 \`${p.viewId}\` — ${[a, r].filter(Boolean).join(';')}`);
493
+ }
494
+ }
495
+ lines.push('');
496
+ }
497
+ lines.push('> 本 diff 基于 EA 可见对象模型(schema_id 锚 tag 对齐),不读 kg_sync_meta;几何不进 canonical。交由 agent 经 ARGO preview/apply 写入图谱。');
498
+ lines.push('');
499
+ return lines.join('\n');
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // CLI
504
+ // ---------------------------------------------------------------------------
505
+ function parseArgs(argv) {
506
+ const args = { base: '', work: '', graph: '', out: '', baselineCommit: '', md: true };
507
+ for (let i = 0; i < argv.length; i++) {
508
+ const a = argv[i];
509
+ const next = () => (i + 1 < argv.length ? argv[++i] : '');
510
+ if (a === '--base') { args.base = next(); }
511
+ else if (a === '--work') { args.work = next(); }
512
+ else if (a === '--graph') { args.graph = next(); }
513
+ else if (a === '--out') { args.out = next(); }
514
+ else if (a === '--baseline-commit') { args.baselineCommit = next(); }
515
+ else if (a === '--no-md') { args.md = false; }
516
+ }
517
+ return args;
518
+ }
519
+
520
+ // Auto-baseline: extract the committed (HEAD) version of the working .qea as the baseline.
521
+ // Lets the day-to-day flow be a single command when --work is a tracked repo file.
522
+ function gitShowHeadBlob(relPath) {
523
+ const { execFileSync } = require('node:child_process');
524
+ return execFileSync('git', ['cat-file', 'blob', 'HEAD:' + relPath], { maxBuffer: 512 * 1024 * 1024 });
525
+ }
526
+ function resolveAutoBase(workPath) {
527
+ const { execFileSync } = require('node:child_process');
528
+ const workAbs = path.resolve(process.cwd(), workPath);
529
+ let root;
530
+ try {
531
+ root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
532
+ } catch {
533
+ throw new Error('--base omitted but git toplevel unavailable; pass --base <baseline.qea> explicitly');
534
+ }
535
+ const rel = path.relative(root, workAbs).split(path.sep).join('/');
536
+ if (rel.startsWith('..')) {
537
+ throw new Error('--work (' + workAbs + ') is outside the git repo; pass --base <baseline.qea> explicitly');
538
+ }
539
+ let blob;
540
+ try {
541
+ blob = gitShowHeadBlob(rel);
542
+ } catch {
543
+ throw new Error('git HEAD has no tracked file "' + rel + '"; pass --base <baseline.qea> explicitly');
544
+ }
545
+ const tmp = path.join(require('node:os').tmpdir(), 'ea-human-diff-base-' + process.pid + '.qea');
546
+ fs.writeFileSync(tmp, blob);
547
+ let short = '';
548
+ try { short = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* ignore */ }
549
+ return { base: tmp, baselineCommit: short };
550
+ }
551
+
552
+ function main() {
553
+ const args = parseArgs(process.argv.slice(2));
554
+ if (!args.base && !args.work) {
555
+ console.error('usage: node argo/scripts/ea-human-diff.js --base <base.qea> --work <work.qea> [--graph <json>] [--out <stem>] [--baseline-commit <sha>] [--no-md]');
556
+ console.error(' (--base optional: when omitted the committed HEAD version of --work is used as baseline)');
557
+ process.exit(2);
558
+ }
559
+ if (args.base === '' && args.work) {
560
+ try {
561
+ const auto = resolveAutoBase(args.work);
562
+ args.base = auto.base;
563
+ if (!args.baselineCommit) { args.baselineCommit = auto.baselineCommit; }
564
+ } catch (err) {
565
+ console.error('ea-human-diff: ' + err.message);
566
+ process.exit(2);
567
+ }
568
+ }
569
+ for (const f of [args.base, args.work]) {
570
+ if (!fs.existsSync(f)) { console.error('file not found: ' + f); process.exit(2); }
571
+ }
572
+ const knownViewIds = null;
573
+ if (args.graph && fs.existsSync(args.graph)) {
574
+ try {
575
+ const g = JSON.parse(fs.readFileSync(args.graph, 'utf8').replace(/^\uFEFF/, ''));
576
+ args._knownViewIds = (g.views || []).map((v) => String(v.view_id));
577
+ } catch { /* optional catalog */ }
578
+ }
579
+ const base = readSnapshot(args.base, { knownViewIds: args._knownViewIds });
580
+ const work = readSnapshot(args.work, { knownViewIds: args._knownViewIds });
581
+ const { proposals, summary } = semanticDiff(base, work, {});
582
+ const result = buildJsonResult({ base: args.base, work: args.work, baselineCommit: args.baselineCommit, summary, proposals });
583
+ if (args.out) {
584
+ const stem = path.resolve(process.cwd(), args.out);
585
+ fs.writeFileSync(stem + '.json', JSON.stringify(result, null, 2), 'utf8');
586
+ if (args.md) {
587
+ fs.writeFileSync(stem + '.md', renderMarkdown(result), 'utf8');
588
+ }
589
+ console.log('ea-human-diff written: ' + stem + '.json' + (args.md ? ' + ' + stem + '.md' : ''));
590
+ console.log(JSON.stringify({ summary }, null, 2));
591
+ } else {
592
+ console.log(JSON.stringify(result, null, 2));
593
+ }
594
+ }
595
+
596
+ if (require.main === module) { main(); }
597
+
598
+ module.exports = {
599
+ parseStyleToken,
600
+ readSnapshot,
601
+ semanticDiff,
602
+ buildJsonResult,
603
+ renderMarkdown,
604
+ };
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: ea-human-draft
3
+ description: "把人类在 EA 里对 .qea 的草稿改动收敛进正式图谱(WP2792 draft-proposal 流程):① 断言前提(EA 已关闭 / .qea 可还原 / 工具存在)→ ② 用 ea-human-diff 提取语义 diff 提议(JSON+Markdown,读 EA 可见对象模型、不读 kg_sync_meta、纯几何不产出)→ ③ git restore 还原 .qea 到上次提交 → ④ 把提议交给 agent/人类伙伴,按其指引经 ARGO preview/apply 写回 canonical JSON(自动增量投影回 .qea 并 commit)。Use when 人类专家用 Sparx EA 直接改了 archgraph.qea 需要并入正式图谱、提取人类 EA 改动的语义 diff、把 .qea 还原回已提交状态、或按 draft-proposal 收敛人类草稿。Keywords: EA 人类草稿, human draft, semantic diff, ea-human-diff, draft-proposal, reverse-ea2kg, 人类参与建图."
4
+ argument-hint: 人类 EA 草稿收敛
5
+ ---
6
+
7
+ # EA HUMAN DRAFT(人类 EA 草稿 → 语义 diff → 写回图谱)
8
+
9
+ 定位:**EA 只当草稿纸**,`design/KG/SystemArchitecture.json` 是唯一真源。人类在 agent 空闲间隙用 EA 改 `archgraph.qea`;本技能负责把人类改动**安全地提取为提议**(断言 + diff + 还原),并把提议交给 agent/人类伙伴,按其指引经 ARGO 写回 canonical——不建立有损的全自动 EA→JSON 反向投影。
10
+
11
+ 依赖工具:`argo/scripts/ea-human-diff.js`(仓库内)或部署版 `~/.argo/scripts/ea-human-diff.js`(随 `archgraph-argo` npm 包发布)。`--base` 可省略:自动取 git HEAD 里 `--work` 的版本作基线。
12
+
13
+ ## 前置(Assert —— 全部满足才继续,任一失败即停下报告)
14
+
15
+ - [ ] 工作区含 `design/KG/SystemArchitecture.json` 且 `archgraph.qea` 被 git 跟踪(`git ls-files archgraph.qea` 有输出)。
16
+ - [ ] diff 工具存在:仓库 `argo/scripts/ea-human-diff.js` 或部署 `~/.argo/scripts/ea-human-diff.js`。
17
+ - [ ] **EA 已完全关闭**(人类改动已保存落盘)——否则读到的不是最终状态,且 `git restore` 可能被文件锁破坏/EA 关盘重写。
18
+ - [ ] git 可用;`HEAD` 中存在 `archgraph.qea`(自动基线依赖)。若人类还没开改(`git status --short archgraph.qea` 为空)→ 说明:需先在 EA 里改、保存、关闭后再回来。
19
+
20
+ Windows 可选核实无进程持有:
21
+ ```powershell
22
+ Get-Process EA -ErrorAction SilentlyContinue # 有输出则先关闭 EA
23
+ ```
24
+
25
+ ## Workflow
26
+
27
+ ### 1 · Assert(断言)
28
+ 逐条检查上方前置并报告结果。任一失败 → 停下,不做 diff、不做 revert。
29
+
30
+ ### 2 · 提取语义 diff
31
+ 仓库根执行(自动基线 = git HEAD 里的 `archgraph.qea`):
32
+ ```powershell
33
+ node argo/scripts/ea-human-diff.js --work archgraph.qea --out results/human-draft
34
+ ```
35
+ 产物:
36
+ - `results/human-draft.json` —— 机器提议集(`proposals[]`:`op`/`kind`/`id`/`fields`/`proposed`/`sourceEa` 等 + `summary`)。
37
+ - `results/human-draft.md` —— 人读摘要(分类表格 + 逐条明细 + EA guid 溯源)。
38
+
39
+ 先给人伙伴看 `human-draft.md`:确认是预期改动、无意外删除;留意 `layoutOnly` / `outOfScopeNew` / `removedUnanchored` 计数(这些不产出提议)。
40
+ 若 `--work` 不是 git 跟踪文件(如临时副本),须显式 `--base <committed.qea>`,不能用自动基线。
41
+
42
+ ### 3 · 还原 .qea 到上次提交
43
+ ```powershell
44
+ git restore archgraph.qea
45
+ git status --short # 应只剩 results/human-draft.* 等产物,archgraph.qea 不再 dirty
46
+ ```
47
+ 目的:把 `.qea` 拉回与 HEAD 一致,杜绝残留分叉;人类草稿只以提议文件形式存在,避免后续 agent 写图触发投影时静默覆盖/合并混乱。
48
+
49
+ ### 4 · 交给下一步(按人类指引写回正式图谱)
50
+ 提议**不会自动写回**。把 `human-draft.json`/`.md` 呈现给人类伙伴,按其指引执行(或交给负责写图的 agent):
51
+ 1. 审阅每条提议(add/update/remove / 视图成员 / 删除需确认),必要时删改。
52
+ 2. 经 ARGO MCP `previewSystemArchitectureMutation` → `applySystemArchitectureMutation` 写入 canonical JSON。
53
+ 3. apply 后 MCP 自动增量投影回 `.qea`——**不要手工编辑 .qea 覆盖**。
54
+ 4. 对受影响元素跑回归验收(WP2792 AT 集等)后 `git commit`,并把 commit id 登记到对应图谱元素的 `commit` 属性(见全局 ArchGraph 红线)。
55
+
56
+ ## Rules
57
+
58
+ - **MUST** 只在 EA 关闭后运行;diff 前先做断言并报告。
59
+ - **MUST** diff 以 EA **可见对象模型**(`schema_id` 锚 tag 对齐)为准;**绝不**用 `kg_sync_meta` 判定人类改动(人类改动不进镜像)。
60
+ - **MUST** 提取 diff 后先 `git restore archgraph.qea` 再进入写回阶段,避免 qea 侧残留被后续投影覆盖/丢失。
61
+ - **MUST** 把每条**删除**提议(removeElement/removeRelationship)标记为需人类/负责 agent 确认后再 apply。
62
+ - **MUST NOT** 把 `human-draft.json` 当 canonical 直接写——必须先 ARGO `preview` 校验,再 `apply`。
63
+ - **MUST NOT** 在 `.qea` 上手工写 canonical 内容(唯一写回通道是 canonical JSON → 自动增量投影)。
64
+ - **MUST NOT** 读取/复述 `.env` 中的 secret。
65
+ - 纯几何(`t_diagramobjects` 坐标)**不进提议**(语义优先);若人类排版也是交付物,需另行走布局侧车(本技能不产出)。
66
+
67
+ ## 产物
68
+
69
+ | 文件 | 内容 |
70
+ | --- | --- |
71
+ | `results/human-draft.json` | 机器提议集:source / baselineCommit / extractedAt / summary / proposals[](每条含 op / kind / id / fields / proposed / sourceEa) |
72
+ | `results/human-draft.md` | 人读摘要:操作计数表 + 逐条明细(EA guid 溯源)+ 排除说明(layoutOnly 等) |
73
+
74
+ ## 故障排查
75
+
76
+ - `git HEAD has no tracked file "..."`:`--work` 未被 git 跟踪 → 显式 `--base <committed.qea>`。
77
+ - `git restore` 失败/文件锁:EA 还开着 → 关闭 EA 后重试。
78
+ - diff 为空但人类确实改过:多半只做了纯几何或超出 canonical 作用域改动 → 看 `.md` 的 `layoutOnly` / `outOfScopeNew` 计数。
79
+ - 关系侧提议异常(id 为 undefined):确认基线 .qea 由投影生成(`t_connectortag` 带 `schema_id`),手绘模型无锚时关系不参与。
package/install-argo.ps1 CHANGED
@@ -37,12 +37,6 @@ $argoDir = Join-Path $repoRoot 'argo'
37
37
  # verbatim across Copilot / Cursor / OpenCode (and DSH presets), so the original
38
38
  # model binding must stay untouched there. OpenCode has no Qwen provider
39
39
  # configured, so 'alibaba-cn/qwen3.7-plus' cannot resolve; only the OpenCode
40
- # deployment artifact remaps that binding to a model id OpenCode knows
41
- # (deepseek/deepseek-v4-flash-vision-exp). All other targets keep the shared
42
- # source binding unchanged.
43
- $OpenCodeModelRemap = @{
44
- 'alibaba-cn/qwen3.7-plus' = 'deepseek/deepseek-v4-flash-vision-exp'
45
- }
46
40
 
47
41
  function Copy-Tree {
48
42
  param([string]$Source, [string]$Destination)
@@ -102,9 +96,7 @@ function Convert-AgentFile {
102
96
  $newFront = "---`r`n"
103
97
  if ($desc) { $newFront += "description: `"$($desc -replace '"','\"')`"`r`n" }
104
98
  if ($model) {
105
- $deployModel = $model
106
- if ($OpenCodeModelRemap.ContainsKey($deployModel)) { $deployModel = $OpenCodeModelRemap[$deployModel] }
107
- $newFront += "model: `"$deployModel`"`r`n"
99
+ $newFront += "model: `"$model`"`r`n"
108
100
  }
109
101
  $newFront += "mode: all`r`n---`r`n"
110
102
  } else {
@@ -770,6 +762,10 @@ $skillDest = Join-Path $SkillsRoot 'argo-init'
770
762
  Write-Host "[4/22] argo\skills\argo-init -> $skillDest"
771
763
  Copy-Tree -Source $skillSrc -Destination $skillDest
772
764
 
765
+ $draftSkillSrc = Join-Path $argoDir 'skills\ea-human-draft'
766
+ Write-Host ' argo\skills\ea-human-draft -> $SkillsRoot\ea-human-draft (ea-human-draft skill)'
767
+ Copy-Tree -Source $draftSkillSrc -Destination (Join-Path $SkillsRoot 'ea-human-draft')
768
+
773
769
  $ruleSrc = Join-Path (Join-Path $argoDir 'rules') 'archgraph.instructions.md'
774
770
  $ruleDest = Join-Path $PromptsRoot 'archgraph.instructions.md'
775
771
  Write-Host "[5/22] argo\rules\archgraph.instructions.md -> $ruleDest"
@@ -784,6 +780,8 @@ Copy-Item -Force -Path $depsSrc -Destination $depsDest
784
780
  $cursorSkillDest = Join-Path $CursorSkillsRoot 'argo-init'
785
781
  Write-Host "[7/22] argo\skills\argo-init -> $cursorSkillDest (Cursor)"
786
782
  Copy-Tree -Source $skillSrc -Destination $cursorSkillDest
783
+ Write-Host ' argo\skills\ea-human-draft -> $CursorSkillsRoot\ea-human-draft (Cursor)'
784
+ Copy-Tree -Source $draftSkillSrc -Destination (Join-Path $CursorSkillsRoot 'ea-human-draft')
787
785
 
788
786
  $mcpBridgeSrc = Join-Path $argoDir 'mcp-bridges'
789
787
  $mcpBridgeDest = Join-Path $CursorMcpBridgesRoot ''
@@ -793,6 +791,8 @@ Copy-Tree -Source $mcpBridgeSrc -Destination $mcpBridgeDest
793
791
  $openCodeSkillDest = Join-Path $OpenCodeSkillsRoot 'argo-init'
794
792
  Write-Host "[8/22] argo\skills\argo-init -> $openCodeSkillDest (OpenCode)"
795
793
  Copy-Tree -Source $skillSrc -Destination $openCodeSkillDest
794
+ Write-Host ' argo\skills\ea-human-draft -> $OpenCodeSkillsRoot\ea-human-draft (OpenCode)'
795
+ Copy-Tree -Source $draftSkillSrc -Destination (Join-Path $OpenCodeSkillsRoot 'ea-human-draft')
796
796
 
797
797
  Write-Host "[9/22] argo\rules\archgraph.instructions.md -> $OpenCodeAgentsPath (OpenCode global AGENTS.md)"
798
798
  Add-AgentsRule -AgentsPath $OpenCodeAgentsPath -RulePath $ruleSrc
@@ -831,6 +831,9 @@ if ($SkipDsh) {
831
831
  Write-Host "[16/22] argo\skills\argo-init -> $dshSkillDest (DeepSeek Harness skill)"
832
832
  Copy-Tree -Source (Join-Path $argoDir 'skills\argo-init') -Destination $dshSkillDest
833
833
 
834
+ Write-Host ' argo\skills\ea-human-draft -> $DshHome\skills\ea-human-draft (DeepSeek Harness skill)'
835
+ Copy-Tree -Source (Join-Path $argoDir 'skills\ea-human-draft') -Destination (Join-Path (Join-Path $DshHome 'skills') 'ea-human-draft')
836
+
834
837
  Write-Host "[17/22] argo\rules\<WakeupGuideline> -> $DshHome\plugins\dsh-argo-wakeup\index.js (DeepSeek Harness wakeup plugin)"
835
838
  $wakeupDshPath = New-DshWakeupPlugin -DshHome $DshHome -RuleText $ruleSrcContent
836
839
 
@@ -892,6 +895,9 @@ if ($SkipOpenClaw) {
892
895
  Write-Host "[21/22] argo\skills\argo-init -> $openClawSkillDest (OpenClaw managed skill, all agents)"
893
896
  Copy-Tree -Source (Join-Path $argoDir 'skills\argo-init') -Destination $openClawSkillDest
894
897
 
898
+ Write-Host ' argo\skills\ea-human-draft -> $OpenClawHome\skills\ea-human-draft (OpenClaw managed skill, all agents)'
899
+ Copy-Tree -Source (Join-Path $argoDir 'skills\ea-human-draft') -Destination (Join-Path (Join-Path $OpenClawHome 'skills') 'ea-human-draft')
900
+
895
901
  Write-Host ' OpenClaw injects AGENTS.md into Project Context on every session, so the wakeup'
896
902
  Write-Host ' gate (UNCONDITIONAL STARTUP GATE) is active on the next OpenClaw session; restart'
897
903
  Write-Host ' the OpenClaw gateway (openclaw gateway restart) if it is already running.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgraph-argo",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
4
4
  "description": "Deploy the ArchGraph ARGO toolchain, skills, and rules (schema, scripts, argo-init skill, global rule) with one command.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "argo/plugins",
15
15
  "argo/mcp-bridges",
16
16
  "argo/skills/argo-init",
17
+ "argo/skills/ea-human-draft",
17
18
  "argo/rules",
18
19
  "argo/package.json",
19
20
  "vendor",
@@ -1,43 +0,0 @@
1
- ---
2
- description: "公众号发布:将 ArchGraph 项目的文章(洞察报告、官宣介绍、开发者社区等)撰写为符合微信公众号格式的 Markdown,并通过 wechat-public-cli 创建草稿、跟进公众号后台手动发布流程。Use when: 发布公众号文章、创建公众号草稿、微信公众平台、wechat、公众号发布员、撰写 .wechat.md 文章。"
3
- name: "公众号发布员"
4
- model: "alibaba-cn/qwen3.7-plus"
5
- tools: [read, edit, search, execute]
6
- user-invocable: true
7
- argument-hint: "要发布的文章主题或源文档路径"
8
- ---
9
- 你是 ArchGraph 项目的「公众号发布员」,专职把项目文章发布到微信公众号。
10
-
11
- ## 职责
12
- 1. 阅读源内容(洞察报告、官宣介绍、开发者社区等文档),提炼并撰写为符合微信公众号格式的 Markdown 文章。
13
- 2. 通过本地 wechat-public-cli 创建公众号草稿。
14
- 3. 返回草稿 media_id,并说明公众号后台手动发布的跟进步骤。
15
-
16
- ## 约束
17
- - 只用 `wechat:draft` 创建草稿。**禁止**运行 `wechat:publish` / `wechat:sendall` / `freepublish`:本公众号是个人未认证订阅号(appid wxdf79e7cb44995aa3),这些 API 永远返回 48001 api unauthorized,只能登录公众号后台「草稿箱」手动发布。
18
- - 文章内容必须基于仓库内已有文档,不得凭空编造。
19
- - 不得打印或泄露 `wechat-public.config.json` 中的 appid/secret 等凭据。
20
- - 发布前需确认当前公网 IP 已加入公众号后台 IP 白名单(否则 token 刷新报 40164)。
21
-
22
- ## 工作方法
23
- 1. 定位源文档,并参考 `docs/*.wechat.md` 既有文章的格式与文风。
24
- 2. 撰写 `docs/<主题>.wechat.md`,YAML frontmatter 至少含 `title` / `author` / `digest`,并按需含 `banner_path`(相对文章目录)、`open_comment`、`source_url`。
25
- 3. 若 `tests/wechat-article.test.js` 尚无对应验收用例,先补一条 GIVEN-WHEN-THEN 用例,再运行 `node --test tests/wechat-article.test.js` 通过。
26
- 4. 创建草稿(banner_path 相对文章文件目录解析):
27
- ```powershell
28
- node "d:\Projects\_tools\wechat-cli-src\obsidian-wechat-public-platform-master\dist\wechat-public-cli.js" wechat:draft --file "<文章绝对路径>" --config "d:\Projects\archgraph\wechat-public.config.json" --css "d:\Projects\_tools\wechat-cli-src\obsidian-wechat-public-platform-master\custom.css"
29
- ```
30
-
31
- ## 文风要求(人味优先,去 AI 味)
32
-
33
- 发布公众号文章前,必须按以下要求降低「AI 味」、增加「人味」,否则读者不会看:
34
-
35
- - 禁止 AI 套话与八股:删掉「首先/其次/最后」「综上所述」「总而言之」「值得注意的是」「赋能」「抓手」「闭环」「底层逻辑」等空洞词,去掉「本文将从以下 N 个方面展开」式开场。
36
- - 去掉机械排比与对仗堆砌;段落要短,句子长短交错,像人说话,不像机器列清单。
37
- - 多点人味:加入真实场景、具体例子、第一人称视角、设问、适度的口语与幽默;用具体数字和细节代替抽象概括。
38
- - 开门见山直给观点与结论,少绕弯子;结尾给出可行动的判断,而非泛泛展望。
39
- - 标题与摘要(digest)同样要有人味,避免口号式、震惊体式标题。
40
- - 每篇至少通读一遍,找出并改写任何读起来像机器生成的句子。
41
-
42
- ## 输出格式
43
- 返回:文章路径、草稿 media_id(或确切失败原因)、状态(草稿已创建 / BLOCKED)。并提醒:API 发布不可用,需在公众号后台「草稿箱」手动发布。