@mmerterden/multi-agent-pipeline 20.11.1 → 20.13.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/CHANGELOG.md +51 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/docs/facts.json +2 -2
- package/install/_common.mjs +1 -1
- package/install/_plugin-skills.mjs +13 -9
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +9 -0
- package/install/codex.mjs +12 -12
- package/manifest.json +74 -67
- package/package.json +5 -5
- package/pipeline/agents/code-reviewer.md +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +7 -3
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +57 -7
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -0
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/doctor/SKILL.md +6 -0
- package/pipeline/commands/multi-agent/feedback/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/forget/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/model/SKILL.md +7 -0
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +5 -0
- package/pipeline/commands/multi-agent/review-analysis/SKILL.md +7 -2
- package/pipeline/commands/multi-agent/sync/SKILL.md +5 -6
- package/pipeline/lib/agent-model-sync.mjs +95 -0
- package/pipeline/lib/analysis-jira-write.sh +81 -27
- package/pipeline/lib/analysis-quality.mjs +261 -0
- package/pipeline/lib/analysis-sections.mjs +105 -0
- package/pipeline/lib/jira-epic-link.sh +54 -0
- package/pipeline/lib/model-rung.sh +7 -0
- package/pipeline/multi-agent-refs/analysis/locked.md +4 -4
- package/pipeline/multi-agent-refs/analysis/redesign.md +3 -1
- package/pipeline/multi-agent-refs/analysis/render.md +20 -21
- package/pipeline/multi-agent-refs/analysis/resolve.md +1 -1
- package/pipeline/multi-agent-refs/analysis/review.md +16 -3
- package/pipeline/multi-agent-refs/analysis/synthesis.md +10 -2
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +44 -9
- package/pipeline/multi-agent-refs/analysis-template.md +21 -14
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -0
- package/pipeline/multi-agent-refs/features/analysis-jira.md +69 -19
- package/pipeline/multi-agent-refs/features/doctor.md +10 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +10 -0
- package/pipeline/multi-agent-refs/features/phone-api.md +1 -1
- package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
- package/pipeline/multi-agent-refs/features/usage-reporting.md +13 -0
- package/pipeline/schemas/analysis-spec.schema.json +22 -0
- package/pipeline/schemas/prefs.schema.json +22 -1
- package/pipeline/scripts/analysis-story-body.mjs +210 -0
- package/pipeline/scripts/analysis-story-tree.mjs +73 -12
- package/pipeline/scripts/analysis-tickets-writeback.mjs +101 -0
- package/pipeline/scripts/build-references.mjs +14 -1
- package/pipeline/scripts/confluence-readback.mjs +148 -0
- package/pipeline/scripts/doctor.mjs +48 -0
- package/pipeline/scripts/jira-wiki-escape.mjs +40 -0
- package/pipeline/scripts/migrate-prefs.mjs +14 -0
- package/pipeline/scripts/unattended_policy.py +12 -5
- package/pipeline/scripts/usage-report.mjs +84 -9
- package/pipeline/scripts/validate-analysis-doc.mjs +120 -70
- package/pipeline/skills/.skill-manifest.json +8 -8
- package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +6 -1
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +56 -6
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +6 -0
- package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +6 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -0
- package/pipeline/skills/shared/core/multi-agent-review-analysis/SKILL.md +5 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -0
|
@@ -99,6 +99,7 @@ const CHECK_IDS = [
|
|
|
99
99
|
"mcp-surface",
|
|
100
100
|
"disk-space",
|
|
101
101
|
"worktree-residue",
|
|
102
|
+
"usage-report",
|
|
102
103
|
];
|
|
103
104
|
|
|
104
105
|
// Only under `--profile server`. Kept in their own list so `--list-checks`
|
|
@@ -883,6 +884,52 @@ function checkMcpSurface() {
|
|
|
883
884
|
);
|
|
884
885
|
}
|
|
885
886
|
|
|
887
|
+
// usage-report.mjs runs detached with its output discarded, so the record it
|
|
888
|
+
// writes after every send attempt is the one place a broken reporting path
|
|
889
|
+
// becomes visible.
|
|
890
|
+
const USAGE_STEPS = {
|
|
891
|
+
"no-login": () => `run node ${join(HERE, "usage-identity.mjs")} --refresh`,
|
|
892
|
+
"no-token": () => `run node ${join(HERE, "usage-register.mjs")}`,
|
|
893
|
+
"endpoint-not-allowed": () => "set usageLog.endpoint to an https address",
|
|
894
|
+
rejected: () => `run node ${join(HERE, "usage-register.mjs")}`,
|
|
895
|
+
"not-accepted": () => `run node ${join(HERE, "usage-identity.mjs")} --refresh`,
|
|
896
|
+
redirected: (r) =>
|
|
897
|
+
r.location
|
|
898
|
+
? `set usageLog.endpoint to the address on ${r.location} it redirects to`
|
|
899
|
+
: `run node ${join(HERE, "migrate-prefs.mjs")}`,
|
|
900
|
+
failed: () => "re-run a phase once the reporting endpoint is reachable",
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
function checkUsageReport() {
|
|
904
|
+
const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
|
|
905
|
+
const usage = prefs?.global?.usageLog;
|
|
906
|
+
if (usage?.enabled !== true || usage?.optOut === true) {
|
|
907
|
+
skip("usage-report", "usage reporting is off");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const last = readJson(join(CLAUDE, "logs", "multi-agent", "usage-last-send.json"));
|
|
911
|
+
if (!last || typeof last.outcome !== "string") {
|
|
912
|
+
skip("usage-report", "no report has been sent from this machine yet");
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (last.outcome === "accepted") {
|
|
916
|
+
ok("usage-report");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const key = last.outcome === "skipped" ? last.reason : last.outcome;
|
|
920
|
+
const stepFor = USAGE_STEPS[key] ?? USAGE_STEPS.failed;
|
|
921
|
+
const detail = [last.status, last.reason, last.location && `to ${last.location}`]
|
|
922
|
+
.filter(Boolean)
|
|
923
|
+
.join(", ");
|
|
924
|
+
const when = last.t ? ` at ${last.t}` : "";
|
|
925
|
+
report(
|
|
926
|
+
"usage-report",
|
|
927
|
+
"WARN",
|
|
928
|
+
`the last usage report${last.id ? ` for ${last.id}` : ""} was ${last.outcome}${detail ? ` (${detail})` : ""}${when}, so runs are missing from the usage panel`,
|
|
929
|
+
stepFor(last),
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
886
933
|
function checkDiskSpace() {
|
|
887
934
|
let freeGb = null;
|
|
888
935
|
try {
|
|
@@ -1175,6 +1222,7 @@ function main() {
|
|
|
1175
1222
|
checkMcpSurface();
|
|
1176
1223
|
checkDiskSpace();
|
|
1177
1224
|
checkWorktreeResidue();
|
|
1225
|
+
checkUsageReport();
|
|
1178
1226
|
if (PROFILE === "server") checkServerProfile(resolved);
|
|
1179
1227
|
|
|
1180
1228
|
const blocked = results.filter((r) => r.severity === "BLOCK");
|
|
@@ -72,6 +72,46 @@ export function findUnescaped(text) {
|
|
|
72
72
|
return hits;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Every character that opens wiki markup: macros {..}, links and mentions [..],
|
|
76
|
+
// table cells |, images !..!, and inline formatting * _ ^ ~ + - #.
|
|
77
|
+
const MARKUP = /[\\{}[\]|!*_^~+\-#]/g;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Escape plain text taken from a document so Jira renders it literally. The
|
|
81
|
+
* generator writes its own markup around the result; nothing inside the result
|
|
82
|
+
* can open a macro, a mention, an image, a link or formatting.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function escapeJiraWiki(text) {
|
|
88
|
+
const escaped = String(text ?? "")
|
|
89
|
+
.replace(MARKUP, (c) => `\\${c}`)
|
|
90
|
+
.replace(/^(h[1-6]|bq)\./gm, "$1\\.");
|
|
91
|
+
return escapeJiraEmoticons(escaped);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A wiki link to an http(s) URL, or the escaped label alone for any other
|
|
96
|
+
* scheme. The URL is percent-encoded where a character would end the link.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} label
|
|
99
|
+
* @param {string} url
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function wikiLink(label, url) {
|
|
103
|
+
const text = escapeJiraWiki(label);
|
|
104
|
+
let u;
|
|
105
|
+
try {
|
|
106
|
+
u = new URL(url);
|
|
107
|
+
} catch {
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return text;
|
|
111
|
+
const safe = u.href.replace(/[|[\]{}!\s]/g, (c) => encodeURIComponent(c));
|
|
112
|
+
return `[${text}|${safe}]`;
|
|
113
|
+
}
|
|
114
|
+
|
|
75
115
|
async function readStdin() {
|
|
76
116
|
const chunks = [];
|
|
77
117
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { runMain } from "../lib/fatal.mjs";
|
|
18
|
+
import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
|
|
18
19
|
import { lockTimeoutFrom, withFileLockSync, writeJsonAtomicSync } from "../lib/json-file-lock.mjs";
|
|
19
20
|
|
|
20
21
|
const DEFAULT_FILE = path.join(os.homedir(), ".claude", "multi-agent-preferences.json");
|
|
@@ -445,9 +446,22 @@ function migrate(prefs) {
|
|
|
445
446
|
// reportContent / wikiScope structure. Idempotent - safe to re-run.
|
|
446
447
|
migrateReportChannels(out, changes);
|
|
447
448
|
|
|
449
|
+
// 13. usageLog.endpoint on the platform host: it answers every request with a
|
|
450
|
+
// redirect, and the usage clients never follow one with a credential attached.
|
|
451
|
+
migrateUsageEndpoint(out, changes);
|
|
452
|
+
|
|
448
453
|
return { out, changes, alreadyMigrated: changes.length === 0 };
|
|
449
454
|
}
|
|
450
455
|
|
|
456
|
+
function migrateUsageEndpoint(out, changes) {
|
|
457
|
+
const usage = out.global.usageLog;
|
|
458
|
+
if (!usage || typeof usage !== "object" || typeof usage.endpoint !== "string") return;
|
|
459
|
+
const next = canonicalEndpoint(usage.endpoint);
|
|
460
|
+
if (next === usage.endpoint) return;
|
|
461
|
+
changes.push(`usageLog.endpoint: ${usage.endpoint} → ${next}`);
|
|
462
|
+
usage.endpoint = next;
|
|
463
|
+
}
|
|
464
|
+
|
|
451
465
|
// v5.7+ sub-migration: legacy jiraCommentDefault / confluenceDefault / wikiDefault booleans
|
|
452
466
|
// → new reportChannels {pr,jira,confluence,wiki} object + wikiScope array. Also initializes
|
|
453
467
|
// reportContent + autopilotReportTimeoutSeconds with schema defaults. Old fields are removed
|
|
@@ -570,15 +570,22 @@ def _run_base_sha(env=None):
|
|
|
570
570
|
return sha if re.match(r"^[0-9a-f]{7,40}$", sha) else None
|
|
571
571
|
|
|
572
572
|
|
|
573
|
+
_TOP_CACHE = {}
|
|
574
|
+
|
|
575
|
+
|
|
573
576
|
def _worktree_top(d):
|
|
577
|
+
if d in _TOP_CACHE:
|
|
578
|
+
return _TOP_CACHE[d]
|
|
574
579
|
try:
|
|
575
580
|
import subprocess
|
|
576
581
|
|
|
577
582
|
r = subprocess.run(["git", "-C", d, "rev-parse", "--show-toplevel"],
|
|
578
583
|
capture_output=True, text=True, timeout=3)
|
|
579
|
-
|
|
584
|
+
top = os.path.realpath(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else None
|
|
580
585
|
except Exception:
|
|
581
|
-
|
|
586
|
+
top = None
|
|
587
|
+
_TOP_CACHE[d] = top
|
|
588
|
+
return top
|
|
582
589
|
|
|
583
590
|
|
|
584
591
|
def _tracked_clean(path, env=None, cwd=None):
|
|
@@ -609,11 +616,11 @@ def _tracked_clean(path, env=None, cwd=None):
|
|
|
609
616
|
|
|
610
617
|
# The file's path relative to the worktree root, so the base lookup
|
|
611
618
|
# names it correctly and a file outside this worktree is rejected.
|
|
612
|
-
top =
|
|
619
|
+
top = _worktree_top(d)
|
|
613
620
|
ls = g("ls-files", "--error-unmatch", "--", name)
|
|
614
621
|
st = g("status", "--porcelain", "--untracked-files=all", "--", name)
|
|
615
|
-
in_worktree = top
|
|
616
|
-
rel = os.path.relpath(p,
|
|
622
|
+
in_worktree = top is not None
|
|
623
|
+
rel = os.path.relpath(p, top) if in_worktree else None
|
|
617
624
|
at_base = (
|
|
618
625
|
g("cat-file", "-e", f"{base}:{rel}").returncode == 0
|
|
619
626
|
and g("diff", "--quiet", base, "--", name).returncode == 0
|
|
@@ -27,7 +27,15 @@
|
|
|
27
27
|
* @module pipeline/scripts/usage-report
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
readFileSync,
|
|
32
|
+
existsSync,
|
|
33
|
+
readdirSync,
|
|
34
|
+
appendFileSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
renameSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
} from "fs";
|
|
31
39
|
import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
|
|
32
40
|
import { homedir, platform } from "os";
|
|
33
41
|
import { dirname, join } from "path";
|
|
@@ -709,27 +717,78 @@ function buildEvent(state, { event = null, login = null } = {}) {
|
|
|
709
717
|
};
|
|
710
718
|
}
|
|
711
719
|
|
|
720
|
+
function hostOf(url) {
|
|
721
|
+
try {
|
|
722
|
+
return new URL(url).hostname;
|
|
723
|
+
} catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The outcome of one POST. A redirect is never followed, because the ingest
|
|
730
|
+
* token would ride along to wherever it points; its target host is reported
|
|
731
|
+
* instead, so a moved endpoint is visible rather than silent.
|
|
732
|
+
*/
|
|
712
733
|
async function post(endpoint, token, event) {
|
|
713
734
|
const ctrl = new AbortController();
|
|
714
735
|
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
715
736
|
try {
|
|
716
|
-
await fetch(endpoint, {
|
|
737
|
+
const res = await fetch(endpoint, {
|
|
717
738
|
method: "POST",
|
|
718
739
|
headers: {
|
|
719
740
|
"Content-Type": "application/json",
|
|
720
741
|
"X-Usage-Token": token,
|
|
721
742
|
},
|
|
722
743
|
body: JSON.stringify(event),
|
|
723
|
-
redirect: "
|
|
744
|
+
redirect: "manual",
|
|
724
745
|
signal: ctrl.signal,
|
|
725
746
|
});
|
|
726
|
-
|
|
727
|
-
|
|
747
|
+
if (res.status >= 300 && res.status < 400) {
|
|
748
|
+
return {
|
|
749
|
+
outcome: "redirected",
|
|
750
|
+
status: res.status,
|
|
751
|
+
location: hostOf(res.headers.get("location") || ""),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
if (!res.ok) return { outcome: "rejected", status: res.status };
|
|
755
|
+
let body = null;
|
|
756
|
+
try {
|
|
757
|
+
body = await res.json();
|
|
758
|
+
} catch {
|
|
759
|
+
/* a 2xx without a JSON body is still a delivery */
|
|
760
|
+
}
|
|
761
|
+
return { outcome: body?.accepted === false ? "not-accepted" : "accepted", status: res.status };
|
|
762
|
+
} catch (err) {
|
|
763
|
+
const reason = ctrl.signal.aborted ? "timeout" : (err?.cause?.code ?? err?.name ?? "error");
|
|
764
|
+
return { outcome: "failed", reason: codeOf(String(reason), 40) };
|
|
728
765
|
} finally {
|
|
729
766
|
clearTimeout(timer);
|
|
730
767
|
}
|
|
731
768
|
}
|
|
732
769
|
|
|
770
|
+
export const LAST_SEND_FILE = "usage-last-send.json";
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Overwrites the record of the most recent send attempt. The emitter runs
|
|
774
|
+
* detached with its output discarded, so this file is the only place a broken
|
|
775
|
+
* reporting path shows up; doctor reads it. It carries no token and no path.
|
|
776
|
+
*/
|
|
777
|
+
function recordSend(entry) {
|
|
778
|
+
const dir = join(homedir(), ".claude", "logs", "multi-agent");
|
|
779
|
+
const target = join(dir, LAST_SEND_FILE);
|
|
780
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
781
|
+
try {
|
|
782
|
+
mkdirSync(dir, { recursive: true });
|
|
783
|
+
writeFileSync(tmp, JSON.stringify({ t: new Date().toISOString(), ...entry }) + "\n", {
|
|
784
|
+
mode: 0o600,
|
|
785
|
+
});
|
|
786
|
+
renameSync(tmp, target);
|
|
787
|
+
} catch {
|
|
788
|
+
/* logs dir unwritable - never break the run */
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
733
792
|
// The write-only ingest token rides in a header; sending it over http would
|
|
734
793
|
// leak it in cleartext, contradicting the setup contract ("transmitted only
|
|
735
794
|
// over TLS"). Accept https anywhere, and http ONLY for loopback (local dev).
|
|
@@ -759,10 +818,16 @@ async function main() {
|
|
|
759
818
|
if (!args.dryRun) {
|
|
760
819
|
if (prefs.optOut === true) return;
|
|
761
820
|
if (prefs.enabled !== true) return;
|
|
762
|
-
if (!endpointAllowed(endpoint))
|
|
821
|
+
if (!endpointAllowed(endpoint)) {
|
|
822
|
+
recordSend({ id: args.taskId, outcome: "skipped", reason: "endpoint-not-allowed" });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
763
825
|
}
|
|
764
826
|
const token = args.dryRun ? "" : resolveToken();
|
|
765
|
-
if (!args.dryRun && !token)
|
|
827
|
+
if (!args.dryRun && !token) {
|
|
828
|
+
recordSend({ id: args.taskId, outcome: "skipped", reason: "no-token" });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
766
831
|
|
|
767
832
|
const statePath = resolveStatePath(args);
|
|
768
833
|
let state = statePath ? readJson(statePath) : null;
|
|
@@ -782,7 +847,10 @@ async function main() {
|
|
|
782
847
|
const who = resolveReportingLogin();
|
|
783
848
|
if (!who.login) {
|
|
784
849
|
process.stderr.write(`usage-report: not sent - ${who.reason}\n`);
|
|
785
|
-
if (!args.dryRun)
|
|
850
|
+
if (!args.dryRun) {
|
|
851
|
+
recordSend({ id: runId(state), ev: kind, outcome: "skipped", reason: "no-login" });
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
786
854
|
}
|
|
787
855
|
|
|
788
856
|
const event = buildEvent(state, { event: kind, login: who.login });
|
|
@@ -793,7 +861,14 @@ async function main() {
|
|
|
793
861
|
}
|
|
794
862
|
|
|
795
863
|
if (event.st !== "in_progress") appendErrorLedger(event);
|
|
796
|
-
await post(endpoint, token, event);
|
|
864
|
+
const result = await post(endpoint, token, event);
|
|
865
|
+
recordSend({ id: event.id, ev: event.ev, host: hostOf(endpoint), ...result });
|
|
866
|
+
if (result.outcome !== "accepted") {
|
|
867
|
+
const detail = result.status ?? result.reason;
|
|
868
|
+
const where = result.location ? ` to ${result.location}` : "";
|
|
869
|
+
const verb = result.outcome === "not-accepted" ? "not accepted" : result.outcome;
|
|
870
|
+
process.stderr.write(`usage-report: ${verb}${where} (${detail}) by ${hostOf(endpoint)}\n`);
|
|
871
|
+
}
|
|
797
872
|
}
|
|
798
873
|
|
|
799
874
|
// Entry-point check: compare file URLs, never string-split a path.
|
|
@@ -42,6 +42,15 @@
|
|
|
42
42
|
|
|
43
43
|
import { readFileSync } from "node:fs";
|
|
44
44
|
import { runMain } from "../lib/fatal.mjs";
|
|
45
|
+
import { redesignSections, sectionBody, tableDataRows } from "../lib/analysis-sections.mjs";
|
|
46
|
+
import {
|
|
47
|
+
businessLayerTechnical,
|
|
48
|
+
citedUndefined,
|
|
49
|
+
designFrameUnmapped,
|
|
50
|
+
exceptionFlow,
|
|
51
|
+
missingInputsFinal,
|
|
52
|
+
scopeOverlap,
|
|
53
|
+
} from "../lib/analysis-quality.mjs";
|
|
45
54
|
|
|
46
55
|
// A repo-less run (Locked 34) still splits by channel, derived from the evidence:
|
|
47
56
|
// "mobile" and "web" are its platform values. "none" is the narrower case where the
|
|
@@ -51,7 +60,7 @@ const KNOWN_PLATFORMS = new Set(["ios", "android", "web", "backend", "mobile", "
|
|
|
51
60
|
const REQUIRED_FM = ["feature", "platform", "language", "mode", "template_version"];
|
|
52
61
|
|
|
53
62
|
// Never-omitted sections (Locked 2), matched by bilingual title keyword so the
|
|
54
|
-
//
|
|
63
|
+
// profile's own numbering does not matter.
|
|
55
64
|
const REQUIRED_SECTIONS = [
|
|
56
65
|
{ key: "summary", any: ["Summary", "Özet", "Ozet"] },
|
|
57
66
|
{ key: "goals", any: ["Goals", "Hedefler"] },
|
|
@@ -108,6 +117,43 @@ const BANNED_PUNCT = [
|
|
|
108
117
|
// that blocked a correctly closed document. English has the same trap in
|
|
109
118
|
// "reopened" and "opened".
|
|
110
119
|
const OPEN_STATUS = ["açık", "acik", "open", "girdi bekleniyor", "pending input"];
|
|
120
|
+
// Words that make a requirement untestable because they carry no threshold.
|
|
121
|
+
// Matched as whole words in either language, case-insensitively.
|
|
122
|
+
const VAGUE_WORDS = [
|
|
123
|
+
"appropriate",
|
|
124
|
+
"appropriately",
|
|
125
|
+
"as needed",
|
|
126
|
+
"as appropriate",
|
|
127
|
+
"user-friendly",
|
|
128
|
+
"fast",
|
|
129
|
+
"quickly",
|
|
130
|
+
"etc.",
|
|
131
|
+
"and/or",
|
|
132
|
+
"various",
|
|
133
|
+
"if possible",
|
|
134
|
+
"reasonable",
|
|
135
|
+
"uygun şekilde",
|
|
136
|
+
"gerektiğinde",
|
|
137
|
+
"gerekirse",
|
|
138
|
+
"hızlı",
|
|
139
|
+
"hızlıca",
|
|
140
|
+
"vb.",
|
|
141
|
+
"vs.",
|
|
142
|
+
"çeşitli",
|
|
143
|
+
"mümkünse",
|
|
144
|
+
"kullanıcı dostu",
|
|
145
|
+
"makul",
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
function vagueWords(text) {
|
|
149
|
+
const found = [];
|
|
150
|
+
for (const w of VAGUE_WORDS) {
|
|
151
|
+
const esc = w.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
152
|
+
if (new RegExp(`(?<![\\p{L}\\p{N}])${esc}(?![\\p{L}\\p{N}])`, "iu").test(text)) found.push(w);
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
|
|
111
157
|
function isOpenStatus(cell) {
|
|
112
158
|
return String(cell)
|
|
113
159
|
.split("/")
|
|
@@ -141,51 +187,6 @@ function parseFrontMatter(text) {
|
|
|
141
187
|
return { fm, bodyStart: end + 1 };
|
|
142
188
|
}
|
|
143
189
|
|
|
144
|
-
function sectionBody(lines, keywords, level = 2) {
|
|
145
|
-
// Opened by a heading at exactly `level`; closed by the next heading at that
|
|
146
|
-
// level OR ANY SHALLOWER one. A sub-section that is the last of its parent has
|
|
147
|
-
// no sibling after it, so terminating only on equal depth runs the body into
|
|
148
|
-
// the following section and audits its rows as if they belonged here.
|
|
149
|
-
const open = new RegExp(`^#{${level}}\\s+\\d+(\\.\\d+)*\\.?\\s`);
|
|
150
|
-
const close = new RegExp(`^#{1,${level}}\\s`);
|
|
151
|
-
let start = -1;
|
|
152
|
-
for (let i = 0; i < lines.length; i++) {
|
|
153
|
-
if (open.test(lines[i]) && keywords.some((k) => lines[i].includes(k))) {
|
|
154
|
-
start = i;
|
|
155
|
-
break;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (start < 0) return null;
|
|
159
|
-
let end = lines.length;
|
|
160
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
161
|
-
if (close.test(lines[i])) {
|
|
162
|
-
end = i;
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return lines.slice(start + 1, end);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Data rows are the pipe-rows that follow a table's separator row, which is the
|
|
170
|
-
// only shape that distinguishes them from the header without guessing at cell text.
|
|
171
|
-
function tableDataRows(bodyLines) {
|
|
172
|
-
const rows = [];
|
|
173
|
-
let afterSeparator = false;
|
|
174
|
-
for (const line of bodyLines) {
|
|
175
|
-
const t = line.trim();
|
|
176
|
-
if (!t.startsWith("|")) {
|
|
177
|
-
afterSeparator = false;
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
if (/^\|[\s\-:|]+\|$/.test(t)) {
|
|
181
|
-
afterSeparator = true;
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
if (afterSeparator) rows.push(t);
|
|
185
|
-
}
|
|
186
|
-
return rows;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
190
|
// The report registry. `--report` prints one line per check, and the reason a
|
|
190
191
|
// check did not run is part of the answer: the traceability matrix is corporate
|
|
191
192
|
// only, so on a global document it never executes - and until this existed the
|
|
@@ -763,29 +764,42 @@ function main() {
|
|
|
763
764
|
|
|
764
765
|
mark("rule-to-test");
|
|
765
766
|
|
|
767
|
+
// 2g-2. Vague wording. A requirement two readers can pass differently is not
|
|
768
|
+
// testable, and these words carry no threshold. Only id-bearing requirement
|
|
769
|
+
// rows are read; summary prose may speak loosely. Code spans are skipped.
|
|
770
|
+
{
|
|
771
|
+
const ROW_ID = /\b(BR-[a-z0-9]+(?:-[a-z0-9]+)*-\d+|IG-\d{2,3}|FG-\d{2,3})\b/i;
|
|
772
|
+
for (const line of allLines) {
|
|
773
|
+
const t = line.trim();
|
|
774
|
+
if (!t.startsWith("|")) continue;
|
|
775
|
+
const id = t.match(ROW_ID);
|
|
776
|
+
if (!id) continue;
|
|
777
|
+
const words = vagueWords(t.replace(/`[^`]*`/g, ""));
|
|
778
|
+
if (words.length) {
|
|
779
|
+
warns.push(
|
|
780
|
+
`vague wording in ${id[1]}: ${words.map((w) => `"${w}"`).join(", ")} - state a threshold, a list or a condition instead`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
mark("vague-wording");
|
|
785
|
+
}
|
|
786
|
+
|
|
766
787
|
// 2h. Redesign mode (Locked 36). Eight checks, live only when the front-matter
|
|
767
788
|
// opts in. The contract they enforce is analysis/redesign.md, which loads only
|
|
768
789
|
// on a redesign run; what is here is the enforcement, not the explanation.
|
|
769
790
|
const redesign = String(parsed?.fm?.redesign || "false").toLowerCase() === "true";
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
);
|
|
775
|
-
const epBody = sectionBody(
|
|
776
|
-
allLines,
|
|
777
|
-
["Endpoint Eşlemesi", "Endpoint Eslemesi", "Endpoint Mapping"],
|
|
778
|
-
3,
|
|
779
|
-
);
|
|
780
|
-
const dlBody = sectionBody(allLines, ["Fark Listesi", "Difference List"], 3);
|
|
791
|
+
const rd = redesignSections(allLines, profile);
|
|
792
|
+
const cbBody = rd.cb.body;
|
|
793
|
+
const epBody = rd.ep.body;
|
|
794
|
+
const dlBody = rd.dl.body;
|
|
781
795
|
if (redesign) {
|
|
782
796
|
// 1. The sections themselves. Without this every row check below passes over
|
|
783
797
|
// nothing, which is the specific defect that shows a gate green on an empty
|
|
784
798
|
// document - the same shape the corporate matrix check guards against.
|
|
785
799
|
for (const [body, name] of [
|
|
786
|
-
[cbBody,
|
|
787
|
-
[epBody,
|
|
788
|
-
[dlBody,
|
|
800
|
+
[cbBody, rd.cb.label],
|
|
801
|
+
[epBody, rd.ep.label],
|
|
802
|
+
[dlBody, rd.dl.label],
|
|
789
803
|
]) {
|
|
790
804
|
if (!body) errors.push(`redesign: true but Section ${name} is missing (Locked 36)`);
|
|
791
805
|
}
|
|
@@ -866,7 +880,7 @@ function main() {
|
|
|
866
880
|
const cellCount = (line) =>
|
|
867
881
|
line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").length;
|
|
868
882
|
if (!header) {
|
|
869
|
-
errors.push(
|
|
883
|
+
errors.push(`Section ${rd.ep.label} has no v1/v2 header row (Locked 36)`);
|
|
870
884
|
} else {
|
|
871
885
|
const cols = cellCount(header);
|
|
872
886
|
for (const row of tableDataRows(epBody)) {
|
|
@@ -883,8 +897,8 @@ function main() {
|
|
|
883
897
|
//
|
|
884
898
|
// The heading match alone is not enough to say that. sectionBody tests the
|
|
885
899
|
// keyword as a raw substring of the heading line - deliberately, since Locked
|
|
886
|
-
// 2
|
|
887
|
-
//
|
|
900
|
+
// 2 drops sections and the corporate profile numbers them differently - so
|
|
901
|
+
// an unrelated `### 4.9 Notes on the old Current Behaviour
|
|
888
902
|
// audit process` matched, and Phase 3 runs --strict, which turns this warning
|
|
889
903
|
// into a blocked document. The artefact itself is the honest signal: a
|
|
890
904
|
// rendered current-behaviour table always carries `CB-<slug>-NN` ids, and a
|
|
@@ -900,6 +914,43 @@ function main() {
|
|
|
900
914
|
mark("redesign-artifacts", "not a redesign");
|
|
901
915
|
}
|
|
902
916
|
|
|
917
|
+
// 2e. Document quality (analysis-quality.mjs): each check returns its own
|
|
918
|
+
// errors and warnings, so it is testable without this file.
|
|
919
|
+
{
|
|
920
|
+
const isFinal = (parsed?.fm?.status || "draft").toLowerCase() === "final";
|
|
921
|
+
const corporate = profile === "corporate";
|
|
922
|
+
const run = (id, check, skipped) => {
|
|
923
|
+
if (!skipped) {
|
|
924
|
+
const r = check();
|
|
925
|
+
errors.push(...r.errors);
|
|
926
|
+
warns.push(...r.warns);
|
|
927
|
+
}
|
|
928
|
+
mark(id, skipped);
|
|
929
|
+
};
|
|
930
|
+
run(
|
|
931
|
+
"exception-flow",
|
|
932
|
+
() => exceptionFlow(allLines, { final: isFinal }),
|
|
933
|
+
corporate ? undefined : "global profile",
|
|
934
|
+
);
|
|
935
|
+
run("scope-overlap", () => scopeOverlap(allLines));
|
|
936
|
+
run("cited-undefined", () => citedUndefined(allLines));
|
|
937
|
+
run(
|
|
938
|
+
"design-frame-unmapped",
|
|
939
|
+
() => designFrameUnmapped(allLines),
|
|
940
|
+
corporate ? undefined : "global profile",
|
|
941
|
+
);
|
|
942
|
+
run(
|
|
943
|
+
"business-layer-technical",
|
|
944
|
+
() => businessLayerTechnical(allLines),
|
|
945
|
+
corporate ? "corporate profile" : undefined,
|
|
946
|
+
);
|
|
947
|
+
run(
|
|
948
|
+
"missing-inputs",
|
|
949
|
+
() => missingInputsFinal(allLines, { final: isFinal }),
|
|
950
|
+
isFinal ? undefined : "draft document",
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
903
954
|
// 3. Humanizer punctuation. Scope mirrors the humanizer pass exactly
|
|
904
955
|
// (analysis/render.md): front-matter, fenced code blocks, table rows and
|
|
905
956
|
// URLs are exempt there, so flagging them here would raise an ERROR that no
|
|
@@ -937,11 +988,10 @@ function main() {
|
|
|
937
988
|
|
|
938
989
|
// 4b. Locked 2's numbering half.
|
|
939
990
|
//
|
|
940
|
-
//
|
|
941
|
-
// threads ids across the document by section
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
// section. No emitted document ever re-flowed. The rule was the wrong half.
|
|
991
|
+
// Rendered sections keep their canonical template numbers and may leave
|
|
992
|
+
// gaps, because Locked 30 threads ids across the document by section NUMBER
|
|
993
|
+
// ("Section 15.1 scenario", "Section 4.4", "Section 3/5 layout cells"), and
|
|
994
|
+
// renumbering would send every one of those references to the wrong section.
|
|
945
995
|
//
|
|
946
996
|
// What holds instead: a rendered section keeps its canonical template number,
|
|
947
997
|
// so gaps are correct and `1, 2, 4, 9, 13, 14, 21` is a valid document. Two
|
|
@@ -1018,8 +1068,8 @@ function main() {
|
|
|
1018
1068
|
// audited - "used subset" means nothing without the set it is a subset of.
|
|
1019
1069
|
let inVariant = false;
|
|
1020
1070
|
for (const line of text.split("\n")) {
|
|
1021
|
-
// Keyed on the heading text, not the number:
|
|
1022
|
-
// a literal "6.X" match
|
|
1071
|
+
// Keyed on the heading text, not the number: the corporate profile numbers
|
|
1072
|
+
// this section differently, so a literal "6.X" match would skip it there.
|
|
1023
1073
|
if (/^#{2,3}\s+\d+(\.[0-9X]+)*\.?\s/.test(line) && /Varyant|Variant/i.test(line)) {
|
|
1024
1074
|
inVariant = true;
|
|
1025
1075
|
continue;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "1.0.0",
|
|
3
|
-
"generatedAt": "2026-
|
|
3
|
+
"generatedAt": "2026-10-01T20:47:44Z",
|
|
4
4
|
"skillCount": 218,
|
|
5
5
|
"entries": [
|
|
6
6
|
{
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "shared/core/multi-agent-analysis-jira/SKILL.md",
|
|
16
|
-
"sha256": "
|
|
16
|
+
"sha256": "130543d1017115de351a1175b6b179778642ba0b3207034b54114b39d0546407"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "shared/core/multi-agent-analysis-resolve/SKILL.md",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"path": "shared/core/multi-agent-analysis/SKILL.md",
|
|
24
|
-
"sha256": "
|
|
24
|
+
"sha256": "a7cf677df00433583d5e2e1578223d1e1797a1bd4d07ff0d1d8596d94baa013a"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"path": "shared/core/multi-agent-autopilot-off/SKILL.md",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "shared/core/multi-agent-autopilot/SKILL.md",
|
|
40
|
-
"sha256": "
|
|
40
|
+
"sha256": "a9731771fbc1f45260f214f069b976eff16e7b25e2f1375b0d9a213575271bc6"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "shared/core/multi-agent-build-optimize/SKILL.md",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"path": "shared/core/multi-agent-doctor/SKILL.md",
|
|
68
|
-
"sha256": "
|
|
68
|
+
"sha256": "2f6222cdef9c6b2b4e2e330ba0acee33ab0fb797029bb16c50bbb244be5fcbe6"
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
"path": "shared/core/multi-agent-feedback/SKILL.md",
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
"path": "shared/core/multi-agent-review-analysis/SKILL.md",
|
|
148
|
-
"sha256": "
|
|
148
|
+
"sha256": "5f90d2da227e1ffb7fedd00c3a0d094bb0e0e32a26aa25a7ec503ff6e4865503"
|
|
149
149
|
},
|
|
150
150
|
{
|
|
151
151
|
"path": "shared/core/multi-agent-review-issue/SKILL.md",
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
},
|
|
158
158
|
{
|
|
159
159
|
"path": "shared/core/multi-agent-review/SKILL.md",
|
|
160
|
-
"sha256": "
|
|
160
|
+
"sha256": "f1bad66314a55f35b7ac056a2f97e44c30702bebfbacc886b8efe7887eec5685"
|
|
161
161
|
},
|
|
162
162
|
{
|
|
163
163
|
"path": "shared/core/multi-agent-route-off/SKILL.md",
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
},
|
|
202
202
|
{
|
|
203
203
|
"path": "shared/core/multi-agent-setup/SKILL.md",
|
|
204
|
-
"sha256": "
|
|
204
|
+
"sha256": "38fdbe979ab2da87dcee55d1ce988cbda5bab92f5f751f448e2e64b18113c50e"
|
|
205
205
|
},
|
|
206
206
|
{
|
|
207
207
|
"path": "shared/core/multi-agent-stack/SKILL.md",
|