@cyning/harness 1.1.0 → 2.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.
@@ -0,0 +1,563 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const HGM_DIR = '.cyning-harness';
5
+ const EVENTS_DIR = 'events';
6
+ const SNAPSHOT_FILE = 'graph/snapshot.json';
7
+
8
+ export class HgmError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'HgmError';
12
+ }
13
+ }
14
+
15
+ export function eventsDir(target) {
16
+ return path.join(target, HGM_DIR, EVENTS_DIR);
17
+ }
18
+
19
+ export function eventsFileForMonth(target, date = new Date()) {
20
+ const y = date.getUTCFullYear();
21
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
22
+ return path.join(eventsDir(target), `${y}-${m}.jsonl`);
23
+ }
24
+
25
+ export function snapshotPath(target) {
26
+ return path.join(target, HGM_DIR, SNAPSHOT_FILE);
27
+ }
28
+
29
+ function ensureDir(dir) {
30
+ if (!fs.existsSync(dir)) {
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ }
33
+ }
34
+
35
+ function isoDate(date = new Date()) {
36
+ return date.toISOString();
37
+ }
38
+
39
+ function eventId(date = new Date(), seq = 0) {
40
+ const d = date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
41
+ return `evt:${d}:${String(seq).padStart(3, '0')}`;
42
+ }
43
+
44
+ /**
45
+ * 追加单条事件到 events/YYYY-MM.jsonl。
46
+ */
47
+ export function appendEvent(target, event) {
48
+ const file = eventsFileForMonth(target, new Date(event.occurred_at));
49
+ ensureDir(path.dirname(file));
50
+ const line = JSON.stringify(event);
51
+ fs.appendFileSync(file, `${line}\n`);
52
+ return file;
53
+ }
54
+
55
+ /**
56
+ * 读取全部事件,按 occurred_at 排序。
57
+ */
58
+ export function loadEvents(target) {
59
+ const dir = eventsDir(target);
60
+ if (!fs.existsSync(dir)) return [];
61
+ const events = [];
62
+ const files = fs
63
+ .readdirSync(dir)
64
+ .filter((n) => n.endsWith('.jsonl'))
65
+ .sort();
66
+ for (const f of files) {
67
+ const raw = fs.readFileSync(path.join(dir, f), 'utf8');
68
+ for (const line of raw.split('\n')) {
69
+ const trimmed = line.trim();
70
+ if (!trimmed) continue;
71
+ try {
72
+ events.push(JSON.parse(trimmed));
73
+ } catch (err) {
74
+ throw new HgmError(`JSONL 解析失败 ${f}: ${err.message}`);
75
+ }
76
+ }
77
+ }
78
+ events.sort((a, b) => a.occurred_at.localeCompare(b.occurred_at) || a.event_id.localeCompare(b.event_id));
79
+ return events;
80
+ }
81
+
82
+ /**
83
+ * 轻量解析 task markdown:提取 slug、status、title、gates、must_read。
84
+ */
85
+ export function parseTaskMarkdown(content, fileName) {
86
+ const slugMatch = content.match(/\*\*task_slug\*\*\s*[::]\s*`?([a-zA-Z0-9_-]+)`?/) ||
87
+ fileName.match(/task_([a-zA-Z0-9_-]+)_v\d+/);
88
+ const taskSlug = slugMatch ? slugMatch[1] : path.basename(fileName, '.md');
89
+
90
+ const titleMatch = content.match(/^#\s+Task\s*[·•]\s*(.+)$/m) || content.match(/^#\s+(.+)$/m);
91
+ const title = titleMatch ? titleMatch[1].trim() : taskSlug;
92
+
93
+ const statusMatch = content.match(/\*\*状态\*\*\s*[::]\s*`?([a-zA-Z0-9_-]+)`?/);
94
+ const status = statusMatch ? statusMatch[1] : 'pending';
95
+
96
+ const gates = [];
97
+ const gateHeader = content.search(/^\s*\|\s*human_gate_id\s*\|/m);
98
+ if (gateHeader !== -1) {
99
+ const tableStart = content.lastIndexOf('\n', gateHeader) + 1;
100
+ let tableEnd = content.indexOf('\n\n', gateHeader);
101
+ if (tableEnd === -1) tableEnd = content.length;
102
+ const table = content.slice(tableStart, tableEnd).trim();
103
+ const lines = table.split('\n').filter((l) => l.trim().startsWith('|'));
104
+ for (let i = 2; i < lines.length; i += 1) {
105
+ const cols = lines[i].split('|').map((c) => c.trim().replace(/\*/g, ''));
106
+ if (cols.length >= 3) {
107
+ const humanGateId = cols[1];
108
+ const gateStatus = cols[2];
109
+ const blocksHats = cols[3] && cols[3] !== '—' ? cols[3].split(/[,\s]+/).filter(Boolean) : [];
110
+ if (humanGateId && gateStatus) {
111
+ gates.push({ human_gate_id: humanGateId, status: gateStatus, blocks_hats: blocksHats });
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ const mustRead = [];
118
+ const mustReadMatch = content.match(/MUST_READ|must_read|必须阅读/);
119
+ if (mustReadMatch) {
120
+ const sectionStart = content.indexOf(mustReadMatch[0]);
121
+ const sectionEnd = content.indexOf('\n\n', sectionStart);
122
+ const section = content.slice(sectionStart, sectionEnd === -1 ? content.length : sectionEnd);
123
+ const paths = section.match(/`(docs\/[^`]+\.(?:md|yaml|graph\.yaml))`/g) || [];
124
+ for (const p of paths) {
125
+ mustRead.push(p.slice(1, -1));
126
+ }
127
+ }
128
+
129
+ return { task_slug: taskSlug, title, status, gates, must_read: mustRead };
130
+ }
131
+
132
+ function listMarkdownFiles(dir) {
133
+ if (!fs.existsSync(dir)) return [];
134
+ return fs
135
+ .readdirSync(dir, { withFileTypes: true })
136
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
137
+ .map((e) => path.join(dir, e.name));
138
+ }
139
+
140
+ /**
141
+ * 扫描业务仓并追加 HGM 事件。
142
+ */
143
+ export function ingestRepo(target, options = {}) {
144
+ const {
145
+ actor = 'system',
146
+ source = 'cli',
147
+ dryRun = false,
148
+ occurredAt = isoDate(),
149
+ } = options;
150
+
151
+ const events = [];
152
+ let seq = 0;
153
+
154
+ // RepositoryAdopted
155
+ const manifestPath = path.join(target, HGM_DIR, 'manifest.json');
156
+ if (fs.existsSync(manifestPath)) {
157
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
158
+ const repoSlug = path.basename(target);
159
+ events.push({
160
+ event_id: eventId(new Date(occurredAt), seq++),
161
+ type: 'RepositoryAdopted',
162
+ occurred_at: occurredAt,
163
+ actor,
164
+ subject: `repo:${repoSlug}`,
165
+ data: {
166
+ manifest_version: manifest.version,
167
+ preset: manifest.preset,
168
+ ide: manifest.ide || [],
169
+ },
170
+ source,
171
+ });
172
+ }
173
+
174
+ // Tasks + gates
175
+ const activeDir = path.join(target, 'docs/tasks/active');
176
+ for (const tf of listMarkdownFiles(activeDir)) {
177
+ const parsed = parseTaskMarkdown(fs.readFileSync(tf, 'utf8'), tf);
178
+ const taskSubject = `task:${parsed.task_slug}`;
179
+ events.push({
180
+ event_id: eventId(new Date(occurredAt), seq++),
181
+ type: 'TaskCreated',
182
+ occurred_at: occurredAt,
183
+ actor,
184
+ subject: taskSubject,
185
+ data: {
186
+ task_slug: parsed.task_slug,
187
+ title: parsed.title,
188
+ status: parsed.status,
189
+ path: path.relative(target, tf),
190
+ must_read: parsed.must_read,
191
+ },
192
+ source,
193
+ });
194
+
195
+ for (const g of parsed.gates) {
196
+ events.push({
197
+ event_id: eventId(new Date(occurredAt), seq++),
198
+ type: 'GateStatusChanged',
199
+ occurred_at: occurredAt,
200
+ actor,
201
+ subject: `gate:${parsed.task_slug}:${g.human_gate_id}`,
202
+ data: {
203
+ old_status: 'pending',
204
+ new_status: g.status,
205
+ task_slug: parsed.task_slug,
206
+ human_gate_id: g.human_gate_id,
207
+ blocks_hats: g.blocks_hats,
208
+ },
209
+ source,
210
+ });
211
+ if (g.status === 'rejected') {
212
+ events.push({
213
+ event_id: eventId(new Date(occurredAt), seq++),
214
+ type: 'HumanGateRejected',
215
+ occurred_at: occurredAt,
216
+ actor,
217
+ subject: `gate:${parsed.task_slug}:${g.human_gate_id}`,
218
+ data: {
219
+ task_slug: parsed.task_slug,
220
+ human_gate_id: g.human_gate_id,
221
+ reason: 'from task table',
222
+ returns_to_status: 'draft',
223
+ },
224
+ source,
225
+ });
226
+ }
227
+ }
228
+
229
+ for (const mr of parsed.must_read) {
230
+ events.push({
231
+ event_id: eventId(new Date(occurredAt), seq++),
232
+ type: 'InformArtifactUpdated',
233
+ occurred_at: occurredAt,
234
+ actor,
235
+ subject: `inform:${mr}`,
236
+ data: { path: mr, inform_schema: 'inform_graph.v3' },
237
+ source,
238
+ });
239
+ }
240
+ }
241
+
242
+ // Reviews
243
+ const reviewsDir = path.join(target, 'docs/harness/reviews');
244
+ if (fs.existsSync(reviewsDir)) {
245
+ for (const rf of listMarkdownFiles(reviewsDir)) {
246
+ const name = path.basename(rf, '.md');
247
+ const taskMatch = name.match(/^task_([a-zA-Z0-9_-]+)_/);
248
+ const roundMatch = name.match(/_(r\d|close)_/i);
249
+ const taskSlug = taskMatch ? taskMatch[1] : 'unknown';
250
+ const round = roundMatch ? roundMatch[1].toUpperCase() : 'R1';
251
+ events.push({
252
+ event_id: eventId(new Date(occurredAt), seq++),
253
+ type: 'AuditReviewProduced',
254
+ occurred_at: occurredAt,
255
+ actor,
256
+ subject: `task:${taskSlug}`,
257
+ data: {
258
+ review_path: path.relative(target, rf),
259
+ round,
260
+ },
261
+ source,
262
+ });
263
+ }
264
+ }
265
+
266
+ // Invokes
267
+ const invokesDir = path.join(target, 'docs/harness/invokes/by-task');
268
+ if (fs.existsSync(invokesDir)) {
269
+ const stack = [invokesDir];
270
+ while (stack.length > 0) {
271
+ const dir = stack.pop();
272
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
273
+ const full = path.join(dir, ent.name);
274
+ if (ent.isDirectory()) {
275
+ stack.push(full);
276
+ } else if (ent.isFile() && ent.name.endsWith('.md')) {
277
+ const rel = path.relative(target, full);
278
+ const taskMatch = rel.match(/by-task\/([a-zA-Z0-9_-]+)\//);
279
+ const taskSlug = taskMatch ? taskMatch[1] : 'unknown';
280
+ events.push({
281
+ event_id: eventId(new Date(occurredAt), seq++),
282
+ type: 'InvokeSnapshotCreated',
283
+ occurred_at: occurredAt,
284
+ actor,
285
+ subject: `task:${taskSlug}`,
286
+ data: { invoke_path: rel },
287
+ source,
288
+ });
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ if (!dryRun) {
295
+ for (const e of events) {
296
+ appendEvent(target, e);
297
+ }
298
+ }
299
+
300
+ return { events, count: events.length };
301
+ }
302
+
303
+ /**
304
+ * 从事件流投影图快照。
305
+ */
306
+ export function buildSnapshot(events) {
307
+ const nodes = new Map();
308
+ const edges = [];
309
+
310
+ function ensureNode(id, label, kind, extra = {}) {
311
+ if (!nodes.has(id)) {
312
+ nodes.set(id, { id, label, kind, ...extra });
313
+ }
314
+ return nodes.get(id);
315
+ }
316
+
317
+ function addEdge(from, to, type, props = {}) {
318
+ edges.push({ from, to, type, ...props });
319
+ }
320
+
321
+ const sorted = [...events].sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
322
+ const taskStatus = new Map();
323
+ const gateStatus = new Map();
324
+ const rejectedEvents = [];
325
+
326
+ for (const e of sorted) {
327
+ const { type, subject, data } = e;
328
+ switch (type) {
329
+ case 'RepositoryAdopted': {
330
+ ensureNode(subject, data.preset || subject, 'BusinessRepository', {
331
+ manifest_version: data.manifest_version,
332
+ });
333
+ break;
334
+ }
335
+ case 'TaskCreated': {
336
+ ensureNode(subject, data.title, 'Task', { status: data.status, path: data.path });
337
+ taskStatus.set(subject, data.status);
338
+ if (data.path) {
339
+ const parts = data.path.split('/');
340
+ for (let i = 1; i < parts.length; i += 1) {
341
+ const parent = parts.slice(0, i).join('/');
342
+ const child = parts.slice(0, i + 1).join('/');
343
+ ensureNode(`file:${child}`, parts[i], 'File');
344
+ addEdge(`file:${parent || '.'}`, `file:${child}`, 'CONTAINS');
345
+ }
346
+ }
347
+ break;
348
+ }
349
+ case 'TaskStatusChanged': {
350
+ ensureNode(subject, data.task_slug || subject, 'Task');
351
+ taskStatus.set(subject, data.new_status);
352
+ break;
353
+ }
354
+ case 'GateStatusChanged': {
355
+ ensureNode(subject, data.human_gate_id, 'HumanGate', {
356
+ task_slug: data.task_slug,
357
+ status: data.new_status,
358
+ });
359
+ gateStatus.set(subject, data.new_status);
360
+ addEdge(`task:${data.task_slug}`, subject, 'HAS_GATE', { since: e.occurred_at });
361
+ if (data.blocks_hats && data.blocks_hats.length > 0) {
362
+ for (const hat of data.blocks_hats) {
363
+ ensureNode(`hat:${hat}`, hat, 'Hat');
364
+ addEdge(subject, `hat:${hat}`, 'BLOCKS', { hat_id: hat });
365
+ }
366
+ }
367
+ break;
368
+ }
369
+ case 'HumanGateRejected': {
370
+ rejectedEvents.push(e);
371
+ break;
372
+ }
373
+ case 'AuditReviewProduced': {
374
+ ensureNode(subject, data.task_slug || subject, 'Task');
375
+ const reviewId = `review:${data.review_path}`;
376
+ ensureNode(reviewId, path.basename(data.review_path), 'AuditReview', {
377
+ round: data.round,
378
+ path: data.review_path,
379
+ });
380
+ addEdge(subject, reviewId, 'PRODUCED', { round: data.round, hat_id: data.hat_id });
381
+ break;
382
+ }
383
+ case 'InvokeSnapshotCreated': {
384
+ ensureNode(subject, data.task_slug || subject, 'Task');
385
+ const invokeId = `invoke:${data.invoke_path}`;
386
+ ensureNode(invokeId, path.basename(data.invoke_path), 'InvokeSnapshot', {
387
+ path: data.invoke_path,
388
+ });
389
+ addEdge(subject, invokeId, 'PRODUCED', { hat_id: data.hat_id });
390
+ break;
391
+ }
392
+ case 'FailureReportProduced': {
393
+ ensureNode(subject, data.task_slug || subject, 'Task');
394
+ const failureId = `failure:${data.failure_path}`;
395
+ ensureNode(failureId, path.basename(data.failure_path), 'FailureReport', {
396
+ path: data.failure_path,
397
+ });
398
+ addEdge(subject, failureId, 'FAILED_WITH', { at_gate: data.at_gate });
399
+ break;
400
+ }
401
+ case 'GateCheckRunCompleted': {
402
+ const gcrId = `gcr:${e.occurred_at}:${data.task_slug}`;
403
+ ensureNode(gcrId, `gate-check ${data.task_slug}`, 'GateCheckRun', {
404
+ exit_code: data.exit_code,
405
+ });
406
+ addEdge(gcrId, `task:${data.task_slug}`, 'CHECKED', { exit_code: data.exit_code });
407
+ break;
408
+ }
409
+ case 'SyncOperationCompleted': {
410
+ const syncId = `sync:${e.occurred_at}:${data.mode}`;
411
+ ensureNode(syncId, `sync ${data.mode}`, 'SyncOperation', {
412
+ mode: data.mode,
413
+ version: data.version,
414
+ });
415
+ addEdge(syncId, subject, 'SYNCED', {
416
+ mode: data.mode,
417
+ version: data.version,
418
+ files_touched: data.files_touched || [],
419
+ });
420
+ break;
421
+ }
422
+ case 'InformArtifactUpdated': {
423
+ ensureNode(subject, data.path, 'InformArtifact', {
424
+ inform_schema: data.inform_schema,
425
+ compiled_from: data.compiled_from,
426
+ });
427
+ break;
428
+ }
429
+ default:
430
+ break;
431
+ }
432
+ }
433
+
434
+ // MUST_READ 边:task → inform
435
+ for (const e of sorted) {
436
+ if (e.type === 'TaskCreated' && e.data.must_read) {
437
+ for (const mr of e.data.must_read) {
438
+ ensureNode(`inform:${mr}`, mr, 'InformArtifact');
439
+ addEdge(e.subject, `inform:${mr}`, 'MUST_READ');
440
+ }
441
+ }
442
+ }
443
+
444
+ return {
445
+ nodes: Object.fromEntries(nodes),
446
+ edges,
447
+ projections: {
448
+ task_status: Object.fromEntries(taskStatus),
449
+ gate_status: Object.fromEntries(gateStatus),
450
+ rejected_events: rejectedEvents.map((e) => e.event_id),
451
+ },
452
+ generated_at: isoDate(),
453
+ };
454
+ }
455
+
456
+ /**
457
+ * 公理检查:D2 · D3 · rejected→draft · S2 事件审计。
458
+ */
459
+ export function checkAxioms(snapshot) {
460
+ const violations = [];
461
+ const { nodes, edges, projections } = snapshot;
462
+ const nodeMap = new Map(Object.entries(nodes));
463
+
464
+ function outgoing(id, type) {
465
+ return edges.filter((e) => e.from === id && e.type === type);
466
+ }
467
+
468
+ function hasEdge(from, to, type) {
469
+ return edges.some((e) => e.from === from && e.to === to && e.type === type);
470
+ }
471
+
472
+ // D2: pending 的 HG-AUDIT-R1 BLOCKS 30 帽
473
+ for (const [id, node] of nodeMap) {
474
+ if (node.kind === 'HumanGate' && node.status === 'pending') {
475
+ const blocks30 = outgoing(id, 'BLOCKS').some((e) => String(e.hat_id).includes('30'));
476
+ if (blocks30) {
477
+ violations.push({
478
+ axiom: 'D2',
479
+ severity: 'error',
480
+ message: `gate ${id} pending 且阻塞 30 帽`,
481
+ node: id,
482
+ });
483
+ }
484
+ }
485
+ }
486
+
487
+ // D3: 30 首动作须关联 GateCheckRun CHECKED 边
488
+ for (const [taskId, status] of Object.entries(projections.task_status || {})) {
489
+ if (status === 'in_progress') {
490
+ const checked = edges.some(
491
+ (e) => e.to === taskId && e.type === 'CHECKED' && e.exit_code === 0,
492
+ );
493
+ if (!checked) {
494
+ violations.push({
495
+ axiom: 'D3',
496
+ severity: 'warn',
497
+ message: `task ${taskId} in_progress 但无通过 GateCheckRun`,
498
+ node: taskId,
499
+ });
500
+ }
501
+ }
502
+ }
503
+
504
+ // rejected→draft: rejected 后必须有 TaskStatusChanged(draft)
505
+ for (const eventId of projections.rejected_events || []) {
506
+ // snapshot 保留 event_id,但需事件流重放才能精确检查;这里只标记缺失 draft 回退
507
+ violations.push({
508
+ axiom: 'rejected→draft',
509
+ severity: 'warn',
510
+ message: `gate rejected 事件 ${eventId} 须后续 TaskStatusChanged(draft)`,
511
+ node: eventId,
512
+ });
513
+ }
514
+
515
+ // S2: SyncOperation 不得 touch S2 路径
516
+ const s2Prefixes = ['docs/tasks/', 'docs/harness/reviews/', 'docs/harness/invokes/by-task/'];
517
+ for (const edge of edges) {
518
+ if (edge.type === 'SYNCED') {
519
+ const files = edge.files_touched || [];
520
+ for (const f of files) {
521
+ if (s2Prefixes.some((p) => f.startsWith(p))) {
522
+ violations.push({
523
+ axiom: 'S2',
524
+ severity: 'error',
525
+ message: `sync 事件 touch S2 保护域: ${f}`,
526
+ node: edge.from,
527
+ });
528
+ }
529
+ }
530
+ }
531
+ }
532
+
533
+ return {
534
+ ok: violations.filter((v) => v.severity === 'error').length === 0,
535
+ violations,
536
+ };
537
+ }
538
+
539
+ /**
540
+ * 写入 snapshot.json。
541
+ */
542
+ export function writeSnapshot(target, snapshot) {
543
+ const p = snapshotPath(target);
544
+ ensureDir(path.dirname(p));
545
+ fs.writeFileSync(p, `${JSON.stringify(snapshot, null, 2)}\n`);
546
+ return p;
547
+ }
548
+
549
+ /**
550
+ * 幂等 ingest:先读已有事件,避免重复写入相同 subject+type 的关键事件。
551
+ */
552
+ export function ingestRepoIdempotent(target, options = {}) {
553
+ const existing = loadEvents(target);
554
+ const existingKeys = new Set(existing.map((e) => `${e.type}:${e.subject}`));
555
+ const { events, count } = ingestRepo(target, { ...options, dryRun: true });
556
+ const newEvents = events.filter((e) => !existingKeys.has(`${e.type}:${e.subject}`));
557
+ if (!options.dryRun) {
558
+ for (const e of newEvents) {
559
+ appendEvent(target, e);
560
+ }
561
+ }
562
+ return { events: newEvents, count: newEvents.length, skipped: count - newEvents.length };
563
+ }
package/ontology.yaml CHANGED
@@ -3,7 +3,7 @@
3
3
  # 冲突时以 Markdown 为准 · 供未来 harness ontology-check 使用
4
4
 
5
5
  version: "1.2"
6
- product_semver: "1.1.0"
6
+ product_semver: "2.0.0"
7
7
  license: MIT
8
8
 
9
9
  classes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyning/harness",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "cyning-harness discipline package · init / upgrade / check CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",