@jaimevalasek/aioson 1.9.3 → 1.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +237 -0
- package/README.md +44 -1
- package/package.json +1 -1
- package/src/cli.js +50 -1
- package/src/commands/chain-audit.js +156 -0
- package/src/commands/op-capture.js +146 -0
- package/src/commands/op-forget.js +54 -0
- package/src/commands/op-identity.js +145 -0
- package/src/commands/op-list.js +105 -0
- package/src/commands/op-migrate.js +158 -0
- package/src/commands/op-promote.js +66 -0
- package/src/commands/op-reinforce.js +73 -0
- package/src/commands/op-show.js +71 -0
- package/src/commands/op-stubs.js +67 -0
- package/src/commands/preflight.js +6 -2
- package/src/commands/runtime.js +178 -0
- package/src/commands/state-save.js +61 -0
- package/src/commands/sync-agents-preflight.js +117 -3
- package/src/commands/workflow-next.js +64 -0
- package/src/handoff-contract.js +25 -0
- package/src/i18n/messages/en.js +9 -0
- package/src/i18n/messages/es.js +9 -0
- package/src/i18n/messages/fr.js +9 -0
- package/src/i18n/messages/pt-BR.js +9 -0
- package/src/lib/agent-semantic-diff.js +199 -0
- package/src/neural-chain-agent-ingest.js +400 -0
- package/src/neural-chain-config.js +95 -0
- package/src/neural-chain-git-ingest.js +280 -0
- package/src/neural-chain-migration.js +61 -0
- package/src/neural-chain-noise-file.js +332 -0
- package/src/neural-chain-sanitize.js +0 -0
- package/src/neural-chain-telemetry.js +90 -0
- package/src/operator-memory/conflict.js +202 -0
- package/src/operator-memory/decay.js +157 -0
- package/src/operator-memory/decision.js +274 -0
- package/src/operator-memory/identity.js +109 -0
- package/src/operator-memory/index-md.js +170 -0
- package/src/operator-memory/loader.js +106 -0
- package/src/operator-memory/proposal.js +179 -0
- package/src/operator-memory/prune.js +81 -0
- package/src/operator-memory/slug.js +90 -0
- package/src/operator-memory/storage.js +121 -0
- package/src/preflight-engine.js +91 -1
- package/src/runtime-store.js +2 -0
- package/template/.aioson/agents/dev.md +1 -1
- package/template/.aioson/agents/deyvin.md +3 -3
- package/template/.aioson/agents/neo.md +23 -1
- package/template/.aioson/agents/product.md +1 -1
- package/template/.aioson/agents/setup.md +1 -1
- package/template/.aioson/docs/deyvin/pair-execution.md +1 -1
- package/template/.aioson/skills/process/decision-presentation/SKILL.md +9 -0
- package/template/AGENTS.md +23 -0
- package/template/CLAUDE.md +23 -0
- package/template/agents/_shared/memory-capture-directive.md +115 -0
|
@@ -967,6 +967,57 @@ async function activateStage(targetDir, state, locale, tool, explicitAgent = nul
|
|
|
967
967
|
};
|
|
968
968
|
}
|
|
969
969
|
|
|
970
|
+
/**
|
|
971
|
+
* F3 (workflow-handoff-integrity v1.9.6) — pending-decisions guard.
|
|
972
|
+
*
|
|
973
|
+
* Reads `.aioson/plans/{slug}/manifest.md` frontmatter. If `status` matches
|
|
974
|
+
* `pending-<X>-decisions`, throws a hard error recommending the agent that
|
|
975
|
+
* resolves those decisions. `--force` overrides.
|
|
976
|
+
*
|
|
977
|
+
* Whitelist (DD-02): known agents are [architect, product, pm, qa]. Unknown
|
|
978
|
+
* captured groups still block but are flagged as unrecognized so typos don't
|
|
979
|
+
* silently route to nonexistent agents.
|
|
980
|
+
*
|
|
981
|
+
* Errors:
|
|
982
|
+
* - WORKFLOW_NEXT_PENDING_DECISIONS — pending state detected, advance blocked.
|
|
983
|
+
*
|
|
984
|
+
* @param {string} targetDir Project root.
|
|
985
|
+
* @param {string|null} slug Feature slug (null in project mode → no-op).
|
|
986
|
+
* @param {boolean} force When true, skip the check (--force override).
|
|
987
|
+
* @returns {Promise<void>} Resolves silently when no pending decisions block; throws otherwise.
|
|
988
|
+
*/
|
|
989
|
+
const PENDING_STATE_WHITELIST = ['architect', 'product', 'pm', 'qa'];
|
|
990
|
+
|
|
991
|
+
async function assertManifestNotPending(targetDir, slug, force) {
|
|
992
|
+
if (force) return; // AC-F3-03 — explicit override.
|
|
993
|
+
if (!slug) return; // AC-F3-04 — no feature context, nothing to guard.
|
|
994
|
+
const manifestPath = path.join(targetDir, '.aioson', 'plans', slug, 'manifest.md');
|
|
995
|
+
let content;
|
|
996
|
+
try {
|
|
997
|
+
content = await fs.readFile(manifestPath, 'utf8');
|
|
998
|
+
} catch {
|
|
999
|
+
return; // AC-F3-04 — no manifest (e.g. MICRO without Sheldon stage), skip.
|
|
1000
|
+
}
|
|
1001
|
+
const status = parseFrontmatterValue(content, 'status');
|
|
1002
|
+
if (!status) return; // No status field → nothing to assert.
|
|
1003
|
+
const match = String(status).match(/^pending-(.+)-decisions$/);
|
|
1004
|
+
if (!match) return; // AC-F3-02 — only pending-*-decisions pattern blocks.
|
|
1005
|
+
const captured = match[1].toLowerCase();
|
|
1006
|
+
const known = PENDING_STATE_WHITELIST.includes(captured);
|
|
1007
|
+
const recommendation = known
|
|
1008
|
+
? `Próximo agente recomendado: @${captured}.`
|
|
1009
|
+
: `Estado desconhecido '${captured}' — whitelist atual: ${PENDING_STATE_WHITELIST.map((a) => `@${a}`).join(', ')}.`;
|
|
1010
|
+
const err = new Error(
|
|
1011
|
+
`[workflow:next] Gate blocked: ${slug} manifest tem status 'pending-${captured}-decisions'. ${recommendation} Use --force para override.`
|
|
1012
|
+
);
|
|
1013
|
+
err.code = 'WORKFLOW_NEXT_PENDING_DECISIONS';
|
|
1014
|
+
err.slug = slug;
|
|
1015
|
+
err.pendingState = captured;
|
|
1016
|
+
err.knownState = known;
|
|
1017
|
+
throw err;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
|
|
970
1021
|
async function runWorkflowNext({ args, options, logger, t }) {
|
|
971
1022
|
if (options.status || options.suggest) {
|
|
972
1023
|
const { runWorkflowStatus } = require('./workflow-status');
|
|
@@ -988,6 +1039,17 @@ async function runWorkflowNext({ args, options, logger, t }) {
|
|
|
988
1039
|
let completedStage = null;
|
|
989
1040
|
|
|
990
1041
|
if (options.complete || options['complete-current']) {
|
|
1042
|
+
// F3 (workflow-handoff-integrity v1.9.6) — pending-decisions guard.
|
|
1043
|
+
// Hard error if sheldon manifest has unresolved decisions; --force overrides.
|
|
1044
|
+
try {
|
|
1045
|
+
await assertManifestNotPending(targetDir, state.featureSlug, Boolean(options.force));
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
if (err && err.code === 'WORKFLOW_NEXT_PENDING_DECISIONS') {
|
|
1048
|
+
logErrorLine(err.message);
|
|
1049
|
+
}
|
|
1050
|
+
throw err;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
991
1053
|
let finalized;
|
|
992
1054
|
try {
|
|
993
1055
|
finalized = await finalizeCurrentStage(
|
|
@@ -1274,6 +1336,8 @@ module.exports = {
|
|
|
1274
1336
|
applySkip,
|
|
1275
1337
|
activateStage,
|
|
1276
1338
|
runWorkflowNext,
|
|
1339
|
+
assertManifestNotPending,
|
|
1340
|
+
PENDING_STATE_WHITELIST,
|
|
1277
1341
|
shouldRouteToValidator,
|
|
1278
1342
|
detectUnsubstantiatedCompletions
|
|
1279
1343
|
};
|
package/src/handoff-contract.js
CHANGED
|
@@ -405,6 +405,30 @@ async function getBlockingRevisions(targetDir, featureSlug) {
|
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* getCanonicalArtifactsForAgent
|
|
410
|
+
*
|
|
411
|
+
* Public lookup helper used by `runAgentDone` (F2 — workflow-handoff-integrity v1.9.5)
|
|
412
|
+
* to determine which artifact paths an agent is expected to produce. Returns the
|
|
413
|
+
* paths declared by the agent's contract in CONTRACTS, fully resolved against the
|
|
414
|
+
* workflow state.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} agent Agent name (with or without leading `@`).
|
|
417
|
+
* @param {string} targetDir Project root path (absolute).
|
|
418
|
+
* @param {object} state Workflow state: { mode, featureSlug, classification }.
|
|
419
|
+
* @returns {string[]|null} Array of absolute artifact paths, or `null` when the
|
|
420
|
+
* agent is not registered in CONTRACTS. An empty array
|
|
421
|
+
* means the agent produces no canonical artifact (e.g.
|
|
422
|
+
* `@committer`, `@dev`) — auto-emit should be skipped.
|
|
423
|
+
*/
|
|
424
|
+
async function getCanonicalArtifactsForAgent(agent, targetDir, state) {
|
|
425
|
+
const normalizedAgent = String(agent || '').replace(/^@/, '').toLowerCase();
|
|
426
|
+
if (!normalizedAgent) return null;
|
|
427
|
+
const contract = CONTRACTS[normalizedAgent];
|
|
428
|
+
if (!contract) return null;
|
|
429
|
+
return await resolveArtifacts(contract, targetDir, state || {});
|
|
430
|
+
}
|
|
431
|
+
|
|
408
432
|
module.exports = {
|
|
409
433
|
parseFrontmatterValue,
|
|
410
434
|
readProjectClassification,
|
|
@@ -413,5 +437,6 @@ module.exports = {
|
|
|
413
437
|
validateHandoffContract,
|
|
414
438
|
formatContractError,
|
|
415
439
|
getBlockingRevisions,
|
|
440
|
+
getCanonicalArtifactsForAgent,
|
|
416
441
|
CONTRACTS
|
|
417
442
|
};
|
package/src/i18n/messages/en.js
CHANGED
|
@@ -26,6 +26,15 @@ module.exports = {
|
|
|
26
26
|
'aioson context:pack [path] [--agent=<agent>] [--goal=<text>] [--module=<module-or-folder>] [--max-files=8] [--json] [--locale=en]',
|
|
27
27
|
help_context_load:
|
|
28
28
|
'aioson context:load [path] --target=<rule|brain>:<slug> --agent=<name> [--batch="slug1,slug2"] [--feature=<slug>] [--classification=<MICRO|SMALL|MEDIUM>] [--verbose] [--json] [--locale=en]',
|
|
29
|
+
help_chain_audit:
|
|
30
|
+
'aioson chain:audit <file> [path] [--limit=N] [--feature=<slug>] [--json] [--locale=en]',
|
|
31
|
+
chain_audit: {
|
|
32
|
+
file_required: 'chain:audit requires a file path. Usage: aioson chain:audit <file> [--limit=N] [--feature=<slug>] [--json]',
|
|
33
|
+
runtime_unavailable: 'chain:audit runtime db unavailable: {error}',
|
|
34
|
+
query_failed: 'chain:audit failed to query chain_edges: {error}',
|
|
35
|
+
no_impacts: 'chain:audit {file} → no impacts detected ({duration}ms)',
|
|
36
|
+
results_header: 'chain:audit {file} → {count} impact(s) ({duration}ms):'
|
|
37
|
+
},
|
|
29
38
|
context_load: {
|
|
30
39
|
target_required: 'context:load requires --target=<rule|brain>:<slug>.',
|
|
31
40
|
agent_required: 'context:load requires --agent=<name>.',
|
package/src/i18n/messages/es.js
CHANGED
|
@@ -27,6 +27,15 @@ module.exports = {
|
|
|
27
27
|
'aioson context:pack [path] [--agent=<agente>] [--goal=<texto>] [--module=<modulo-o-carpeta>] [--max-files=8] [--json] [--locale=es]',
|
|
28
28
|
help_context_load:
|
|
29
29
|
'aioson context:load [path] --target=<rule|brain>:<slug> --agent=<nombre> [--batch="slug1,slug2"] [--feature=<slug>] [--classification=<MICRO|SMALL|MEDIUM>] [--verbose] [--json] [--locale=es]',
|
|
30
|
+
help_chain_audit:
|
|
31
|
+
'aioson chain:audit <archivo> [path] [--limit=N] [--feature=<slug>] [--json] [--locale=es]',
|
|
32
|
+
chain_audit: {
|
|
33
|
+
file_required: 'chain:audit requiere una ruta de archivo. Uso: aioson chain:audit <archivo> [--limit=N] [--feature=<slug>] [--json]',
|
|
34
|
+
runtime_unavailable: 'chain:audit runtime db no disponible: {error}',
|
|
35
|
+
query_failed: 'chain:audit falló al consultar chain_edges: {error}',
|
|
36
|
+
no_impacts: 'chain:audit {file} → ningún impacto detectado ({duration}ms)',
|
|
37
|
+
results_header: 'chain:audit {file} → {count} impacto(s) ({duration}ms):'
|
|
38
|
+
},
|
|
30
39
|
context_load: {
|
|
31
40
|
target_required: 'context:load requiere --target=<rule|brain>:<slug>.',
|
|
32
41
|
agent_required: 'context:load requiere --agent=<nombre>.',
|
package/src/i18n/messages/fr.js
CHANGED
|
@@ -27,6 +27,15 @@ module.exports = {
|
|
|
27
27
|
'aioson context:pack [path] [--agent=<agent>] [--goal=<texte>] [--module=<module-ou-dossier>] [--max-files=8] [--json] [--locale=fr]',
|
|
28
28
|
help_context_load:
|
|
29
29
|
'aioson context:load [path] --target=<rule|brain>:<slug> --agent=<nom> [--batch="slug1,slug2"] [--feature=<slug>] [--classification=<MICRO|SMALL|MEDIUM>] [--verbose] [--json] [--locale=fr]',
|
|
30
|
+
help_chain_audit:
|
|
31
|
+
'aioson chain:audit <fichier> [path] [--limit=N] [--feature=<slug>] [--json] [--locale=fr]',
|
|
32
|
+
chain_audit: {
|
|
33
|
+
file_required: 'chain:audit exige un chemin de fichier. Usage : aioson chain:audit <fichier> [--limit=N] [--feature=<slug>] [--json]',
|
|
34
|
+
runtime_unavailable: 'chain:audit runtime db indisponible : {error}',
|
|
35
|
+
query_failed: 'chain:audit échec de la requête chain_edges : {error}',
|
|
36
|
+
no_impacts: 'chain:audit {file} → aucun impact détecté ({duration}ms)',
|
|
37
|
+
results_header: 'chain:audit {file} → {count} impact(s) ({duration}ms) :'
|
|
38
|
+
},
|
|
30
39
|
context_load: {
|
|
31
40
|
target_required: 'context:load exige --target=<rule|brain>:<slug>.',
|
|
32
41
|
agent_required: 'context:load exige --agent=<nom>.',
|
|
@@ -27,6 +27,15 @@ module.exports = {
|
|
|
27
27
|
'aioson context:pack [path] [--agent=<agente>] [--goal=<texto>] [--module=<modulo-ou-pasta>] [--max-files=8] [--json] [--locale=pt-BR]',
|
|
28
28
|
help_context_load:
|
|
29
29
|
'aioson context:load [path] --target=<rule|brain>:<slug> --agent=<nome> [--batch="slug1,slug2"] [--feature=<slug>] [--classification=<MICRO|SMALL|MEDIUM>] [--verbose] [--json] [--locale=pt-BR]',
|
|
30
|
+
help_chain_audit:
|
|
31
|
+
'aioson chain:audit <arquivo> [path] [--limit=N] [--feature=<slug>] [--json] [--locale=pt-BR]',
|
|
32
|
+
chain_audit: {
|
|
33
|
+
file_required: 'chain:audit exige um caminho de arquivo. Uso: aioson chain:audit <arquivo> [--limit=N] [--feature=<slug>] [--json]',
|
|
34
|
+
runtime_unavailable: 'chain:audit runtime db indisponível: {error}',
|
|
35
|
+
query_failed: 'chain:audit falhou ao consultar chain_edges: {error}',
|
|
36
|
+
no_impacts: 'chain:audit {file} → nenhum impacto detectado ({duration}ms)',
|
|
37
|
+
results_header: 'chain:audit {file} → {count} impacto(s) ({duration}ms):'
|
|
38
|
+
},
|
|
30
39
|
context_load: {
|
|
31
40
|
target_required: 'context:load exige --target=<rule|brain>:<slug>.',
|
|
32
41
|
agent_required: 'context:load exige --agent=<nome>.',
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* agent-semantic-diff — pure helpers for detecting semantic drift between
|
|
5
|
+
* workspace `.aioson/agents/{agent}.md` and template `template/.aioson/agents/{agent}.md`.
|
|
6
|
+
*
|
|
7
|
+
* F4 / T5 (workflow-handoff-integrity v1.9.8). Designed to catch the 981a8fd-style
|
|
8
|
+
* migration where the workspace agent prompt was updated but the template was not.
|
|
9
|
+
* The existing `sync-agents-preflight#checkParity` only inspects the `## Feature dossier`
|
|
10
|
+
* section length — this helper extends that lens to headers, section content, and
|
|
11
|
+
* frontmatter.
|
|
12
|
+
*
|
|
13
|
+
* Three diff strategies (per DD-03):
|
|
14
|
+
* - diffHeaders — section list (presence + order) at `##` and `###` levels
|
|
15
|
+
* - diffSectionContent — hash-based diff of section bodies (catches content drift)
|
|
16
|
+
* - diffFrontmatter — field-level YAML-ish frontmatter comparison
|
|
17
|
+
*
|
|
18
|
+
* Plain text body diff is deliberately skipped — too noisy for typos/cosmetic edits.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const crypto = require('node:crypto');
|
|
22
|
+
|
|
23
|
+
// ─── Frontmatter ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function extractFrontmatter(content) {
|
|
26
|
+
const match = String(content || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
30
|
+
const idx = line.indexOf(':');
|
|
31
|
+
if (idx === -1) continue;
|
|
32
|
+
const key = line.slice(0, idx).trim();
|
|
33
|
+
if (!key) continue;
|
|
34
|
+
out[key] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function diffFrontmatter(workspaceContent, templateContent) {
|
|
40
|
+
const ws = extractFrontmatter(workspaceContent);
|
|
41
|
+
const tpl = extractFrontmatter(templateContent);
|
|
42
|
+
if (ws === null && tpl === null) return null; // both have no frontmatter
|
|
43
|
+
const missingInTemplate = [];
|
|
44
|
+
const missingInWorkspace = [];
|
|
45
|
+
const valueChanged = [];
|
|
46
|
+
const wsObj = ws || {};
|
|
47
|
+
const tplObj = tpl || {};
|
|
48
|
+
for (const key of Object.keys(wsObj)) {
|
|
49
|
+
if (!(key in tplObj)) {
|
|
50
|
+
missingInTemplate.push(key);
|
|
51
|
+
} else if (wsObj[key] !== tplObj[key]) {
|
|
52
|
+
valueChanged.push({ key, workspace: wsObj[key], template: tplObj[key] });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const key of Object.keys(tplObj)) {
|
|
56
|
+
if (!(key in wsObj)) missingInWorkspace.push(key);
|
|
57
|
+
}
|
|
58
|
+
if (missingInTemplate.length + missingInWorkspace.length + valueChanged.length === 0) return null;
|
|
59
|
+
return { missingInTemplate, missingInWorkspace, valueChanged };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Headers ─────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract ## and ### headers in document order.
|
|
66
|
+
* Skips anything inside fenced code blocks.
|
|
67
|
+
*/
|
|
68
|
+
function extractHeaders(content) {
|
|
69
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
70
|
+
const headers = [];
|
|
71
|
+
let inFence = false;
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
if (/^```/.test(line.trim())) {
|
|
74
|
+
inFence = !inFence;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (inFence) continue;
|
|
78
|
+
const m = line.match(/^(##{1,2})\s+(.+?)\s*$/);
|
|
79
|
+
if (m) headers.push(m[2].trim());
|
|
80
|
+
}
|
|
81
|
+
return headers;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function diffHeaders(workspaceContent, templateContent) {
|
|
85
|
+
const ws = extractHeaders(workspaceContent);
|
|
86
|
+
const tpl = extractHeaders(templateContent);
|
|
87
|
+
const wsSet = new Set(ws);
|
|
88
|
+
const tplSet = new Set(tpl);
|
|
89
|
+
const missingInTemplate = ws.filter((h) => !tplSet.has(h));
|
|
90
|
+
const missingInWorkspace = tpl.filter((h) => !wsSet.has(h));
|
|
91
|
+
// Order check: of the headers present in both, do they appear in the same sequence?
|
|
92
|
+
const common = ws.filter((h) => tplSet.has(h));
|
|
93
|
+
const commonInTpl = tpl.filter((h) => wsSet.has(h));
|
|
94
|
+
const reordered = common.length === commonInTpl.length
|
|
95
|
+
&& common.some((h, i) => h !== commonInTpl[i]);
|
|
96
|
+
if (missingInTemplate.length + missingInWorkspace.length === 0 && !reordered) return null;
|
|
97
|
+
return { missingInTemplate, missingInWorkspace, reordered };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Section content (hash-based) ────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split content into Map<header, body>. Body is normalized (trimmed lines,
|
|
104
|
+
* collapsed whitespace) before hashing to avoid cosmetic-only false positives.
|
|
105
|
+
*/
|
|
106
|
+
function extractSections(content) {
|
|
107
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
108
|
+
const sections = new Map();
|
|
109
|
+
let current = '__preamble__';
|
|
110
|
+
let body = [];
|
|
111
|
+
let inFence = false;
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (/^```/.test(line.trim())) {
|
|
114
|
+
inFence = !inFence;
|
|
115
|
+
body.push(line);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const headerMatch = !inFence && line.match(/^(##{1,2})\s+(.+?)\s*$/);
|
|
119
|
+
if (headerMatch) {
|
|
120
|
+
sections.set(current, body.join('\n'));
|
|
121
|
+
current = headerMatch[2].trim();
|
|
122
|
+
body = [];
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
body.push(line);
|
|
126
|
+
}
|
|
127
|
+
sections.set(current, body.join('\n'));
|
|
128
|
+
return sections;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeBody(body) {
|
|
132
|
+
return String(body || '')
|
|
133
|
+
.split(/\r?\n/)
|
|
134
|
+
.map((l) => l.replace(/\s+/g, ' ').trim())
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hashBody(body) {
|
|
140
|
+
return crypto.createHash('sha256').update(normalizeBody(body)).digest('hex').slice(0, 16);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function diffSectionContent(workspaceContent, templateContent) {
|
|
144
|
+
const ws = extractSections(workspaceContent);
|
|
145
|
+
const tpl = extractSections(templateContent);
|
|
146
|
+
const diverged = [];
|
|
147
|
+
for (const [header, wsBody] of ws.entries()) {
|
|
148
|
+
if (!tpl.has(header)) continue; // missing-header case handled by diffHeaders
|
|
149
|
+
const wsHash = hashBody(wsBody);
|
|
150
|
+
const tplHash = hashBody(tpl.get(header));
|
|
151
|
+
if (wsHash !== tplHash) {
|
|
152
|
+
diverged.push({ header, workspaceHash: wsHash, templateHash: tplHash });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return diverged.length > 0 ? diverged : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Aggregate runner ────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run all three diff strategies on a single agent file pair.
|
|
162
|
+
* Returns null when no drift is detected, otherwise an issue object.
|
|
163
|
+
*/
|
|
164
|
+
function diffAgentFile(workspaceContent, templateContent) {
|
|
165
|
+
// AC-T5-08: missing-on-one-side detection.
|
|
166
|
+
if (!workspaceContent && !templateContent) return null;
|
|
167
|
+
if (!workspaceContent || !templateContent) {
|
|
168
|
+
return {
|
|
169
|
+
missingFile: !workspaceContent ? 'workspace' : 'template',
|
|
170
|
+
missingInTemplate: [], missingInWorkspace: [], reordered: false,
|
|
171
|
+
divergedSections: [],
|
|
172
|
+
frontmatter: null
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const headers = diffHeaders(workspaceContent, templateContent);
|
|
176
|
+
const sections = diffSectionContent(workspaceContent, templateContent);
|
|
177
|
+
const frontmatter = diffFrontmatter(workspaceContent, templateContent);
|
|
178
|
+
if (!headers && !sections && !frontmatter) return null;
|
|
179
|
+
return {
|
|
180
|
+
missingFile: null,
|
|
181
|
+
missingInTemplate: headers?.missingInTemplate || [],
|
|
182
|
+
missingInWorkspace: headers?.missingInWorkspace || [],
|
|
183
|
+
reordered: headers?.reordered || false,
|
|
184
|
+
divergedSections: sections || [],
|
|
185
|
+
frontmatter: frontmatter || null
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
extractFrontmatter,
|
|
191
|
+
extractHeaders,
|
|
192
|
+
extractSections,
|
|
193
|
+
diffFrontmatter,
|
|
194
|
+
diffHeaders,
|
|
195
|
+
diffSectionContent,
|
|
196
|
+
diffAgentFile,
|
|
197
|
+
hashBody,
|
|
198
|
+
normalizeBody
|
|
199
|
+
};
|